Guide · Container Runtime
MCP Tools for Docker Engine API — container lifecycle, exec vs attach, and image cache
The Docker Engine REST API is what docker CLI commands translate into under the hood. When you build an MCP tool that manages containers — spinning up ephemeral sandboxes for code execution, managing service dependencies, or introspecting running workloads — you are calling this API directly. Three distinctions that the CLI abstracts away become critical at the API layer: create vs start vs run (three different lifecycle operations, not one), exec vs attach (two fundamentally different ways to interact with a running container), and health probe depth (a container in "Status": "running" state does not mean the process inside it is healthy or even reachable). Getting these wrong produces MCP tools that report false success, leave zombie containers, or misdiagnose container failures.
TL;DR
Connect to the Docker Engine API at /var/run/docker.sock (Unix socket) or tcp://host:2376 (TLS for remote). Use POST /containers/create then POST /containers/{id}/start as separate operations — do not use POST /containers/{id}/run as a shortcut; it does not exist as a single endpoint. Use POST /containers/{id}/exec then POST /exec/{id}/start to run a command inside a running container; use POST /containers/{id}/attach only when you need to connect to the container's main process stdin/stdout. For health probes, check GET /containers/{id}/json and inspect .State.Health.Status (requires a HEALTHCHECK in the image), not just .State.Status. Register your container's HTTP endpoint with AliveMCP for external monitoring that survives Docker daemon restarts.
Connecting to Docker Engine: socket vs TCP
The Docker Engine exposes an HTTP API. On Linux and macOS, the default transport is a Unix domain socket at /var/run/docker.sock. Node.js MCP tools connect using the dockerode library or raw http requests over the socket:
import Dockerode from 'dockerode';
// Default: /var/run/docker.sock
const docker = new Dockerode();
// Remote with TLS (recommended for production)
const dockerTLS = new Dockerode({
host: '10.0.0.1',
port: 2376,
ca: fs.readFileSync('/certs/ca.pem'),
cert: fs.readFileSync('/certs/cert.pem'),
key: fs.readFileSync('/certs/key.pem'),
protocol: 'https'
});
// Raw socket request (no library)
const http = require('http');
function dockerRequest(method, path, body) {
return new Promise((resolve, reject) => {
const opts = {
socketPath: '/var/run/docker.sock',
path,
method,
headers: body ? { 'Content-Type': 'application/json' } : {}
};
const req = http.request(opts, res => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve({ status: res.statusCode, body: data }));
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
The Docker socket grants root-equivalent access to the host. An MCP tool that accepts arbitrary container parameters from an AI agent must validate inputs carefully: restrict allowed images to an allowlist, never pass untrusted values directly into Binds (volume mount paths), and never expose the socket to the network without TLS mutual authentication. For MCP tools running inside a container themselves, mount the socket read-write only if container management is the tool's explicit purpose.
The API version is part of every URL: /v1.47/containers/json. Omitting the version is allowed and routes to the engine's current default, but pinning a version prevents breaking changes when the engine upgrades. Check GET /version to discover the engine version and API version at startup; log a warning if the engine API version is older than the minimum your tool requires.
Container lifecycle: create, start, and run are distinct operations
The Docker CLI's docker run command executes three API calls in sequence. Understanding each helps you build MCP tools that handle partial failures correctly.
POST /containers/create allocates the container on the engine: it validates the image exists locally, reserves the container configuration (environment variables, port bindings, volume mounts, resource limits), assigns a container ID, and creates the writable container layer on top of the image layers. The container does not execute any process at this point. The response returns the container ID and a Warnings array — always log warnings, as they surface configuration issues (obsolete flags, missing capabilities) that the API still accepts rather than rejecting.
POST /containers/{id}/start starts the container's main process (the CMD or ENTRYPOINT defined in the image, or the override from the create request). After this call returns 204, the container has transitioned to running state — but the process inside may still be initializing. A web server container in running state is not necessarily accepting HTTP connections yet.
POST /containers/{id}/wait?condition=not-running blocks until the container exits. For MCP tools that run ephemeral workloads (running a test suite, a migration, a one-shot data processor), use wait after start to get the exit code. The response body contains {"StatusCode": 0} — a non-zero status code means the process failed. Do not poll GET /containers/{id}/json in a loop as a substitute for wait; the blocking wait endpoint is more efficient and has lower latency.
// Pattern: create → start → wait (ephemeral workload)
async function runContainer(image, cmd, env) {
// Step 1: create
const createResp = await docker.createContainer({
Image: image,
Cmd: cmd,
Env: env,
HostConfig: {
AutoRemove: true, // remove container on exit
NetworkMode: 'none', // isolate ephemeral workloads
Memory: 512 * 1024 * 1024, // 512MB limit
CpuQuota: 50000, // 50% of one CPU
ReadonlyRootfs: true, // prevent filesystem writes
}
});
const container = createResp;
if (createResp.Warnings?.length) {
console.warn('Docker create warnings:', createResp.Warnings);
}
// Step 2: start
await container.start();
// Step 3: wait for exit
const result = await container.wait(); // blocks until exit
return { exitCode: result.StatusCode };
}
The separation between create and start matters for error handling. If start fails (image's entrypoint is missing, port is already bound, cgroup limit is rejected), the container exists in created state and must be explicitly removed with DELETE /containers/{id}. An MCP tool that only handles the happy path will leave created containers accumulating on the engine. Always wrap start in try/catch and remove the container on failure.
exec vs attach: two different interaction models
Both exec and attach let you interact with a running container's processes, but they work at different levels and have different use cases for MCP tools.
exec creates a brand-new process inside the running container's namespace. The new process shares the container's filesystem, environment, and network interfaces but is otherwise independent — it has its own PID, its own stdin/stdout/stderr, and its own exit code. Exec is two API calls: POST /containers/{id}/exec (creates the exec instance, returns an exec ID) then POST /exec/{execId}/start (starts the process and optionally attaches to its streams).
// exec: run a command inside a running container
async function execInContainer(containerId, cmd) {
const exec = await docker.getContainer(containerId).exec({
Cmd: cmd, // e.g., ['ls', '-la', '/app']
AttachStdout: true,
AttachStderr: true,
AttachStdin: false,
});
const stream = await exec.start({ Detach: false, Tty: false });
// Docker multiplexes stdout/stderr into a single stream
// demux requires parsing the 8-byte frame header
const output = await new Promise((resolve, reject) => {
let stdout = '', stderr = '';
docker.modem.demuxStream(stream,
{ write: chunk => stdout += chunk },
{ write: chunk => stderr += chunk }
);
stream.on('end', () => resolve({ stdout, stderr }));
stream.on('error', reject);
});
// Get exit code after stream ends
const inspect = await exec.inspect();
return { ...output, exitCode: inspect.ExitCode };
}
attach connects you to the container's existing main process (PID 1 in the container namespace). If you attach to a container and send data to stdin, that data goes to PID 1's stdin — the same process whose output you see in docker logs. Detaching from attach does not stop PID 1; the main process continues. Attach is appropriate when you need to interact with an interactive REPL or a prompt-driven application that is already running. For most MCP tool use cases — running a health check command, listing files, inspecting configuration — exec is the right choice, not attach.
A critical difference: if the main process of the container exits while you have an exec session running, the exec process continues until it exits itself. Exec processes outlive the container's natural lifecycle if AutoRemove is not set; they are cleaned up when the container is removed. Do not use exec to run long-lived processes that are intended to outlive the main container process — they become orphaned and are invisible in docker ps.
Image management: pull, local cache, and layer deduplication
Docker images are composed of read-only layers. When you pull an image, each layer is downloaded separately and stored in the local image cache. If a second image shares a layer (for example, two images both built FROM node:22-alpine), the shared layer is stored once and referenced by both images. This layer deduplication makes pulling related images fast, but it also means that inspecting the image size on disk requires understanding that docker images shows "virtual size" (the sum of all layers) while the actual disk usage is lower because of sharing.
For MCP tools that pull images on demand, three things matter:
1. Check local cache before pulling. GET /images/{name}/json returns the image metadata if the image exists locally, or a 404 if it does not. Use this to avoid unnecessary pulls for images that are already present.
2. Pulling is streamed, not atomic. POST /images/create?fromImage=nginx&tag=alpine returns a stream of JSON objects showing pull progress. The pull is complete when the stream ends — not when the first JSON object arrives. For large images, the pull can take minutes. MCP tools should stream progress back to the agent, not silently block.
3. Image size affects startup latency. A container from a 1GB image that is not in the local cache takes much longer to start than one from a 50MB image. MCP tools that create containers on-demand should prefer images with small footprints and should maintain a warm local cache for frequently-used images.
// Check local cache, pull if missing, return image info
async function ensureImage(imageName) {
try {
const info = await docker.getImage(imageName).inspect();
return { cached: true, id: info.Id, size: info.Size };
} catch (e) {
if (e.statusCode !== 404) throw e;
}
// Image not in cache — pull it
const pullStream = await docker.pull(imageName);
await new Promise((resolve, reject) => {
docker.modem.followProgress(pullStream, (err, result) => {
if (err) reject(err); else resolve(result);
});
});
const info = await docker.getImage(imageName).inspect();
return { cached: false, id: info.Id, size: info.Size };
}
Images can be pruned with POST /images/prune, which removes dangling images (untagged images with no containers referencing them). For MCP tools managing long-running Docker hosts, expose a prune_images tool that runs prune and reports reclaimed space. Never prune without a confirm guard — pruning removes layers that may be shared by images currently in use.
Health probe depth: container status is not process health
The most common mistake in Docker MCP tools is equating .State.Status === "running" with "the container is healthy." Running means the main process (PID 1) has been started and has not yet exited. It says nothing about whether the application inside is ready to accept connections, whether its dependencies are reachable, or whether it is currently in a crash-restart loop.
Docker provides a layered health model. Use all three layers when building a container_health MCP tool:
Layer 1: .State.Status — the container lifecycle state. Values: created, running, paused, restarting, removing, exited, dead. A status of restarting is a critical signal — it means the main process is crash-looping. Check .State.RestartCount alongside status; a running container with RestartCount > 0 recently crash-looped and recovered.
Layer 2: .State.Health — the Docker HEALTHCHECK result. Only present if the image defines a HEALTHCHECK directive or if the container was created with a Healthcheck config override. Values: starting (grace period before first check), healthy, unhealthy. The .State.Health.Log array contains the last 5 health check outputs — surface these when status is unhealthy. If .State.Health is absent, the image has no health check and you must fall back to Layer 3.
Layer 3: external probe — an HTTP or TCP probe from outside the container. For containers that expose HTTP endpoints, an MCP tool can exec a curl command or make an HTTP request to the container's published port. This is the most accurate signal but requires knowledge of what the container is running. Register the container's HTTP endpoint with AliveMCP for continuous external monitoring.
async function containerHealth(containerId) {
const info = await docker.getContainer(containerId).inspect();
const state = info.State;
const result = {
status: state.Status, // running, exited, restarting, ...
restartCount: state.RestartCount,
startedAt: state.StartedAt,
exitCode: state.ExitCode, // 0 means clean exit
};
// Layer 2: Docker HEALTHCHECK
if (state.Health) {
result.healthStatus = state.Health.Status; // healthy, unhealthy, starting
result.lastCheck = state.Health.Log?.[0]; // most recent check output
}
// Synthesize: running but unhealthy is worse than exited clean
if (state.Status === 'running' && state.Health?.Status === 'unhealthy') {
result.summary = 'process_running_but_unhealthy';
} else if (state.Status === 'restarting') {
result.summary = 'crash_looping';
} else if (state.Status === 'running') {
result.summary = state.Health ? state.Health.Status : 'running_no_healthcheck';
} else {
result.summary = state.Status;
}
return result;
}
Network management: inspect, connect, and port binding traps
Docker networks are first-class resources. Containers on the same user-defined network can reach each other by container name (DNS resolution via Docker's embedded DNS server). Containers on the default bridge network (bridge) can only reach each other by IP address — container name DNS does not work on the default bridge network.
For MCP tools that need to wire containers together (e.g., spinning up an application container and a database container that must communicate), always create a user-defined bridge network first, then attach both containers to it at creation time:
// Create network, attach containers, verify connectivity
async function setupNetwork(name) {
const network = await docker.createNetwork({
Name: name,
Driver: 'bridge',
Options: {},
Labels: { 'managed-by': 'mcp-tool' }
});
return network.id;
}
// Create container attached to named network
async function createConnectedContainer(image, name, networkId) {
const container = await docker.createContainer({
Image: image,
name, // container name — resolvable by DNS on user-defined networks
HostConfig: { NetworkMode: networkId }
});
return container.id;
}
Port binding traps to watch for in MCP tools: host port conflicts — POST /containers/create accepts a port binding even if the host port is already in use; the conflict is only detected at start time (returns 500). Check GET /containers/json?filters={"status":["running"]} for existing port bindings before creating a container with a specific host port. Binding to 0.0.0.0 — the default when you specify only the container port — exposes the port on all network interfaces, including public-facing ones. For MCP tools managing development environments, bind explicitly to 127.0.0.1 to avoid accidental public exposure.
Use DELETE /networks/{id} to clean up named networks, but Docker will refuse if any containers are still connected to the network. Always disconnect containers first with POST /networks/{id}/disconnect or remove the containers, then remove the network. An MCP cleanup_environment tool should follow this order: stop containers → remove containers → remove networks → prune dangling volumes.
Frequently asked questions
Why does the Docker Engine API not have a single "run" endpoint?
The Docker CLI's docker run is a client-side convenience that chains three API operations: pull (if image not local), create, and start. The API exposes each step separately to give callers fine-grained control over the lifecycle. This separation matters in MCP tools because failures at each step require different recovery actions: a pull failure means the image doesn't exist or isn't accessible; a create failure means the configuration is invalid (bad environment variable format, non-existent network, conflicting container name); a start failure means the runtime rejected the container after creation (port conflict, cgroup limit rejection, missing entrypoint). By keeping the steps separate, your MCP tool can report exactly which step failed and why, and can clean up correctly — removing a created-but-not-started container on a start failure, for example. A single "run" API would require the caller to infer which step failed from the error message, which is fragile.
How is Docker exec different from running a new container with the same image?
Exec runs a new process inside an existing running container's namespace — it shares the container's filesystem, environment, network interfaces, and PID namespace (so the exec process can see other processes running in the container). A new container from the same image starts completely fresh: new writable layer, fresh filesystem, new network namespace, no processes from the previous container. Exec is appropriate when you need to inspect or interact with the state of a running container — reading files, running diagnostic commands, checking process status. A new container is appropriate when you want to run an independent workload that should not share state with another container. For MCP tools that implement "run a command in a container" semantics, exec is almost always what you want if a suitable container is already running; a new container is what you want if you need a clean, isolated environment.
What does the 8-byte stream header in exec output mean?
When you attach to an exec process with AttachStdout: true and AttachStderr: true and Tty: false, Docker multiplexes stdout and stderr into a single stream using a simple framing protocol. Each frame is prefixed with an 8-byte header: byte 0 is the stream type (1 = stdout, 2 = stderr, 3 = stdin), bytes 1-3 are padding zeros, and bytes 4-7 are the frame length as a big-endian uint32. The actual content follows immediately after these 8 bytes. If you read the raw stream without demultiplexing, you will see binary header bytes mixed with your output — the first byte being 01 or 02 followed by three zero bytes and a length. The dockerode library provides docker.modem.demuxStream() to handle this automatically. When you use Tty: true in the exec or create request, Docker allocates a pseudo-terminal and the output is not multiplexed — it arrives as plain bytes on a single stream, making demultiplexing unnecessary but also making stdout and stderr indistinguishable.
How do I monitor Docker containers with AliveMCP if they don't expose HTTP endpoints?
AliveMCP monitors HTTP and HTTPS endpoints, so containers that expose only TCP ports (databases, message queues) or only internal network addresses require a thin HTTP health bridge. Three approaches: (1) Add a lightweight HTTP health endpoint to the container's application — even a single-route Express.js server that responds to GET /health with 200 is sufficient. (2) Run a sidecar container in the same user-defined network that performs the container-specific health check (a TCP probe for the database port, a Redis PING, a PostgreSQL SELECT 1) and exposes the result as an HTTP endpoint. (3) Use an MCP tool that execs a health check command inside the container and exposes the result via an HTTP endpoint on the MCP server's host. The sidecar approach is the most robust because AliveMCP can then probe it independently of the MCP server's availability. For containers managed by Docker Compose, a sidecar health proxy is easy to add as an additional service in the Compose file.
How do I clean up containers created by MCP tools without leaking resources?
MCP tools that create containers must guarantee cleanup even on failure. Three strategies: (1) Use AutoRemove: true in the container's HostConfig — Docker will automatically remove the container when it exits. This works for ephemeral workloads but not for long-lived service containers. (2) Label every container created by the MCP tool with a label like "managed-by": "mcp-tool" and include a prune_managed_containers tool that lists containers with this label and removes stopped ones. (3) Register cleanup with a process exit handler (process.on('exit', cleanup)) in the MCP server, but note that exit handlers don't run on SIGKILL. The label-based approach is the most reliable because it works even after an MCP server restart. Always check for orphaned labeled containers at MCP server startup and surface a warning if any are found in an unexpected state — this catches cases where the previous MCP server session crashed mid-operation.
Further reading
- MCP Tools for containerd — gRPC API, task lifecycle, and snapshot management
- MCP Tools for Podman REST API — rootless containers, pods, and quadlets
- MCP Tools for Amazon ECS — task state machine, service health, and capacity providers
- MCP Tools for Google Cloud Run — revision lifecycle, traffic splitting, and cold start
- MCP Server Docker — Dockerfile, signal handling, and health checks
- MCP Server Docker Compose — local dev, Redis, and production setup
- MCP Servers on Kubernetes — readiness probes, PDBs, and session affinity
- MCP Server Health Checks — implementing and monitoring protocol probes
- MCP Server Deployment — transport selection and hosting patterns
- MCP Tools for DevOps — patterns across container runtimes and orchestrators