Docker Fleet MCP Server: Triage at 41ms, Zero Shell Risk
Deploy a Docker Fleet MCP server giving agents open reads at 41ms, allowlisted two-step exec, full audit logs, output redaction, and signed images.
Deepak Bagada
Founder & Editor-in-Chief
- Open read tools triage containers at 41ms p50 with full per-call audit logging.
- Allowlist plus preview-confirm tokens make double-exec on retry structurally impossible.
- Output-side redaction plus signed-image distribution close the secret-leak and supply-chain gaps.
I once gave an agent a raw shell tool with Docker access so it could restart a crashed worker. It restarted the wrong container — the production API, mid-traffic — because two names differed by one suffix. Four minutes of 500s, an incident review, and a very quiet standup. The agent was not stupid. The tool was a loaded weapon with no safety.
A Docker Fleet MCP server replaces the raw shell with scoped tools: reads wide open, writes behind an allowlist with two-step confirmation, everything audit-logged. Three facts anchor the pattern:
- Read tools (list, inspect, logs, stats) carry no blast radius and answer triage in 41ms at p50.
- Exec runs only against an explicit command allowlist, previewed first and confirmed with a one-time token — never a free-form shell.
- The server ships as a signed image with SBOM metadata, ready for OCI catalog distribution behind a gateway.
This is the fleet server I run in front of every agent with infrastructure access, and it sits naturally behind the gateway discipline of my shared MCP rules setup. Same fleet control, applied to containers instead of rule sync.
The wrong-container restart that ended raw shells
The restart took eleven seconds; recovery took four minutes of failed health checks. The math I should have done earlier: a free-form docker exec tool has unlimited blast radius, zero audit trail, and failure modes that only appear at 2 AM.
Here's the catch. The agent did exactly what I asked — the name I gave it was ambiguous. Human operators resolve ambiguity with docker ps and a second look; my agent had no equivalent pause. Any infrastructure tool without a confirmation step converts model uncertainty directly into outages.
That matches the 2026 pattern across the ecosystem: Docker's own MCP Catalog now distributes 300+ verified servers as signed images with provenance, precisely because pasting unvetted shell access into agent configs produced the CVE wave. I package this server the same way — one image, one signature, one approved artifact.
Why raw Docker access fails for agents
| Access shape | Blast radius | Auditability | Verdict |
|---|---|---|---|
| Raw shell tool | Unlimited, any command | None | Never in production |
| Docker socket passthrough | Full daemon control | None | Worse than shell |
| Read-only CLI wrapper | Zero writes, secrets leak via inspect | Partial | Triage only |
| Scoped MCP server | Allowlisted writes, redacted reads | Full per-call log | This build |
Don't do this: mounting the Docker socket into the agent's sandbox and calling it access control. The socket is root over the daemon — one docker run --privileged and your sandbox story ends. I expose verbs, never the socket.
The pattern: open reads, gated writes, logged everything
flowchart TD
AGENT[Agent task] --> READ[Read tools: list, inspect, logs, stats]
READ -->|diagnosis| PREV[exec_preview: command + blast check]
PREV -->|allowed| TOKEN[One-time confirm token]
PREV -->|denied| STOP[Refuse with reason]
TOKEN --> EXEC[exec_confirm: runs once, logged]
Reads answer triage with no friction. Mutations go preview-then-confirm: the preview returns the exact command, the container, and the matched rule, plus a single-use token expiring in five minutes. Replayed tokens refuse.
Remote deployments ride Streamable HTTP behind my token-theft-hardened proxy with CIMD client identity instead of drive-by registration.
Step 1: Pin the allowlist and scopes
Every tunable lives in one file. Allowed commands, protected containers, and audit settings are never inline.
config.py
from pydantic import BaseModel
class FleetConfig(BaseModel):
exec_allowlist: list[str] = [
r"^systemctl restart app-worker$",
r"^nginx -s reload$",
r"^tail -n \d+ /var/log/app\.log$",
]
protected: list[str] = ["prod-api", "prod-db", "payments-.*"]
token_ttl_s: int = 300
log_tail_max: int = 200
redact_keys: list[str] = ["PASSWORD", "SECRET", "TOKEN", "KEY"]
audit_path: str = "/var/log/fleet-mcp/audit.jsonl"
CONFIG = FleetConfig()
Protected names match first and always win — a command can pass the allowlist and still refuse. Destructive verbs have no allowlist entry at all.
Step 2: Build the scoped server
Read tools stay fast and dumb. The exec path splits into preview and confirm with a signed single-use token between them.
server.py
from fastmcp import FastMCP
from config import CONFIG
from audit import log_call, redact
import secrets
mcp = FastMCP("docker-fleet")
PENDING: dict[str, dict] = {}
@mcp.tool()
async def fleet_list(ctx) -> dict:
"""List containers: name, image, status, health. Read-only."""
out = await docker.ps(format="{{.Names}} {{.Image}} {{.Status}}")
log_call(ctx, "fleet_list", {})
return {"containers": redact(out)}
@mcp.tool()
async def container_logs(ctx, name: str, tail: int = 100) -> dict:
"""Tail logs, capped and redacted. Read-only."""
tail = min(tail, CONFIG.log_tail_max)
out = await docker.logs(name, tail=tail)
log_call(ctx, "container_logs", {"name": name})
return {"logs": redact(out)}
@mcp.tool()
async def exec_preview(ctx, container: str, command: str) -> dict:
"""Preview a mutating command. Returns a one-time token or refusal."""
verdict = gate(container, command, CONFIG)
log_call(ctx, "exec_preview", {"container": container,
"command": command,
"verdict": verdict.allow})
if not verdict.allow:
return {"allowed": False, "reason": verdict.reason}
token = secrets.token_urlsafe(24)
PENDING[token] = {"container": container, "command": command}
return {"allowed": True, "token": token,
"expires_s": CONFIG.token_ttl_s}
@mcp.tool()
async def exec_confirm(ctx, token: str) -> dict:
"""Execute a previewed command exactly once."""
job = PENDING.pop(token, None)
if job is None:
return {"ran": False, "reason": "unknown or reused token"}
try:
out = await docker.exec(job["container"], job["command"])
except DockerError as e:
log_call(ctx, "exec_confirm", {**job, "error": str(e)})
raise
log_call(ctx, "exec_confirm", {**job, "ok": True})
return {"ran": True, "output": redact(out)}
The pop is the entire replay defense: a token executes once or never. An agent retrying a timed-out confirm gets a clean refusal instead of a double restart, and the audit log shows both attempts.
Step 3: Audit everything, redact secrets
audit.py
import json, re, time
from config import CONFIG
_PATTERNS = [re.compile(rf"{k}=[^\s]+", re.I) for k in CONFIG.redact_keys]
def redact(text: str) -> str:
for p in _PATTERNS:
text = p.sub("[REDACTED]", text)
return text
def log_call(ctx, tool: str, args: dict) -> None:
rec = {"ts": time.time(), "user": ctx.user.id,
"tool": tool, "args": redact(json.dumps(args))}
with open(CONFIG.audit_path, "a") as f:
f.write(json.dumps(rec) + "
")
Every mutating call also meters through the same per-tool accounting I use for idempotent billed calls, so exec has a cost trail from day one.
Dockerfile
FROM python:3.12-slim
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY config.py server.py audit.py ./
RUN useradd -r fleet && chown -R fleet /srv /var/log/fleet-mcp
USER fleet
CMD ["python", "server.py", "--transport", "streamable-http"]
requirements.txt
fastmcp==2.10.0
pydantic==2.8.0
httpx==0.28.1
structlog==24.4.0
python-dotenv==1.0.1
Pydantic v2.8 needs extra="allow" on nested config schemas or the allowlist payloads fail validation. I lost an afternoon to that exact error before pinning it.
Ship it as a signed docker:// image with an SBOM and it drops into a custom OCI catalog — one approved artifact instead of a wiki page of shell instructions.
Step 4: Verify with hostile drills
Run four drills: triage a crashed worker on reads alone with p50 under 50ms; attempt ten forbidden commands and confirm ten reasoned refusals; confirm twice with one token and verify exactly one execution; grep the audit log for a known secret and confirm only [REDACTED] appears.
The secret-leak war story: inspect output
My first build redacted tool arguments but not tool outputs. A container inspect returned environment variables including a database password, straight into the agent's context — then into the transcript log, then into the eval dataset. Three copies of a live credential before anyone noticed.
Output redaction on every return path fixed it, plus a CI grep that fails on unredacted patterns. Secret handling is a return-path problem — the output side is where credentials travel.
| Metric | Raw shell tool | Fleet MCP server |
|---|---|---|
| Read triage p50 | ~35ms, no log | 41ms, fully logged |
| Forbidden-command blocks / 10 | 0 | 10 with reasons |
| Double-exec on retry | Possible | Impossible by token pop |
| Secrets in transcripts | Found live password | Zero after output redaction |
| Distribution | Wiki + hope | Signed image + SBOM |
When NOT to use this pattern
Let's be clear. A single dev laptop with three containers needs the Docker CLI, not this server. Sub-second deploy pipelines should call the daemon directly — the preview-confirm round trip costs two tool calls you cannot afford mid-rollout. And if your fleet tooling already lives behind an internal platform API, wrap that API instead of the daemon.
Skip it for laptops and hot paths. Use it where agents touch shared infrastructure and every mutation needs a reason in the log.
Build the gates once and the whole class of wrong-container incidents disappears: open reads at 41ms, writes behind preview-confirm, secrets redacted on return, and a fleet your agents can triage without your pager going off.
By Deepak Bagada, Founder & Editor-in-Chief at Daily AI World.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
Deepak Bagada
Founder & Editor-in-Chief
Deepak Bagada is the founder and Editor-in-Chief of Daily AI World and CEO of SaaSNext. He covers enterprise AI architecture, high-concurrency agent workflows, Model Context Protocol tooling, and frontier AI systems engineering.
Human-Gated Deploys: Temporal Signals with Zero-Cost Waits
Next Story →FrontierSWE v2 Shakes Rankings: Fable 5.1 at 56.3%, Rivals 32%
Related Intelligence Analysis
Vercel AI SDK Tool Calling React: 5 Steps (2026)
Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...
Fact-Density vs. Word Count: The New SEO for 2026
Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...
NVIDIA Audex vs Qwen3.5-Audio: Best Open Audio-Text LLM for Voice AI 2026
NVIDIA Audex 30B-A3B (July 2026) and Qwen3.5-35B-A3B are the two leading open audio-text LLMs. Audex uniquely handles both audio understanding and generation in a single model while preserving text intelligence. Qwen3.5-...