Skip to main content
Glama

docker-mcp-server

More than just a fully featured MCP server that lets AI agents manage Docker — containers, images, networks, volumes, swarm services, secrets, configs, nodes, plugins, etc., it helps you create workflows to easily manage your Docker environments.

For simple cases, you can just install and go with no configuration required - once loaded it will discover your local Docker socket and expose the full command surface to your AI agent. For more advanced users it can manage multiple Docker daemons, e.g. both your local dev environment and also a remote production environment over TCP, TLS or SSH in a single session. It can also be configured to mark some daemons as read-only, so you can monitor them without the risk of making accidental changes.

The MCP server also exposes things like logs and stats as resources so that you can monitor and triage, enabling you to answer questions like 'why did my container crash?', 'what is the state of my swarm?', 'am I suffering memory pressure?', 'what is the disk usage of my volumes?', 'what differences are there between my test and production systems?', and more...

docker-mcp-server is optimized to work efficiently with the new generation of MCP clients that support lazy tool loading. For clients that still eagerly load all tools, the server can optionally be configured to exclude tools from a subset of domains (e.g. exclude 'swarm' and 'scout' tools) to reduce the tool list size. It's also possible to put the MCP server into 'read-only' or 'no-destructive' modes that prevent any tools with write or destructive capabilities from being registered, which again reduces the footprint.

The server runs entirely on your machine, either natively, as an mcpb bundle, or containerized, and sends no telemetry. You are entirely in control — see the Privacy Policy.

Requirements

Note: If you're using the containerized MCP server or MCPB bundle, the Python and uv requirements are taken care of for you.

  • A running Docker daemon reachable from the host that runs the server (the standard DOCKER_HOST / unix socket conventions apply)

  • Python ≥ 3.14

  • uv for dependency management

Related MCP server: Docker MCP

Using the server

The server is published to PyPI as docker-mcp-server. Add an entry to your AI tool's MCP configuration (commonly mcp.json or the equivalent in your client) pointing uvx at it — uv will fetch and cache the package on first use:

{
  "mcpServers": {
    "docker-mcp-server": {
      "command": "uvx",
      "args": ["docker-mcp-server"],
      "env": {}
    }
  }
}

To pin a specific version, append ==<version> to the package name (e.g. docker-mcp-server==1.5.0). If you'd rather install it onto your PATH, pipx install docker-mcp-server gives you the docker-mcp-server console script (a docker-mcp alias is also installed).

Installing from git instead. To run an unreleased revision straight from this repository:

{
  "mcpServers": {
    "docker-mcp-server": {
      "command": "uvx",
      "args": [
        "--from",
        "git+https://github.com/GavinLucas/docker-mcp.git",
        "docker-mcp-server"
      ],
      "env": {}
    }
  }
}

To pin a specific revision, append @<tag-or-commit> to the git URL.

Install as a Desktop Extension (.mcpb)

For Claude Desktop, a one-click bundle is attached to each GitHub Release as docker-mcp-server-<version>.mcpb (with a matching .sha256). Download it and drag it into Settings → Extensions, or use Settings → Extensions → Advanced settings → Install extension… and pick the file. The install dialog surfaces a Docker host(s) field and the read-only / no-destructive / disabled-domain switches, so no manual JSON editing is needed.

