Introduction
In production MCP deployments, each server is a potential attack surface. Sandboxing and isolation techniques confine the blast radius of a compromised server, preventing lateral movement across your infrastructure. Defense in depth means that even if input validation fails, the attacker remains trapped in a restricted environment.
Key Concepts
- Process Isolation: Each MCP server runs as a separate OS process with its own memory space, preventing one server from reading another's data.
- Container Isolation: Docker containers provide filesystem, network, and process namespace isolation, creating a minimal attack surface.
- chroot / Volume Mounts: Restrict a server's view of the filesystem to only the directories it needs.
- Resource Limits: CPU, memory, and file descriptor limits prevent a compromised or buggy server from consuming all host resources (denial of service).
- Egress Filtering: Network policies that restrict which external hosts a server can contact, preventing data exfiltration.
- Seccomp Profiles: Linux kernel feature that restricts which system calls a process can make.
Real World Context
A company deploys five MCP servers: a code search tool, a database query tool, a file manager, a CI/CD trigger, and a monitoring dashboard. Without isolation, a vulnerability in the file manager could be exploited to read database credentials, trigger deployments, or exfiltrate source code. With proper sandboxing, the file manager can only access /data/uploads, has no network access, and runs with 256MB memory limit.
Deep Dive
Process Isolation with stdio Transport
The stdio transport naturally provides process isolation since each server runs as a child process:
typescriptimport { spawn } from "child_process"; // Each server is a separate process const fileServer = spawn("node", ["./servers/file-server.js"], { stdio: ["pipe", "pipe", "pipe"], env: { // Minimal environment - only what the server needs NODE_ENV: "production", ALLOWED_ROOT: "/data/uploads", MAX_FILE_SIZE: "10485760" // 10MB }, uid: 1001, // Run as unprivileged user gid: 1001 });
This spawns the file server with a stripped-down environment and runs it as an unprivileged user, limiting what it can do even if compromised.
Docker-Based Isolation
For stronger isolation, run each MCP server in its own container:
dockerfileFROM node:22-slim RUN groupadd -r mcpuser && useradd -r -g mcpuser mcpuser WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY src/ ./src/ # Drop all capabilities, run as non-root USER mcpuser # Health check for orchestrators HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD curl -f http://localhost:3100/health || exit 1 EXPOSE 3100 CMD ["node", "src/server.js"]
The container runs as a non-root user with no elevated capabilities. Combine this with Docker's security options for even tighter control.
Docker Compose with Resource Limits and Network Isolation
yamlversion: "3.8" services: file-server: build: ./servers/file-server deploy: resources: limits: cpus: "0.5" memory: 256M pids: 100 reservations: memory: 128M volumes: - ./data/uploads:/data/uploads:ro networks: - file-net security_opt: - no-new-privileges:true read_only: true tmpfs: - /tmp:size=50M db-server: build: ./servers/db-server deploy: resources: limits: cpus: "1.0" memory: 512M networks: - db-net environment: - DATABASE_URL=postgresql://reader:${DB_PASS}@db:5432/app security_opt: - no-new-privileges:true networks: file-net: internal: true db-net: internal: true
This configuration enforces read-only filesystem access for the file server, separate networks preventing cross-server communication, PID limits to prevent fork bombs, and memory caps to prevent resource exhaustion.
Resource Limits Without Docker
For non-containerized deployments, use OS-level limits:
typescriptimport { setrlimit } from "posix"; import { spawn } from "child_process"; // Using ulimit-style constraints const server = spawn("node", ["server.js"], { stdio: ["pipe", "pipe", "pipe"], env: { ...minimalEnv }, // On Linux, use cgroups or systemd resource controls }); // Alternatively, limit within the server process itself process.on("warning", (warning) => { if (warning.name === "MaxListenersExceededWarning") { process.exit(1); // Prevent file descriptor leaks } }); setTimeout(() => { const memUsage = process.memoryUsage(); if (memUsage.heapUsed > 200 * 1024 * 1024) { console.error("Memory limit exceeded, shutting down"); process.exit(1); } }, 5000);
Self-imposed limits serve as a safety net when OS-level controls are unavailable.
Common Pitfalls
- Running containers as root: Even inside a container, running as root expands the attack surface. Always use a non-root user.
- Mounting the Docker socket: Never mount
/var/run/docker.sockinto an MCP server container—it grants full control over the host. - Shared networks between unrelated servers: Servers that don't need to communicate should be on separate networks to prevent lateral movement.
Best Practices
- Use read-only filesystems: Mount container filesystems as read-only with explicit tmpfs for temporary data. This prevents an attacker from writing malicious files.
- Set memory and CPU limits: Always define resource limits to prevent denial-of-service from runaway processes.
- Apply
no-new-privileges: This Linux security option prevents a process from gaining additional privileges through setuid binaries or other escalation vectors.
Summary
- Process isolation via stdio transport gives basic separation; Docker containers provide stronger boundaries
- Resource limits (CPU, memory, PIDs) prevent denial-of-service from compromised servers
- Read-only filesystems with explicit volume mounts restrict file access to exactly what is needed
- Network isolation prevents lateral movement between unrelated MCP servers
- Always run as non-root and apply
no-new-privilegesto minimize escalation risk
Code Examples
services:
mcp-server:
build: ./server
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
pids: 100
volumes:
- ./data:/data:ro
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:size=50M
networks:
- isolated