Building contain-yourself: A Dockerized MCP Sandbox for AI Security Agents
MCP Docker Python

Building contain-yourself: A Dockerized MCP Sandbox for AI Security Agents

How I wrapped standard Linux networking utilities in Model Context Protocol (MCP) servers and Docker containers to give LLMs safe, structured access to recon tools.

Why I Decided to Do This

We’re in an era where AI agents can reason through complex workflows, but giving them raw, unrestricted shell access to run security and networking tools is a recipe for disaster. If you’ve ever watched an LLM hallucinate a flag for nmap or decide to enthusiastically run ffuf against a /8 subnet without rate limits, you know exactly what I mean.

The gap was obvious: LLMs need access to standard Linux utilities to perform meaningful reconnaissance, but they need guardrails. I needed a way to expose complex CLI tools like httpx, subfinder, and nuclei to AI agents, but with strict safety limits, structured JSON outputs instead of messy stdout, and absolute host isolation.

Architecture/System Design

contain-yourself bridges the gap between unpredictable AI agents and raw command-line binaries by wrapping them in Model Context Protocol (MCP) servers.

The system relies on three core architectural pillars:

  1. MCP Interface: We use FastMCP (mcp>=1.0.0) running on Python 3.12 to expose tools to compatible clients (like Claude Desktop). This provides a typed, clean interface where tools declare their required parameters, and the agent natively understands what inputs are expected.
  2. Dockerized Isolation: Every single tool gets its own container. Whether it’s domain-mcp doing WHOIS lookups or gowitness-mcp firing up headless Chromium for screenshots, it happens in a sandbox. There is zero host access.
  3. The Shared Utility Layer: To prevent resource exhaustion, all servers leverage a shared utils.py module. This enforces strict constraints: MAX_TARGETS limits, numeric parameter clamping (e.g., bounding concurrency threads), and hard subprocess timeouts.

contain-yourself data flow diagram
Data flows linearly - The LLM decides to scan a host -> MCP Client sends a JSON request -> The containerized FastMCP server validates and clamps inputs -> The underlying binary (e.g., nmap) executes -> The Python wrapper parses the messy XML/stdout -> A Pydantic BaseModel enforces the output schema -> The LLM receives structured JSON.

Implementation Details

The meat of this build was wrangling heavily optimized Go and C binaries into a uniform Python/Docker wrapper.

For compiled tools from ProjectDiscovery (like httpx, subfinder, nuclei), I used a multi-stage Docker build pattern. We compile the binary in a Go Alpine builder, then copy it into a lightweight Python 3.12 slim runtime managed by uv. This keeps image sizes manageable while isolating the execution environment.

# Snippet from subfinder-mcp/Dockerfile
FROM golang:1.22-alpine AS builder
RUN go install github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest

FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY --from=builder /root/go/bin/subfinder /usr/local/bin/

WORKDIR /app
COPY requirements.txt .
RUN uv pip install --system --no-cache -r requirements.txt
COPY shared/utils.py .
COPY subfinder-mcp/server.py .

EXPOSE 8000
CMD ["python", "server.py"]

On the Python side, the biggest hurdle was ensuring the LLM received predictable data. Earlier iterations returned stringified dicts, which occasionally led to schema drift. By upgrading the FastMCP tools to return strictly typed Pydantic BaseModel instances, the agent knows exactly what to expect.

Here is what the safety wrapper looks like in practice. Notice how we use the shared utilities to clamp inputs before the subprocess ever fires:

@mcp.tool()
def enumerate_subdomains(
    domains: list[str],
    threads: Optional[int] = 10,
    timeout: Optional[int] = 30
) -> SubfinderResponse:
    """Passively enumerate subdomains for a list of root domains."""
    
    # Enforce safety limits
    if not domains:
        return SubfinderResponse(error="No domains provided.")
        
    if len(domains) > MAX_TARGETS:
        return SubfinderResponse(error=f"Too many targets. Maximum is {MAX_TARGETS}.")
        
    safe_threads = clamp(threads, 1, 50)
    safe_timeout = clamp(timeout, 5, 120)
    
    # ... execute subfinder subprocess ...

Key Takeaways/Lessons Learned

  • Parsing CLI output is a dark art: Getting clean JSON out of tools that were designed to print colored ASCII art to standard out is tedious. Parsing nmap XML output into a clean JSON structure for an LLM was easily the most annoying part of the build, but absolutely necessary for agent comprehension.
  • Base64 is a superpower for agents: For the gowitness-mcp tool, passing back base64-encoded PNG strings directly in the JSON response allowed multimodal agents to “see” the web pages they were scanning without needing volume mounts or external image hosting.
  • What I’d do differently: Right now, there are 11 individual Docker containers, which means 11 MCP servers to attach to a client. While docker-compose handles the orchestration nicely over streamable-http, managing the config files for standard stdio MCP clients (like Claude Desktop) is bulky. Grouping related tools into single “domain-specific” containers (e.g., a single recon-mcp server) might simplify deployment in the future.