It's a uv-type bundle: Claude Desktop's managed uv resolves the dependencies and runs the server, so the only host prerequisite is Docker itself — no separate Python, uv, or git. Leave the Docker host(s) field blank to use your default Docker context; set one endpoint (ssh://user@host) for a remote daemon, or list several (see Managing several daemons).

Run as a container

Running the server as a container removes the Python / uv / git prerequisites entirely — the only thing the host needs is Docker, which you already have. Prebuilt multi-arch images (linux/amd64 + linux/arm64) are published on each release to Docker Hub (gavinlucas/docker-mcp-server) and GHCR (ghcr.io/gavinlucas/docker-mcp-server) — the two are identical. Point your MCP client at docker run:

{
  "mcpServers": {
    "docker-mcp-server": {
      "command": "docker",
      "args": [
        "run", "--rm", "-i",
        "-v", "/var/run/docker.sock:/var/run/docker.sock",
        "gavinlucas/docker-mcp-server:latest"
      ],
      "env": {}
    }
  }
}

-i is required (the server speaks MCP over stdio); --rm cleans up when the client disconnects. To pin a version, replace :latest with a release tag (e.g. :1.5.1). To pull from GHCR instead, use ghcr.io/gavinlucas/docker-mcp-server:latest.

Image renamed. As of 1.5.0 the image is published to ghcr.io/gavinlucas/docker-mcp-server (matching the PyPI name). The old ghcr.io/gavinlucas/docker-mcp image is frozen at 1.4.0 and no longer updated — point new pulls at …/docker-mcp-server.

Image variants. Two variants are published to both registries (gavinlucas/docker-mcp-server on Docker Hub and ghcr.io/gavinlucas/docker-mcp-server on GHCR), both built from one Dockerfile. The CLI-backed domains (Compose, Stack, Buildx, Scout, Context) shell out to the docker CLI and its plugins.

Variant

Tags

Approx. size

Includes

full (default)

:latest, :<version>

~510 MB

docker CLI + compose + buildx + scout

no-scout

:no-scout, :<version>-no-scout

~315 MB

docker CLI + compose + buildx

Scout's plugin binary alone accounts for the ~195 MB jump from no-scout to full. The no-scout image also defaults DOCKER_MCP_SERVER_DISABLE=scout, so the scout tools don't register — the agent is never offered tools whose CLI plugin isn't present (it sees a smaller, fully-working tool list rather than scout tools that error on every call). Override at runtime with -e DOCKER_MCP_SERVER_DISABLE=... if you ever need to change the disabled set (note it replaces, not appends).

Building it yourself. All variants build from the repo's Dockerfile via build args:

docker build -t docker-mcp-server:full .                                    # full (default)
docker build --build-arg INSTALL_SCOUT=0 --build-arg DISABLE_DOMAINS=scout \
  -t docker-mcp-server:no-scout .                                           # no-scout
docker build --build-arg INSTALL_CLI=0 -t docker-mcp-server:lite .          # lite (SDK-only, ~165 MB)

The lite image (docker-py SDK tools only — Compose/Buildx/Scout/Context degrade to "plugin unavailable") is buildable but not published.

Reaching the daemon from inside the container. The image defaults DOCKER_HOST to unix:///var/run/docker.sock, so mounting your host's socket onto that path is all that's needed. Where the host socket is, however, varies — and the server prints a platform-aware hint to stderr if it can't connect at startup:

  • Linux: -v /var/run/docker.sock:/var/run/docker.sock (rootless: -v $XDG_RUNTIME_DIR/docker.sock:/var/run/docker.sock).

  • macOS (Docker Desktop): the real socket is usually ~/.docker/run/docker.sock — mount it onto the in-container path: -v $HOME/.docker/run/docker.sock:/var/run/docker.sock (or enable Settings → Advanced → Allow the default Docker socket and use /var/run/docker.sock).

  • Windows (Docker Desktop / WSL2): the engine uses a named pipe, not a Unix socket — prefer -e DOCKER_HOST=tcp://host.docker.internal:2375 (enable the TCP endpoint in Docker Desktop). That endpoint is unauthenticated and unencrypted — keep it bound to localhost, disable it when you're not using it, and use TLS or DOCKER_HOST=ssh://... for any remote daemon.

  • Remote / TLS / SSH daemon: skip the socket mount and pass -e DOCKER_HOST=... (plus the TLS vars below) — see Talking to a remote daemon.

Host filesystem access. Inside a container, the file-path tools (save_image_to_file, load_image_from_file, export_container_to_file, the container-archive *_to_file / *_from_file variants, and compose project_dir / files) resolve paths inside the container, not on your host. Bind-mount any directory you want to exchange files through — using the same path inside and out keeps host and container paths identical:

-v $HOME/docker-work:$HOME/docker-work

If you call one of these tools with a path that isn't on a bind mount, the server refuses up front with a message telling you exactly which -v to add — a write to an unmapped path would otherwise be silently discarded when the container exits. (The in-band byte tools, capped at 32 MiB, need no mount.) Configuration env vars (DOCKER_MCP_SERVER_READONLY, DOCKER_HOST, etc.) go in the client's env block exactly as for the uvx install.

Talking to a remote daemon

When DOCKER_HOST is set the server uses it directly (via docker.from_env(), so DOCKER_TLS_VERIFY / DOCKER_CERT_PATH are honoured too). Common overrides via env:

"env": {
  "DOCKER_HOST": "tcp://remote-host:2375",
  "DOCKER_TLS_VERIFY": "1",
  "DOCKER_CERT_PATH": "/path/to/certs"
}

Default daemon (no DOCKER_HOST). With DOCKER_HOST unset, the server resolves the daemon the way the docker CLI does, rather than assuming /var/run/docker.sock: it follows the active Docker context (DOCKER_CONTEXT, else currentContext from ~/.docker/config.json, reading the endpoint from that context's meta.json), and if that yields nothing it probes the well-known socket locations (~/.docker/run/docker.sock for Docker Desktop 4.13+, $XDG_RUNTIME_DIR/docker.sock for rootless, then /var/run/docker.sock). This matters because docker.from_env() alone ignores contexts and would fall back to /var/run/docker.sock — which Docker Desktop 4.13+ no longer creates by default (it uses the desktop-linux context unless you enable Settings → Advanced → Allow the default Docker socket), so a stock Desktop install reachable by your CLI would otherwise fail here. Precedence: a non-empty DOCKER_HOST always wins and goes straight through docker.from_env() (which ignores contexts); DOCKER_CONTEXT / currentContext is consulted only when DOCKER_HOST is unset, and the socket probe only when neither resolves. TLS material attached to a remote context is not applied automatically — a tcp:// + TLS context still needs DOCKER_HOST / DOCKER_CERT_PATH.

Over SSH. DOCKER_HOST=ssh://user@remote-host is supported via a pure-Python transport (paramiko, pulled in by the docker[ssh] dependency) — there is no system ssh binary requirement, so it works the same on the host install and inside the container images. It authenticates with your normal SSH setup:

  • Keys / agent. Use key-based auth; load the key into your agent (ssh-add) and make sure SSH_AUTH_SOCK is set in the server's environment (or place the key at the default ~/.ssh/id_* path).

  • Known hosts. paramiko verifies the host key against ~/.ssh/known_hosts and rejects an unknown host. Add the host key only after verifying its fingerprint through a trusted channel — connect once interactively with ssh user@remote-host and confirm the prompt, or compare ssh-keyscan remote-host | ssh-keygen -lf - against a known-good fingerprint before appending it. Avoid blindly piping ssh-keyscan straight into known_hosts, which trusts whatever key is returned (including a MITM's).

  • In a container. Mount your SSH material read-only — -v $HOME/.ssh:/root/.ssh:ro (key + known_hosts) — or forward your agent socket; no socket mount and no ssh package needed.

CLI-backed tools (Compose, Buildx, Context, Scout) shell out to the docker CLI, which would otherwise use the system ssh binary over an ssh:// endpoint. Instead, run_docker() detects DOCKER_HOST=ssh://... and transparently starts a per-call local TCP proxy (docker_mcp/tools/_ssh_proxy.py) that opens the same paramiko connection docker-py would, runs docker system dial-stdio over it, and points the CLI subprocess at tcp://127.0.0.1:<ephemeral port> for the duration of that one call. So the CLI-backed tools authenticate with the exact same credentials and host-key policy as the docker-py-backed tools above — no system ssh binary for the direct connection, identical on the host install and inside the container images. The one exception is a ProxyCommand in ~/.ssh/config (bastion/jump-host setups): paramiko runs that command as-given, and it's commonly ssh -W %h:%p ..., so a jump-host hop still shells out to the system ssh client even though the direct connection does not.

That ephemeral 127.0.0.1 listener bridges to the remote (root-equivalent) daemon with your SSH credentials for the duration of a single CLI call, so any process sharing the same loopback could reach it during that brief window. The exposure is narrow — localhost-only and torn down when the call returns — and inside a container it's narrower still, reachable only by processes within that container's network namespace. The daemon remains the trust boundary either way (see Security considerations).

Managing several daemons

Everything above targets one daemon. To manage several in a single session — e.g. local dev plus a remote production daemon — set DOCKER_MCP_SERVER_HOSTS to a comma-separated list of name=endpoint pairs:

"env": {
  "DOCKER_MCP_SERVER_HOSTS": "local=auto, prod=ssh://ops@prod.example.com(ro)"
}
  • endpoint is auto (your default context/socket, as above), local (the platform-local socket, ignoring contexts), or a unix:// / tcp:// / ssh:// / npipe:// URL. ssh:// is the recommended remote transport (per-host auth via your SSH keys, no TLS cert plumbing). A tcp:// daemon over TLS takes a (tls=<dir>) marker pointing at a cert directory, e.g. prod=tcp://prod:2376(tls=/etc/docker/prod). That directory must hold ca.pem (the daemon is always verified against it — so a self-signed daemon works, you just pin its cert here); add cert.pem and key.pem only if the daemon requires a client certificate (mutual TLS). There is no unverified-TLS mode — a TLS connection always authenticates the daemon, so encryption never comes without verification.

  • (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.

  • 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).

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). list_hosts (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.

What the agent can do

Once loaded, the agent gets MCP tools grouped by Docker domain. A few examples:

  • Containersrun_container, list_containers (managed_only=True to list only what this server created — see Provenance labels), exec_in_container, container_logs, stop_container, commit_container, wait_for_container_healthy (poll until a healthcheck passes), export_container_to_file / get_container_archive_to_file / put_container_archive_from_file (stream tar archives to/from a host path)

  • Imagesbuild_image, pull_image, push_image, tag_image, prune_images, save_image_to_file / load_image_from_file (stream image tarballs to/from a host path)

  • Networks / Volumescreate_network, connect_network, create_volume, prune_volumes

  • Swarminit_swarm, get_swarm_join_tokens (close the init → join loop), rotate_swarm_join_token, create_service, scale_service, rollback_service (re-apply the previous service spec), list_nodes, remove_node, create_secret, create_config

  • Systemping, info, version, df, events, list_hosts (the configured daemons and which is the default — see Managing several daemons), login / logout (cache or clear registry credentials), reconnect (rebuild a host's SDK client to recover a wedged connection)

  • Composecompose_up, compose_down, compose_stop, compose_start, compose_restart, compose_pause / compose_unpause, compose_kill, compose_ps, compose_ls, 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)

  • Stacksstack_deploy, stack_ls, stack_ps, stack_services, stack_rm (deploy a Compose file to a swarm as a stack; wraps the docker stack CLI — requires a swarm manager)

  • Contextscontext_ls, context_inspect, context_create, context_use, context_rm (wraps the docker context CLI)

  • Registry / Hubregistry_list_tags, registry_inspect_manifest, registry_get_config (read an image's env/entrypoint/labels without pulling), hub_list_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)

  • Buildxbuildx_build, buildx_bake, buildx_imagetools_inspect, buildx_imagetools_create, buildx_ls, buildx_inspect, buildx_du, buildx_history_ls / buildx_history_inspect (drill into past build records), buildx_prune, buildx_create, buildx_use, buildx_rm (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.

  • Scoutscout_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).

The SDK-backed surface mirrors the Docker SDK reference — if it's documented there, it's available here. The Compose and Context surfaces follow the Compose CLI and docker context references.

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.

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.

Example prompts

Many AI clients let you invoke registered MCP prompts directly (in Claude Code, type / to see them). The server ships a small library of templates in docker_mcp/tools/prompts.py that scaffold multi-step workflows — they emit a structured plan that the agent then carries out using the docker tools.

Looking things up in the SDK docs

/lookup_docker_docs section=services
/verify_docker_method method=containers.run section=containers

…or just ask in plain English:

Read docker-docs://networks and tell me the difference between create and connect. Before changing any code, check docker-docs://containers and confirm run accepts a restart_policy argument.

Creating and managing containers

/deploy_container image=nginx:1.27 name=web
/monitor_container_fleet
/triage_incident window_minutes=30
/troubleshoot_container container=api-1
/migrate_container container=api-1 new_image=myorg/api:v2
/inspect_stack label=com.example.app=web
/clean_environment scope=stopped
/plan_compose_stack description="wordpress + mysql sharing a named volume"

Compose, contexts, and registries

/deploy_compose_project project_dir=/srv/myapp
/troubleshoot_compose_project project_dir=/srv/myapp
/deploy_swarm_stack stack_name=web compose_file=/srv/myapp/docker-stack.yml
/audit_docker_contexts
/find_latest_image_tag image=ghcr.io/org/repo

Auditing, security, and host operations

/review_dockerfile dockerfile_path=/srv/myapp/Dockerfile
/audit_container_security
/debug_container_networking source=web target=db
/investigate_disk_usage
/backup_volume volume=pgdata dest_path=/backups/pgdata.tar
/restore_volume volume=pgdata source_path=/backups/pgdata.tar
/audit_swarm_health

Buildx, Scout, and multi-arch manifests

/plan_multiarch_build image=ghcr.io/org/app:v1 platforms=linux/amd64,linux/arm64
/audit_image_cves image=alpine:3.19
/compare_image_versions old_image=org/app:v1 new_image=org/app:v2
/recommend_base_image image=org/app:v1
/inspect_multiarch_manifest image=alpine:3.19
/create_multiarch_manifest target_tag=org/app:v1 source_tags=org/app:v1-amd64,org/app:v1-arm64
/migrate_from_docker_manifest

…or in plain English:

Pull redis:7-alpine and run it as a container called cache on a new app-net network, exposing port 6379 only inside that network. Container api-1 keeps restarting — grab the last 200 log lines, inspect its state and exit code, and tell me what's wrong before changing anything. Replace the running web container with nginx:1.27 while keeping its current ports, mounts, and restart policy. Plan a wordpress + mysql stack on a private network with a named volume for the database. Show me the plan before creating anything. Show every container, network, and volume tagged com.example.app=web as one table. Don't change anything. We're tight on disk — show df, prune stopped containers and dangling images, then show df again. Skip volumes. Bring up the compose project in /srv/myapp, but show me the rendered config and pull the images before starting anything. List my Docker contexts and tell me which daemon this MCP server is currently talking to. Find the most recent stable tag for ghcr.io/org/repo without pulling it, and tell me which platforms it supports.

Configuration

Env var rename (deprecated aliases honored). The server's environment variables are namespaced DOCKER_MCP_SERVER_* to match the published package name. The older DOCKER_MCP_* spellings still work as deprecated aliases — the canonical name wins when both are set, and reading via an old name logs a one-time deprecation notice to stderr. Update your config to the DOCKER_MCP_SERVER_* names; the aliases may be removed in a future release.

To choose which daemon(s) the server talks to, see Talking to a remote daemon and Managing several daemons (DOCKER_MCP_SERVER_HOSTS / DOCKER_HOST). The variables below instead restrict which tools are registered.

Three environment variables restrict which tools are registered when the server starts. Because they drop tools at registration time, a disabled tool never appears in the client's tool list — this is a server-side guarantee, not a client-side prompt. Set the two boolean switches to 1 / true / yes / on:

  • DOCKER_MCP_SERVER_READONLY — register only read-only tools (queries, log/data reads, scans). Every tool that changes state is omitted. Use this for monitoring or inspection agents that must not be able to modify anything.

  • DOCKER_MCP_SERVER_NO_DESTRUCTIVE — register everything except destructive tools (remove_*, prune_*, kill_container, compose_down, leave_swarm, context_rm, buildx_prune, buildx_rm). A "no data loss" mode that still allows creating and starting resources. DOCKER_MCP_SERVER_READONLY is stricter and wins if both are set.

  • DOCKER_MCP_SERVER_DISABLE — a comma-separated list of domains (feature areas) to drop wholesale, regardless of category: e.g. DOCKER_MCP_SERVER_DISABLE=swarm,services,nodes,configs,secrets removes the entire swarm surface from a single-host server, and DOCKER_MCP_SERVER_DISABLE=scout,buildx trims build/scan tooling an agent will never use. A domain is a tool module's name — containers, images, networks, volumes, compose, stack, context, buildx, scout, registry, swarm, services, nodes, plugins, configs, secrets, client. Names are case-insensitive; an unrecognized name is ignored (and surfaced as unknown_disabled_domains in the tool catalog, see below). This stacks with the category switches — a tool registers only if its category survives and its domain is enabled. Disabling a domain drops more than its tools: the matching workflow prompts are skipped (so the agent isn't handed a prompt that drives a feature area this server no longer exposes — e.g. disabling scout removes the audit_image_cves prompt that would otherwise tell the agent to call a tool that isn't registered) and the matching documentation resources are hidden from docker-docs://contents (e.g. the scout / scout-cli sections). The tool catalog's prompts list and disabled_doc_sections field make both auditable. Trimming domains an agent doesn't need also cuts the tool-list size the client has to reason about, which matters at this server's ~190-tool scale.

Independently, every registered tool carries MCP ToolAnnotationsreadOnlyHint on queries and destructiveHint on destructive operations (plus idempotentHint on the prune family) — so a client like Claude Code can auto-allow safe reads and gate destructive calls. The classification lives in TOOL_CATEGORIES in docker_mcp/server.py. To see the full picture at runtime — every tool with its domain, category, and whether the active switches registered it — read the docker-mcp://tool-catalog MCP resource.

For private registries, the HTTPS-backed registry_* tools fall back to DOCKER_MCP_SERVER_REGISTRY_USERNAME / DOCKER_MCP_SERVER_REGISTRY_PASSWORD from the server's environment when no explicit username/password arguments are passed (explicit arguments win; the env pair is only used when both arguments are unset). Setting credentials in the environment keeps them out of tool arguments, which many MCP clients log verbatim — the password may be a personal-access token.

Provenance labels

Every Docker object the agent creates through this server — containers, networks, volumes, swarm services, configs, and secrets — is stamped with a small set of docker-mcp-server.* labels recording that this server made it (docker-mcp-server.managed=true), the server version, the originating tool, and a creation timestamp. This lets you (or a cleanup job) later enumerate exactly the footprint the agent created with a single docker ... --filter label=docker-mcp-server.managed=true; the managed_only=True argument on list_containers, list_networks, list_volumes, and list_services is the in-tool shortcut (it combines with any other filters you pass). The stamping is additive (a label you pass yourself always wins on a key collision) and uniquely namespaced, so it's safe by default; DOCKER_MCP_SERVER_NO_LABELS=1 turns it off entirely. Image builds are deliberately not stamped, because a build label changes the resulting image digest.

To tear down only what the server created — and nothing else — use the prune_managed workflow prompt, which scopes every removal step to the docker-mcp-server.managed=true label (volumes only when you pass include_volumes=True, and only after confirmation).

Example: a read-only monitoring server

All of these go in the env block of the server entry in your MCP client config (the same place as DOCKER_HOST above). For example, a read-only inspection server against a remote daemon:

{
  "mcpServers": {
    "docker-mcp-server-readonly": {
      "command": "uvx",
      "args": [
        "--from",
        "git+https://github.com/GavinLucas/docker-mcp.git",
        "docker-mcp-server"
      ],
      "env": {
        "DOCKER_HOST": "tcp://staging-host:2376",
        "DOCKER_TLS_VERIFY": "1",
        "DOCKER_MCP_SERVER_READONLY": "1"
      }
    }
  }
}

Swap DOCKER_MCP_SERVER_READONLY for DOCKER_MCP_SERVER_NO_DESTRUCTIVE to allow create/start/deploy while still making remove_* / prune_* / kill_container impossible. You can also register the same server twice under different names — a full-access entry you enable when needed and a read-only entry for everyday use. With claude mcp (Claude Code), the equivalent is:

claude mcp add docker-mcp-server-readonly \
  --env DOCKER_MCP_SERVER_READONLY=1 \
  -- uvx --from git+https://github.com/GavinLucas/docker-mcp.git docker-mcp-server

Security considerations

Connecting this server to an AI agent grants it the same level of access as a local Docker CLI session against the configured daemon. That is broad: the daemon's socket is effectively root-equivalent on the host running it. Treat the agent as a privileged user and weigh the risks below before enabling the server.

  • Use a scoped daemon. Prefer pointing DOCKER_HOST at a daemon dedicated to workloads the agent is allowed to touch (a development VM, a remote sandbox, Docker Desktop, a rootless install) rather than your production socket. The daemon is the trust boundary — there is no per-tool authorization layer.

  • Running as a container. Mounting /var/run/docker.sock into the container grants it the same root-equivalent access to that daemon as the uvx install has — no more, no less, but now explicit in the docker run line. The same scoped-daemon advice applies: prefer mounting a socket for, or pointing DOCKER_HOST at, a daemon the agent is allowed to control. Note that when containerized the file-path tools read and write the container's filesystem, so they can only reach host directories you bind-mount in (see Run as a container). As an accident guard, the destructive container-lifecycle tools (remove_container, kill_container, stop_container, restart_container, pause_container) refuse to act on the server's own container so the agent can't end its own session mid-call; this is convenience, not a security boundary (it's bypassable with DOCKER_MCP_SERVER_ALLOW_SELF_TERMINATE=1, and a human can always recover the container from the host shell), and it does not constrain the many other ways a daemon-privileged agent can affect the host.

  • Privileged containers and host mounts. run_container accepts privileged=True and arbitrary volumes. A privileged container, or one that bind-mounts / from the host, can trivially escape to the host filesystem. Avoid letting the agent set these unless you have reviewed the request. Compose files can declare the same — review the rendered compose_config output before approving compose_up on an unfamiliar project.

  • Pass-through extra_kwargs / updates bypass the visible schema. run_container, create_container, create_service (extra_kwargs) and update_container, update_service (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.

  • Registry credentials. Many MCP clients log tool calls verbatim, so treat any password or auth_config you pass through a tool as exposed.

    • SDK-backed tools (login, push_image, get_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 login is cached in the server's memory for the life of the client; logout clears that in-memory cache (all registries, or one) without touching ~/.docker/config.json, and close / reconnect clear it by discarding the client. There is no daemon-side session to end — the Engine's /auth endpoint only validates.

    • HTTPS-backed registry tools (registry_list_tags, registry_inspect_manifest, registry_get_config, hub_list_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); 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.

  • 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: create_secret(data=...) and create_config(data=...) take the payload as an argument, get_secret / get_config return the stored object, join_swarm(join_token=...) and unlock_swarm(key=...) take cluster join/unlock secrets, and get_swarm_unlock_key, get_swarm_join_tokens, and rotate_swarm_join_token return cluster credentials — 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).

  • exec_in_container, 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.

  • Container archive paths. get_container_archive and put_container_archive 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.

  • File-path payload tools read and write the server host's filesystem. save_image_to_file, export_container_to_file, and get_container_archive_to_file write to a dest_path on the host running this MCP server (refusing to overwrite an existing file unless overwrite=True); load_image_from_file and put_container_archive_from_file read a host path; compose_cp copies between a service container and a host path in either direction. These run as the server's user, so the agent can write any file that user can write and read any file it can read. Prefer the in-band byte tools (capped at 32 MiB) when you don't trust the agent with host filesystem access. DOCKER_MCP_SERVER_READONLY also drops the host-writing variants — but note it is not targeted at them: it registers only read-only tools, so load_image_from_file and put_container_archive_from_file (and every other mutating/destructive tool) go too. There is no switch that drops just the file-writers.

  • Destructive operations have no built-in confirmation. prune_*, remove_*, kill_container, leave_swarm, compose_down(volumes=True), compose_kill, stack_rm (tears down every service in a stack), buildx_prune (always runs with --force), and buildx_rm execute immediately. These tools carry the destructiveHint annotation, so a client like Claude Code can gate them, and the shipped clean_environment prompt asks the agent to confirm before pruning volumes — but tool calls themselves are not gated by the server. For a hard guarantee, run with DOCKER_MCP_SERVER_NO_DESTRUCTIVE=1 (drops them entirely) or DOCKER_MCP_SERVER_READONLY=1 (see Configuration); for an approval step, configure it at the MCP client.

  • CLI shell-out attack surface. Compose, Context, Buildx, and Scout tools spawn docker subprocesses on the host running this MCP server. Every invocation passes arguments as a list (no shell, no metacharacter interpretation), resolves the binary via shutil.which, and runs against a scrubbed environment (DOCKER_HOST and related vars only). Positional values (image refs, service / context / builder names, build contexts) are additionally rejected if they start with -, so an argument can't be smuggled in as a CLI flag (e.g. a service named --output=…); the one deliberate exception is the trailing command in compose_exec / compose_run, which is meant to be an arbitrary argv. Filesystem paths supplied to compose_* (project_dir, files) are read by the docker CLI on the server host — passing an unfamiliar path can expose any compose file the server's user can read.

  • The daemon set is fixed at startup; pick it deliberately. When DOCKER_HOST / DOCKER_MCP_SERVER_HOSTS are unset, the server's initial SDK connection follows your active Docker context (DOCKER_CONTEXT / currentContext) — the same daemon your docker CLI targets — so if that context points at a remote or production daemon, the agent connects there too. Set DOCKER_MCP_SERVER_HOSTS (or DOCKER_HOST, or select a scoped context) before starting the server to pin the target(s) deliberately; with DOCKER_MCP_SERVER_HOSTS the auto/local endpoints are resolved and pinned at startup, so they can't drift if a context changes later. After startup, context_use only changes the CLI default for subsequent CLI-backed tools; SDK-backed tools keep using the daemon their pooled client connected to. There is no runtime way to introduce or retarget a daemon at an arbitrary endpointreconnect only rebuilds an already-configured host's client (to recover a wedged connection), it can't point it elsewhere; to add or change a daemon, edit DOCKER_MCP_SERVER_HOSTS and restart. This deliberately closes a trust-expansion vector (an agent can't move the root-equivalent boundary to an unvetted endpoint mid-session). context_create(skip_tls_verify=True) disables TLS verification for a context; use only against trusted local daemons.

  • Per-host read-only is an accident guard, not a security boundary. A host marked (ro) in DOCKER_MCP_SERVER_HOSTS makes mutating/destructive tools refuse to act on it at call time (and, with several hosts, writes require naming the target host explicitly — so the agent can't change the wrong daemon by omission). Like guard_not_self, this is in-process convenience: it constrains the agent through this server's tools, but the daemon itself is still the trust boundary, so for a host the agent must never modify, prefer pointing it at a genuinely read-only or scoped daemon over relying on the marker alone.

Privacy Policy

docker-mcp-server collects no data, sends no telemetry, and has no author-operated backend. It runs locally and talks only to the Docker daemon and container registries you point it at, as part of the operations you request. The full statement is in PRIVACY.md.

Contributing

Contributions are welcome. The project values a tight mapping between the Docker SDK's public surface and the MCP tools we expose.

Project layout

.
├── docker_mcp/            # the package — `python -m docker_mcp` runs the server
│   ├── __init__.py        # defines `main()`; side-effect-imports `server` and `tools`
│   ├── __main__.py        # calls `main()` so `python -m docker_mcp` works
│   ├── server.py          # creates the FastMCP singleton (`mcp`) shared by every tool module
│   └── tools/             # one file per Docker SDK domain or CLI/registry feature
│       ├── _cli.py        # cross-platform subprocess helper for docker CLI shell-outs (private)
│       ├── _utils.py      # shared helpers (drop_none, join_bounded, MAX_PAYLOAD_BYTES) (private)
│       ├── client.py      # DockerClient connection + lazy `_get_client()` helper
│       ├── containers.py
│       ├── images.py
│       ├── networks.py
│       ├── volumes.py
│       ├── configs.py
│       ├── secrets.py
│       ├── nodes.py
│       ├── services.py
│       ├── swarm.py
│       ├── plugins.py
│       ├── compose.py     # `docker compose` CLI plugin (shells out via _cli.py)
│       ├── stack.py       # `docker stack` (Compose-on-Swarm) CLI (shells out via _cli.py)
│       ├── context.py     # `docker context` CLI (shells out via _cli.py)
│       ├── buildx.py      # `docker buildx` CLI plugin (shells out via _cli.py)
│       ├── scout.py       # `docker scout` CLI plugin (shells out via _cli.py)
│       ├── registry.py    # OCI v2 registries + Docker Hub HTTPS APIs (no daemon)
│       ├── prompts.py     # @mcp.prompt() templates for common docker workflows
│       └── resources.py   # @mcp.resource() endpoints exposing SDK + CLI + registry docs
├── tests/                 # pytest suite, mirrors `docker_mcp/tools/` one-to-one
│   └── integration/       # tests that hit a real Docker daemon or docker.io
├── assets/                # bundle assets (e.g. the .mcpb icon) packed into the Desktop Extension
├── scripts/               # developer convenience scripts (not used by CI) — e.g. build-mcpb.sh
└── dist/                  # build output (git-ignored) — local .mcpb test bundles land here

Each docker_mcp/tools/<file>.py has a matching tests/test_<file>.py. New modules must be added to docker_mcp/tools/__init__.py and have a corresponding test file. Tool modules that wrap CLI features must funnel every subprocess call through docker_mcp/tools/_cli.py so the cross-platform safety concerns (binary discovery, no shell, UTF-8 decoding, output capping, Windows console suppression, env scrubbing) live in one place.

Conventions

Tool functions are decorated with @tool() (the project's wrapper around @mcp.tool(), imported from docker_mcp.server) and follow this docstring style:

from docker_mcp.server import tool


@tool()
def mcp_example(name: str):
    """
    Say hello to someone by name.

    args: name: str - The name to say hello to
    returns: str - The greeting
    """
    return f"Hello, {name}!"
  • Import tool from docker_mcp.server (and, for prompts/resources, mcp), never directly from the mcp package — that creates a circular import.

  • Every tool needs a TOOL_CATEGORIES entry in docker_mcp/server.py (READ_ONLY / MUTATING / DESTRUCTIVE); the central map drives the tool's ToolAnnotations and the read-only env switches, and tests/test_server.py fails if it drifts from the registered set. A tool's domain (for DOCKER_MCP_SERVER_DISABLE and the tool catalog) is derived automatically from its module name, so putting a tool in the right docker_mcp/tools/<domain>.py file is all that's needed.

  • Line length is 120 characters (enforced by ruff).

  • CLI shell-outs must go through docker_mcp/tools/_cli.py:run_docker — never call subprocess.run directly from a tool module. The helper enforces shell=False, resolves the binary via shutil.which (cross-platform), decodes output as UTF-8 with replace, caps the captured bytes, scrubs the environment, and suppresses console pop-ups on Windows.

Checklist when adding a new tool module

When you add a new docker_mcp/tools/<domain>.py, also update:

  1. docker_mcp/tools/__init__.py — star-import the module (private helpers prefixed with _ are excluded).

  2. tests/test_<domain>.py — unit tests using mocks (no real daemon).

  3. tests/integration/test_<domain>.py — at least one happy-path test against a real daemon (or override the skip_if_no_daemon fixture if the module doesn't need one).

  4. docker_mcp/tools/prompts.py — at least one @mcp.prompt(...) template that exercises the new tools end-to-end.

  5. docker_mcp/tools/resources.py — add an entry under SDK_SECTIONS or EXTERNAL_SECTIONS if the new domain has authoritative docs the agent should be able to read at runtime.

  6. README.md — append to the "What the agent can do" list and (if relevant) the "Security considerations" section.

  7. SECURITY.md — only if the new module exposes a new class of risk not already covered by the README's Security section.

Verifying the SDK before writing code

To prevent hallucinated method names, the project includes a /docker-sdk Claude Code skill that fetches the live Docker SDK for Python documentation, inventories what's already exposed, and produces a gap analysis. Run it before adding new tools:

/docker-sdk                    # full gap analysis
/docker-sdk containers         # focus on a single domain

Local development

# install dependencies (creates .venv, installs runtime + dev deps)
uv sync

# run the server
uv run python -m docker_mcp
# …or via the installed console script
uv run docker-mcp

# unit tests (integration tests are excluded by default)
uv run pytest -v

# integration tests — require a running Docker daemon at $DOCKER_HOST
uv run pytest -m integration -v

# lint, format, type-check
uv run ruff check .
uv run ruff format .
uv run pyright

# install the pre-commit hook (one-time, runs ruff on every commit)
uv run pre-commit install

# add a runtime dependency
uv add <package>

# add a development dependency
uv add --group dev <package>

CI runs both pytest and ruff on every push and pull request via .github/workflows/premerge.yaml.

Building a local Desktop Extension (.mcpb)

To smoke-test the Claude Desktop Extension locally, pack a bundle with the developer helper in scripts/:

# pack dist/docker-mcp-server-<version>.mcpb (auto-increments -1, -2, … if it exists)
scripts/build-mcpb.sh

# …or give it an explicit name (a .mcpb extension is added if missing)
scripts/build-mcpb.sh my-test-bundle

It reads the version from pyproject.toml, creates dist/ if needed, writes a .sha256 alongside the bundle, and packs via Anthropic's mcpb CLI (a global mcpb, else npx @anthropic-ai/mcpb; see --help for the MCPB= override). The official release bundle is built separately by .github/workflows/publish-mcpb.yaml — this script is for local testing only and is not used by CI.

Reporting issues

Bug reports and feature requests have templates that you can choose when you create an issue. Please select the correct issue type and follow the template.

Install Server
A
license - permissive license
A
quality
A
maintenance

Maintenance

Maintainers
10dResponse time
3dRelease cycle
16Releases (12mo)
Commit activity

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/GavinLucas/docker-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server