Skip to main content
Subscribe
Front Page / AI Tools / Deep Dive

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

Deepak Bagada

Founder & Editor-in-Chief

Sep 20, 2026 Published
|
Sep 20, 2026 Updated
|
7 Minutes Reading Time
Core Takeaways for Founders & Builders
  • 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 , Founder & Editor-in-Chief at Daily AI World.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

🎉 Thank You for Subscribing!

Frequently Asked Questions
A free-form shell has unlimited blast radius and no audit trail — one ambiguous container name becomes a production outage. The fleet server exposes verbs instead of a shell: reads are open, writes pass an allowlist plus preview-confirm, and every call lands in an append-only audit log.
Preview checks the command against the allowlist and protected-name list, returning a single-use token valid for five minutes. Confirm pops the token and runs the command exactly once — retries get a clean refusal instead of a double execution.
Never mount the daemon socket into an agent sandbox — it is root over the daemon. This server is the only daemon client, it runs as an unprivileged user, and remote deployments sit behind OAuth proxy auth with metadata-document client identity.
Ship it as a versioned image with SBOM and signature, reference it by docker:// URI in a custom OCI catalog, and serve it through the gateway. Teams import one approved artifact instead of copying shell instructions from a wiki.
Deepak Bagada
Author Profile

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.

Related Intelligence Analysis

Briefing AI Tools

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

Deepak Bagada Deepak Bagada
12m read
Breaking AI Tools

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

Deepak Bagada Deepak Bagada
4m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc

Cookie & Privacy Preferences

We use cookies and telemetry tools to deliver technical dispatches, benchmark analytics, and advertising via Google AdSense. Review our Privacy Policy.