MCP Dockhand
An MCP (Model Context Protocol) server that exposes the Dockhand API as MCP tools. Manage your entire Docker infrastructure through AI assistants.
API coverage: 88.7% of in-scope Dockhand endpoints (282/318) have an MCP tool — see docs/coverage.md for the full, auto-updated breakdown by area.
Dockhand is a Docker management server that connects to multiple Docker hosts via Hawser agents. This MCP server provides full programmatic access to all Dockhand features.
Features
- 280+ MCP Tools covering the Dockhand API — see
docs/coverage.mdfor exact, auto-updated coverage - Streamable HTTP Transport (MCP Spec 2025-03-26) for Docker container hosting
- Session-based Auth with auto-relogin on 401
- SSE Support for deploy operations (start, stop, down, restart)
- Environment Filter enforced on all container/stack/image/network/volume endpoints
- Docker Ready with multi-stage build, non-root user, and health checks
Quick Start
Docker (recommended)
docker run -d \
--name mcp-dockhand \
-p 8080:8080 \
-e DOCKHAND_URL=https://your-dockhand-server.com \
-e DOCKHAND_USERNAME=your-username \
-e DOCKHAND_PASSWORD=your-password \
ghcr.io/strausmann/mcp-dockhand:latest
Optionally, replace the DOCKHAND_USERNAME/DOCKHAND_PASSWORD pair with
-e DOCKHAND_API_TOKEN=dh_your-api-token to authenticate with a personal API
token instead — see Authentication.
Docker Compose
services:
mcp-dockhand:
image: ghcr.io/strausmann/mcp-dockhand:latest
container_name: mcp-dockhand
restart: unless-stopped
ports:
- "8080:8080"
environment:
- DOCKHAND_URL=https://your-dockhand-server.com
- DOCKHAND_USERNAME=your-username
- DOCKHAND_PASSWORD=your-password
# Optional: use a personal API token instead of the pair above.
#- DOCKHAND_API_TOKEN=dh_your-api-token
From Source
git clone https://github.com/strausmann/mcp-dockhand.git
cd mcp-dockhand
npm install
npm run build
DOCKHAND_URL=https://your-server.com DOCKHAND_USERNAME=admin DOCKHAND_PASSWORD=secret npm start
(Or, in place of the credential pair, DOCKHAND_API_TOKEN=dh_your-api-token — see
Authentication.)
Configuration
Securing the transport
/mcp binds 0.0.0.0:8080 by default (see MCP_HOST above), and out of the box — with none of
MCP_ALLOWED_HOSTS, MCP_ALLOWED_ORIGINS, or MCP_AUTH_TOKEN set — it accepts any request
with no Host/Origin check and no authentication. This is the same behavior mcp-dockhand
has always had, kept as the default deliberately: enabling a check by default would reject
requests from any client that doesn't reach the server as localhost/127.0.0.1 (a LAN IP, a
reverse proxy, a Docker network alias), breaking existing deployments on a routine update.
You should turn this on once /mcp is reachable beyond your own machine's loopback interface
— the server holds one Dockhand admin credential and every tool call acts with that identity, so
anyone who can open an MCP session controls Docker (container exec, host bind-mounts via
create_container, file read/write, stored git credentials). With no protection configured, the
server logs a [security] WARNING at startup as a reminder. Three independent, all-opt-in layers
are available:
- Host allowlist (
MCP_ALLOWED_HOSTS). Once set to a non-empty value, every request to/mcp—POST,GET, andDELETE— is rejected with403unless itsHostheader matches the allowlist. This is the primary defense against DNS-rebinding: a malicious web page cannot make the operator's browser reach the server under a Host value the allowlist accepts. Set it to however your client actually reaches the server —localhost:8080/127.0.0.1:8080for the documented local setup, or, if you connect directly by address rather than throughlocalhost(including the mcp-proxy remote-server setup below), the exacthost:portyour client sends, e.g.100.100.50.40:8222. Get this wrong and every request is rejected with403 Invalid Host header— check the message, it echoes the Host value it saw. - Origin allowlist (
MCP_ALLOWED_ORIGINS). Once set, any request that does send anOriginheader not in the list is rejected with403. A missingOriginheader always passes (the SDK's own MCP client and most non-browser tooling never send one), so this is only useful if a browser-based client talks to/mcpdirectly; the Host allowlist above is what actually stops DNS-rebinding. - Bearer token (
MCP_AUTH_TOKEN). Once set, every/mcprequest must carryAuthorization: Bearer <token>or is rejected with401; the comparison is constant-time. Recommended alongside the Host allowlist for any deployment reachable from more than the operator's own machine.
# .env — recommended configuration once /mcp is reachable beyond loopback
MCP_ALLOWED_HOSTS=dock-mcp.internal.example.com
# or, connecting directly by address instead of a hostname:
#MCP_ALLOWED_HOSTS=100.100.50.40:8222
MCP_AUTH_TOKEN=<a long random secret, e.g. `openssl rand -hex 32`>
Securing the server with CrowdSec
The server writes an nginx-format access line to stdout for every request, including the ones it rejects, while the structured application log goes to stderr. CrowdSec parses the access lines with its stock collections — no custom parser required.
Add an acquisition file on the host running your CrowdSec agent:
source: docker
container_name:
- mcp-dockhand
labels:
type: docker
program: nginx-mcp
Both labels are required, and neither fails loudly if you forget it.
type: docker enables crowdsecurity/docker-logs, which unwraps Docker's JSON
envelope. program: nginx-mcp enables crowdsecurity/nginx-logs, which matches on
program starting with nginx — the -mcp suffix keeps this source distinguishable
from your other nginx sources. With one label missing the chain simply produces
nothing, and nothing reports it.
Once wired up, the stock scenarios apply:
A 403 is worth watching too: it means a request failed the MCP_ALLOWED_HOSTS or
MCP_ALLOWED_ORIGINS check, which is what a DNS-rebinding attempt looks like from
here.
The stock 401 scenario only counts
POST. Its filter isevt.Parsed.verb == 'POST'— one literal, not a list. This server servesPOST,GETandDELETEon/mcp, and the bearer check runs ahead of all three, so a wrong token onGET /mcporDELETE /mcpreturns401exactly likePOSTdoes — andLePresidente/http-generic-401-bfnever counts those. Someone guessingMCP_AUTH_TOKENoverGET /mcpis invisible to it.This is a property of the upstream scenario, shared with every nginx deployment that uses it — not something this server's log format can fix. To close it, add a local scenario that drops the
verbfilter, or matches the three methods this server answers on. Until then, treat the row above as "repeated401onPOST/mcp".
Set
TRUSTED_PROXIESbefore you enable this. Behind a reverse proxy every request arrives from the proxy's address. WithoutTRUSTED_PROXIESthat address is what gets logged — so the first ban CrowdSec issues takes out the proxy, and with it every user behind it. Set it to the address or subnet your proxy talks from.The setting is equally deliberate in the other direction: the forwarding headers are only honoured from a peer on that list. Trusting them unconditionally would let any direct caller name an arbitrary third party and have them banned.
One expected side effect: the structured JSON lines share the container's log
stream and carry the same program label, so they fail the nginx pattern and count
as unparsed in cscli metrics. That is noise, not a fault — no alert, no decision.
MCP Client Configuration
Claude Desktop / Claude Code
Add to your MCP settings:
{
"mcpServers": {
"dockhand": {
"url": "http://localhost:8080/mcp"
}
}
}
If the server enforces a bearer token (
MCP_AUTH_TOKENset — see Securing the transport), the client must send it as anAuthorizationheader, or every request is rejected with401. In Claude Code's.mcp.json, add aheadersblock — reference an environment variable so the token never lives in the (often version-controlled) config file:{ "mcpServers": { "dockhand": { "type": "http", "url": "http://your-server:8080/mcp", "headers": { "Authorization": "Bearer ${DOCKHAND_MCP_TOKEN}" } } } }Send the token only over an encrypted transport. A bearer over plain
http://on a shared network can be sniffed — terminate TLS at a reverse proxy, or reach the server over a WireGuard/Tailscale/VPN link (the app-layer HTTP is then encrypted by the tunnel).Export
DOCKHAND_MCP_TOKENin the environment Claude Code is launched from (e.g. from a gitignored.envyousourcebefore starting). TheHost/host:portyou connect to must also be in the server'sMCP_ALLOWED_HOSTSif that allowlist is set. For Claude Desktop (native config has noheadersfield), pass the token through the mcp-proxy workaround below — mcp-proxy forwards anAuthorizationheader via its own environment/args.
Claude Desktop with a remote server (mcp-proxy)
Claude Desktop can fail to connect to a remote mcp-dockhand server (not
localhost) using the native "url" config above, even though the endpoint
itself is reachable. The symptom is a generic "not a valid MCP server" error
in Claude Desktop, while a plain browser/curl request to the same URL
correctly returns {"error":"Invalid or missing session ID"}. This is a known
limitation of Claude Desktop with remote Streamable HTTP servers, not a
mcp-dockhand bug.
Workaround: wrap the connection with mcp-proxy, which translates Streamable HTTP to stdio — a transport Claude Desktop handles reliably:
{
"mcpServers": {
"dockhand": {
"command": "/path/to/mcp-proxy",
"args": ["--transport", "streamablehttp", "http://your-server:8080/mcp"]
}
}
}
All tools load and work correctly through the proxy. Thanks to @deadrubberboy for reporting this and sharing the workaround (#90).
Tool Reference
Containers (27 tools)
Stacks (21 tools)
Images (9 tools)
Environments (18 tools)
Networks (7 tools)
Volumes (9 tools)
Git Stacks (15 tools)
Dashboard & Activity (8 tools)
Auth & Hawser (12 tools)
Audit (4 tools)
Notifications (8 tools)
Registries (10 tools)
System & Settings (19 tools)
Users, Roles & Preferences (20 tools)
Schedules (9 tools)
Auto-Update (3 tools)
Self-help / meta tools (6 tools)
Diagnostics for this MCP server itself, distinct from the Dockhand API tools above —
useful for a client or operator asking "is this server healthy and correctly configured?"
rather than "is Dockhand healthy?". None of these six take any input arguments, and none of
them wrap a single Dockhand endpoint the way the tables above do (get_tool_manifest and
get_runtime_stats call no Dockhand endpoint at all) — see src/tools/meta.ts.
Notes:
check_for_updateneeds outbound network access toapi.github.com(GitHub's releases API) — it will degrade toupdateAvailable: nullrather than fail if that's unreachable.- No meta tool exposes any secret value.
validate_configreports only whether the required env vars are present (booleans) and whether they authenticate (a boolean + the raw HTTP status code, e.g.200/401) — never the credential values themselves.self_checkreports auth validity the same way.get_runtime_stats'lastErrorcarries only a tool name, an error message, and a timestamp — never call arguments or response payloads. That error message is not fully opaque, though: for a failed Dockhand API call it can embed a slice of the upstream HTTP status and response body (viaDockhandClient's ownDockhand API error: ... returned <status>: <body>message), and it is echoed to whichever MCP client next callsget_runtime_stats— not necessarily the one that hit the original error. It never includes request bodies or credential values, and it is truncated to 500 characters (with an ellipsis marker) before being stored, so an oversized upstream response is never echoed wholesale.
Important Notes
update_stack_env — Merge vs Replace Semantics
The Dockhand REST endpoint PUT /api/stacks/{name}/env has replace-semantics: submitting a partial list of variables silently deletes all other variables from the stack. A single-variable update would wipe everything else.
To prevent accidental data loss, this MCP tool defaults to merge mode:
- It fetches the current variable list via
GET /api/stacks/{name}/env. - It merges the incoming variables by key (new values overwrite existing ones on key collision).
- It writes the full combined list back via
PUT.
# Safe partial update — only MY_VAR changes, all others preserved
update_stack_env(environmentId=1, name="my-stack", variables=[{key: "MY_VAR", value: "new"}])
# Explicit full replacement — all other variables are deleted
update_stack_env(environmentId=1, name="my-stack", variables=[...], mode="replace")
Use mode="replace" only when you intentionally want to replace the entire variable set.
Environment ID is Required
Most Docker resource endpoints (containers, stacks, images, networks, volumes) require an environmentId parameter. This maps to the ?env=<id> query parameter in the Dockhand API. Without it, endpoints return empty arrays.
SSE Responses
Deploy operations (start, stop, down, restart, compose update with restart) return Server-Sent Events. The MCP server automatically parses these and returns the final result.
Authentication
The server normally authenticates with DOCKHAND_USERNAME + DOCKHAND_PASSWORD
and a session cookie. It automatically:
- Logs in on first request
- Stores the session cookie in memory
- Re-authenticates on 401 responses
- Handles session timeout (24h)
Optionally, set DOCKHAND_API_TOKEN to a personal API token instead — create one
on your Dockhand profile page, under API tokens (Dockhand v1.0.26+; bearer auth
did not work with an enterprise license in v1.0.25). The token acts as the user who
created it, with that user's roles. Every request
then carries Authorization: Bearer <token>: no login round-trip, no session
cookie to expire, and no re-login on 401. The token also works against an account
with MFA enabled, where the session login above cannot complete a TOTP challenge
non-interactively.
When DOCKHAND_API_TOKEN is set it is used and the username/password pair is
ignored (a warning is logged when both are set), so the pair is not required
alongside it. With neither the token nor a complete credential pair, the server exits
at startup rather than failing every tool call later. The same happens for a token
that is not dh_… base64url text (for example one with a line break from copy/paste);
the error names the variable, never its value.
A rejected token is not retried: Dockhand puts an IP into a 5-minute cooldown (HTTP
429) after 15 failed bearer attempts per minute, which would hit every client behind
the same address. A revoked or expired token therefore fails fast with an error that
names DOCKHAND_API_TOKEN.
Limitations in token mode. Some Dockhand endpoints read only the session cookie
(validateSession) or refuse bearer auth on purpose, so these tools do not work with
DOCKHAND_API_TOKEN (their descriptions say so too):
Use the username/password pair for these, or the Dockhand UI.
Troubleshooting
Start with LOG_LEVEL=debug. Every Dockhand request then appears with its endpoint,
status code and duration, and every line of a single call shares one
call identifier — grep for it to get the whole sequence. The req identifier
ties those lines back to the access line that started them, and sid covers
everything one client did across its whole session. For requests through the client, ms is the full request
duration — it spans the response body being read, not just the time until the
response headers arrived, so it reflects what a slow or stalled streamed
response (e.g. a deploy's SSE output) actually cost — and bytes is the size of the
body that was actually read. (The login and self-check probes bootstrap the client and can't route through it, so their lines log time-to-headers without a bytes field.) A failed Dockhand request additionally logs a
warn line carrying errType — the exception name (e.g. TimeoutError, TypeError),
a bounded vocabulary rather than free text — so you can filter failures by error
type. That warn line fires both when the request itself failed before any
response arrived, and when a response body's read failed partway through (e.g.
an SSE stream hitting its timeout mid-stream) — either way ms reflects how
long it took to fail.
Development
# Install dependencies
npm install
# Type check
npm run typecheck
# Build
npm run build
# Run in development mode
DOCKHAND_URL=https://your-server.com \
DOCKHAND_USERNAME=admin \
DOCKHAND_PASSWORD=secret \
npm run dev
Dockhand API spec
MCP tool routes, query parameters, and request-body contracts are all derived from a single pinned source, docs/dockhand-openapi.json — fetched from a fixed upstream Finsys/dockhand commit by scripts/fetch-openapi.mjs (the pin is mirrored in src/openapi/pinned.ts and kept in sync with SOURCE_COMMIT in that script).
npm run api:validateis a hard, PR-blocking CI gate: it fails the build on route/query mismatches and on a tool missing a required body field (BODY_PARAM_MISSING_REQUIRED); other body-contract findings (unknown body fields, untyped passthrough, unresolved contracts, missing tools) stay advisory-only — seedocs/body-contract-report.md.npm run api:coverage-docandnpm run api:tool-endpoint-mapregenerate the derived docs (docs/coverage.md,src/openapi/tool-endpoint-map.ts,docs/body-contract-report.md); CI checks they're in sync with the code on every PR.- A weekly workflow (
.github/workflows/drift-bump.yml) bumps the pinned commit toward upstreammainand opens a PR — no auto-merge, so any body-contract drift from a new Dockhand release is caught by the hard gate above before it ships. - Version-gating (comparing the live Dockhand server's version against the pinned spec) is a separate, not-yet-implemented follow-up.
Linting
npm run lint lints src/ and tests/ with two rules: no-unused-vars and
no-explicit-any. Because typescript-eslint does not support the pinned
typescript@^7.0.2 compiler — it hard-throws on TS 7.0, not just a peer warning: see
typescript-eslint#10940
— the lint runs inside a throwaway node:22 container with pinned TypeScript 5 (the
language is identical across TS 5/6/7; only the compiler differs). It mounts src/,
tests/ and eslint.config.js read-only, so Docker is required to run it. The same
script runs as a hard gate in CI. Unused imports/locals are additionally caught natively on
TS 7 by tsc (noUnusedLocals/noUnusedParameters in tsconfig.tests.json, via
npm run typecheck:tests).
Testing conventions
These grew out of the test-hardening review of PRs #210–#215 (Issue #224) — several tests there passed while proving nothing. Two mechanical rules and one habit came out of it, and are enforced or expected repo-wide:
noUnusedLocals / noUnusedParameters stay on for tests. They are set in
tsconfig.tests.json and checked by npm run typecheck:tests (part of CI). The
reason to keep them, spelled out: a value parsed from output into a local that is
then never asserted on is a dead assertion — a test that looks like it checks
something but doesn't. With these flags on, that is a compile error instead of a
silently green test. Do not relax them for test files, and do not add a per-file
tsconfig under tests/ to opt a file back out.
A negative assertion must name the field it denies. expect(raw).not.toContain('42')
over a whole serialized blob is a coincidence waiting to happen — it can also match a
timestamp, an id, or an unrelated field, so it can fail for the wrong reason (or, for
a keyword sweep, fail because an unrelated legitimate field's name happens to contain
the word). Real case in #215: not.toContain('42') collided with a timestamp. Parse the
output and assert on the specific field (obj.route, obj.err.type) instead of the raw
string. When the whole point of the check is "no unexpected field anywhere in the
tree carries this", walk the object (see walkEntries() in
tests/tools/meta.self-check.test.ts, or quotedFields() /
statusBytesSegment() in tests/access-log.test.ts) and name the offending path in
the assertion message — do not fall back to JSON.stringify(value) plus a blind regex.
Counter-check every new regression test against the un-fixed code, once. If a test stays green against the version of the code it exists to catch, it proves nothing — a fallback path, a duplicate-key dedupe, or an env-driven later failure can all keep a test green for the wrong reason (all three happened across #214/#215). Before considering a new regression test done: revert the fix (or otherwise reintroduce the bug) locally, run the test, confirm it fails for the reason the test claims to check, then restore the fix.