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:

typescript
import { 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:

dockerfile
FROM 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

yaml
version: "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:

typescript
import { 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

  1. Running containers as root: Even inside a container, running as root expands the attack surface. Always use a non-root user.
  2. Mounting the Docker socket: Never mount /var/run/docker.sock into an MCP server container—it grants full control over the host.
  3. Shared networks between unrelated servers: Servers that don't need to communicate should be on separate networks to prevent lateral movement.

Best Practices

  1. Use read-only filesystems: Mount container filesystems as read-only with explicit tmpfs for temporary data. This prevents an attacker from writing malicious files.
  2. Set memory and CPU limits: Always define resource limits to prevent denial-of-service from runaway processes.
  3. 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-privileges to minimize escalation risk

Code Examples

yaml
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
✓ Completed