Skip to main content
Glama
zshureih
by zshureih
README.md
# docker-mcp

An MCP (Model Context Protocol) server that creates Docker containers by
talking directly to the **Docker Engine API** over `/var/run/docker.sock`.

## Why this exists

It's the sibling project to [`unraid-mcp`](../unraid-mcp), which wraps
Unraid's official GraphQL API. That API has no container-creation mutation —
Unraid's own "Add Container" feature is a separate webGUI/dockerMan
subsystem, not part of the official API. `docker-mcp` fills that one gap by
talking to the Docker Engine directly, using the official `docker` Python SDK
(never shelling out to `docker run` — every parameter is a typed field in a
structured API payload, not a shell string).

It is deliberately **not** a fork or subfolder of `unraid-mcp`, and
deliberately narrow in scope: **exactly two tools**, `pull_image` and
`create_container`. Once a container exists — regardless of how it was
created — `unraid-mcp`'s existing `docker/start`, `stop`, `remove_container`,
etc. already manage it by ID against the same Docker daemon. This project
does not duplicate that lifecycle surface.

## Why a separate server instead of adding this to unraid-mcp

Controlling the Docker Engine is root-equivalent access to the host. Bolting
that onto `unraid-mcp` (which is a large, LLM-driven tool surface across 19
domains) would mean every one of those 178 operations shares a blast radius
with "can create arbitrary containers." Splitting it out means only this
small, narrowly-scoped server gets the Docker socket mount — `unraid-mcp`
stays exactly as sensitive as its GraphQL API and nothing more.

## Security posture — read this

- **`privileged` and host PID-namespace sharing are not supported at all** —
  there is no parameter for them anywhere, on any code path.
- `network="host"` and bind-mounting the Docker socket, host root (`/`),
  `/etc`, `/proc`, `/sys`, `/var/lib/docker`, `/root`, or `/boot` are rejected
  before any Docker Engine call is made (see `docker_mcp/core/validation.py`).
- **This validation is a guardrail against mistakes, not a security
  boundary.** Anyone who can call `create_container` already has
  root-equivalent control of the host via the Docker Engine — the same daemon
  would happily do any of the above if asked directly with the Docker CLI.
  Don't expose this server to anyone/anything you wouldn't hand a root shell.
- `create_container` requires `confirm=True` (or interactive elicitation) —
  same pattern as `unraid-mcp`'s destructive-action gating.
- HTTP transport requires a bearer token by default (auto-generated into
  `~/.docker-mcp/.env` on first startup) and refuses to bind an
  unauthenticated endpoint to a non-loopback address unless you explicitly
  set `DOCKER_MCP_TRUST_PROXY=true` behind a gateway that enforces auth
  itself.
- The `entrypoint.sh` docker.sock GID-matching trick (joining a group that
  matches the socket's GID instead of running fully as root) is best-effort
  hardening that mostly helps on Debian-style `root:docker` hosts. **On
  Unraid the socket is commonly `root:root`**, in which case there's no
  non-root group to join and the entrypoint falls back to running as root
  with a printed warning — Docker Engine access is root-equivalent either
  way, so this is not pretending to be a real isolation boundary.

## Tools

### `pull_image(image, tag="latest", platform=None)`

Pulls an image onto the Docker host. Not gated — download-only, no host
mutation.

### `create_container(image, name=None, environment=None, ports=None, volumes=None, network=None, restart_policy=None, labels=None, command=None, working_dir=None, cap_add=None, mem_limit=None, start=True, confirm=False)`

Creates (and by default starts) a container. Requires the image to already
be present locally — call `pull_image` first if needed.

```
create_container(
    image="lscr.io/linuxserver/plex:latest",
    name="plex",
    environment={"PUID": "1000", "PGID": "1000", "TZ": "America/New_York"},
    ports={"32400/tcp": 32400},
    volumes={"/mnt/user/appdata/plex": {"bind": "/config", "mode": "rw"},
             "/mnt/user/media": {"bind": "/media", "mode": "ro"}},
    restart_policy={"Name": "unless-stopped"},
    confirm=True,
)
```

## Running it

```bash
cp .env.example ~/.docker-mcp/.env   # edit as needed
docker compose up -d
```

`docker-compose.yaml` pulls the prebuilt image from
`ghcr.io/zshureih/docker-mcp`, published by `.github/workflows/docker-publish.yml`
on every push to `main`. To build locally instead (e.g. while developing),
replace the `image:` line with `build: .`.

The bearer token is auto-generated into `~/.docker-mcp/.env` on first HTTP
startup if not already set — check the container logs for it.

### Making it reachable from other devices on your LAN

By default the published port is bound to `127.0.0.1` on the Docker host —
nothing else on your network can reach it, even with the token. To expose it
to other LAN devices, set `DOCKER_MCP_BIND_HOST` in a **project-root `.env`
file** (Docker Compose's own dotenv mechanism, which it reads when parsing
`docker-compose.yaml` — this is a *different* file from
`~/.docker-mcp/.env`, which is the app's own credentials file loaded inside
the container and has no effect on Compose's port-mapping substitution):

```bash
echo "DOCKER_MCP_BIND_HOST=192.168.0.174" >> .env   # your Unraid host's LAN IP
docker compose up -d
```

Keep bearer-token auth **enabled** (`DOCKER_MCP_DISABLE_HTTP_AUTH=false`, the
default) when doing this — otherwise every device on your LAN could create
and control containers on your Unraid box with no authentication at all.
Only set `DOCKER_MCP_DISABLE_HTTP_AUTH=true` if a reverse proxy genuinely
sits in front of this port and enforces auth itself (in which case also set
`DOCKER_MCP_TRUST_PROXY=true`); the server refuses to start otherwise once
it detects a non-loopback bind with auth disabled.

## Development

```bash
uv sync --group dev
uv run pytest
uv run ruff check docker_mcp/
uv run ty check docker_mcp/
```