Security Isolation (Docker · Sandbox · None)
MCPProxy can confine stdio MCP servers so a malicious or buggy server cannot freely touch the host. There are three isolation modes — docker, sandbox, and none — selected by docker_isolation.mode (global) or isolation.mode (per-server). Most of this page describes Docker mode (the default and most capable); see Isolation Modes for the native Sandbox mode and the scanner behaviour under each mode.
The global config key is still docker_isolation for backward compatibility, but its mode field selects any of the three modes — it is not Docker-only.
Overview
Docker isolation automatically wraps stdio-based MCP servers in Docker containers, providing:
- Process Isolation: Each server runs in a separate container
- File System Isolation: Servers cannot access host file system
- Network Isolation: Configurable network modes for security
- Resource Limits: Memory and CPU limits prevent resource exhaustion
- Automatic Runtime Detection: Maps commands to appropriate Docker images
Isolation Modes
MCPProxy resolves an isolation mode for every stdio server. Set it globally with docker_isolation.mode and override per-server with isolation.mode:
| Mode | What it does | Where it works | uid/gid drop |
|---|---|---|---|
docker | Wraps the server in a Docker container (process/FS/network isolation, resource limits). The default and most capable mode. | Any host with a working Docker daemon. | Yes (container user) |
sandbox | Runs the server natively under a Linux Landlock filesystem allowlist + setrlimit resource caps — no Docker required. For hosts where Docker isolation is unavailable or broken (e.g. snap-docker + AppArmor). | Linux 5.13+ only. macOS/Windows: documented no-op ⇒ behaves like none. | No — see Honest limitations |
none | No confinement; the server runs directly on the host. | Everywhere. | n/a |
{
"docker_isolation": { "mode": "sandbox" },
"mcpServers": [
{ "name": "trusted-local", "command": "uvx", "args": ["x"], "isolation": { "mode": "none" } }
]
}
Back-compat with the legacy enabled flag
The older boolean docker_isolation.enabled (and per-server isolation.enabled) still works and is mapped to a mode:
- an explicit
modealways wins; - otherwise
enabled: true⇒docker,enabled: false⇒none; - a missing isolation config ⇒
none.
Per-server precedence: explicit per-server mode → per-server legacy enabled → global mode → global legacy enabled.
Sandbox mode (Landlock)
sandbox mode confines a stdio server without Docker by applying a Linux Landlock LSM ruleset (a writable-path allowlist) plus setrlimit resource caps to the process before it execs, then preserving the raw stdin/stdout JSON-RPC pipes. It needs no user namespaces, so it is unaffected by kernel.apparmor_restrict_unprivileged_userns=1 — which is exactly why it works where bubblewrap/userns-based sandboxes are blocked (e.g. Ubuntu 24.04 with snap-docker).
Scanner behaviour under each mode (MCP-34.4)
The security scanner plugins are Docker-based and, since Spec 077, belong to the opt-in deep-scan layer (they run only when security.deep_scan.enabled: true). Under a non-Docker isolation mode they cannot run at all, so MCPProxy skips them and surfaces the skip informationally rather than failing silently:
| Mode | Docker scanner plugins | In-process scanner (tpa-descriptions) | Scan result for a server with only Docker scanners |
|---|---|---|---|
docker | Run normally (when deep scan is on) | Runs | As scanned |
sandbox / none | Skipped with an honest, mode-specific reason pointing at MCPX_DOCKER_SNAP_APPARMOR | Still runs | The baseline verdict is unchanged; the skip surfaces via the informational deep_scan descriptor |
Since Spec 077 (FR-008) a skipped or failed deep scanner never downgrades the baseline verdict to degraded — the old security_scan.status: "degraded" behaviour was removed. The always-emitted deep_scan descriptor carries the skip instead: { "enabled": <bool>, "ran": <bool>, "available": <bool>, "scanners_failed": [...], "skipped_scanners": [...] }. The deterministic in-process tpa-descriptions baseline scanner is the sole source of the verdict, so a low/zero risk score from a baseline-only scan is a trustworthy result, and the incomplete Docker coverage is reported as an informational note. To run the full Docker-based scanner fleet, enable deep scan and use mode: docker on a host with a working Docker daemon, or replace snap-docker with a distro Docker package (see the error doc). The skip is also logged at startup:
WARN Isolation mode runs no Docker for scanner plugins; Docker-based scanners will be skipped … {"isolation_mode": "sandbox"}
Configuration
Global Docker Isolation
Add to your ~/.mcpproxy/mcp_config.json:
{
"docker_isolation": {
"enabled": true,
"memory_limit": "512m",
"cpu_limit": "1.0",
"timeout": "60s",
"network_mode": "bridge",
"registry": "docker.io",
"default_images": {
"python": "ghcr.io/astral-sh/uv:python3.13-bookworm-slim",
"python3": "ghcr.io/astral-sh/uv:python3.13-bookworm-slim",
"uvx": "ghcr.io/astral-sh/uv:python3.13-bookworm-slim",
"pip": "ghcr.io/astral-sh/uv:python3.13-bookworm-slim",
"pipx": "ghcr.io/astral-sh/uv:python3.13-bookworm-slim",
"node": "node:22",
"npm": "node:22",
"npx": "node:22",
"yarn": "node:22",
"go": "golang:1.21-alpine",
"cargo": "rust:1.75-slim",
"rustc": "rust:1.75-slim",
"ruby": "ruby:3.2-alpine",
"gem": "ruby:3.2-alpine",
"php": "php:8.2-cli-alpine",
"composer": "php:8.2-cli-alpine",
"binary": "alpine:3.18",
"sh": "alpine:3.18",
"bash": "alpine:3.18"
},
"extra_args": []
}
}
Configuration Options
| Field | Description | Default |
|---|---|---|
enabled | Enable Docker isolation globally | false |
memory_limit | Memory limit per container | "512m" |
cpu_limit | CPU limit per container | "1.0" |
timeout | Container startup timeout | "30s" |
network_mode | Docker network mode | "bridge" |
registry | Docker registry to use | "docker.io" |
default_images | Runtime to image mappings. The optional uvx-git key (not shipped in the defaults) overrides the git-capable image used when a Python runner installs from a git+ URL | See above |
extra_args | Additional docker run arguments | [] |
Git dependencies (uvx-git)
The Python default image is Astral's slim uv image, which does not contain
git. A server installed straight from a repository —
{ "name": "my-server", "command": "uvx", "args": ["--from", "my-server@git+https://github.com/o/r", "my-server"] }
— cannot resolve without it, and fails with Git executable not found /
Git operation failed (MCPX_DOCKER_MISSING_TOOLCHAIN).
MCPProxy detects the git+ URL in a Python package runner's arguments and runs
that server only on a git-capable image — everyone else keeps the small slim
image. By default that is ghcr.io/astral-sh/uv:python3.13-bookworm, which
ships git. Set the uvx-git key to use your own mirror or a custom build:
{ "docker_isolation": { "default_images": { "uvx-git": "my-registry.example/uv-git:1" } } }
default_images from your config file is merged over the built-in map, so a
partial map like the one above only changes the keys it lists — every other
runtime keeps its built-in image.
Mirrored / air-gapped registries. uvx-git is deliberately not part of
the built-in map, so its presence in your config means exactly one thing: you
chose that image, and it is used — even if you set it to the same public value
MCPProxy ships. Two things follow:
- If you set
registry, the built-in git-capable image is pulled from your registry (<registry>/astral-sh/uv:python3.13-bookworm) rather than fromghcr.io. - If you retargeted
uvx/pythonat your own registry and never setuvx-git, the server runs on your image instead of MCPProxy reaching outside your registry for a public one, and a warning naming this key is logged. Pointuvx-gitat a git-capable image to get the substitution back:
{ "docker_isolation": { "default_images": { "uvx-git": "mirror.internal/astral/uv:python3.13-bookworm" } } }
To turn the substitution off entirely, set the key to an empty string; those
servers then keep whatever uvx/python image you configured:
{ "docker_isolation": { "default_images": { "uvx-git": "" } } }
A per-server isolation.image override always wins over this selection, so a
pinned image must ship git itself. node/npx need no equivalent: node:22
already includes git, and the substitution never applies to them.
Per-Server Configuration
You can override isolation settings per server:
{
"mcpServers": [
{
"name": "custom-python-server",
"command": "python",
"args": ["-m", "my_server"],
"isolation": {
"enabled": true,
"image": "my-custom-python:latest",
"network_mode": "none",
"working_dir": "/app",
"extra_args": ["--cap-drop=ALL"]
},
"enabled": true
},
{
"name": "no-isolation-server",
"command": "python",
"args": ["-m", "trusted_server"],
"isolation": {
"enabled": false
},
"enabled": true
}
]
}
Runtime Detection
MCPProxy automatically detects the runtime type based on the command:
Python Environments
python,python3→python:3.11uvx→python:3.11(includes uv package manager)pip,pipx→python:3.11
Node.js Environments
node→node:20npm,npx→node:20yarn→node:20
Other Languages
go→golang:1.21-alpinecargo,rustc→rust:1.75-slimruby,gem→ruby:3.2-alpinephp,composer→php:8.2-cli-alpine
Shell/Binary
sh,bash→alpine:3.18- Unknown commands →
alpine:3.18
Why Full Images Instead of Slim/Alpine?
MCPProxy uses full Docker images (python:3.11 instead of python:3.11-slim) because:
- Git Support: Many MCP servers install packages from Git repositories using
git+https://URLs - Build Tools: Some packages require compilation during installation
- System Dependencies: Full images include common libraries needed by MCP servers
This trade-off prioritizes compatibility over image size.
Environment Variables
Environment variables from server configuration are automatically passed to containers:
{
"mcpServers": [
{
"name": "api-server",
"command": "uvx",
"args": ["some-package"],
"env": {
"API_KEY": "your-secret-key",
"DEBUG": "true"
},
"enabled": true
}
]
}
These become Docker arguments: -e API_KEY=your-secret-key -e DEBUG=true
Docker-in-Docker Prevention
MCPProxy automatically skips isolation for servers that are already Docker commands:
{
"mcpServers": [
{
"name": "existing-docker-server",
"command": "docker",
"args": ["run", "-i", "--rm", "mcp/some-server"],
"enabled": true
}
]
}
This prevents Docker-in-Docker complications.
Debugging
Check Docker Isolation Status
# Run with debug logging
mcpproxy serve --log-level=debug
# Filter for isolation messages
mcpproxy serve --log-level=debug 2>&1 | grep -i "docker isolation"
Monitor Docker Containers
# List MCPProxy containers
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
# View logs from a specific container
docker logs <container-id>
# Watch container resource usage
docker stats
Common Issues
Container startup timeouts:
- Increase
timeoutin docker_isolation config - Check if Docker images need to be pulled
- Verify network connectivity for package installations
Environment variables not working:
- Check that variables are defined in server
envsection - Use debug logging to see Docker command arguments
- Verify container has access to required environment
Git/package installation failures:
- Ensure using full images (
python:3.11notpython:3.11-slim) - Check container logs for specific error messages
- Verify network access for package repositories
Container Lifecycle
Startup
When a Docker-isolated server starts:
- MCPProxy detects runtime type (npm, uvx, python, etc.)
- Selects appropriate Docker image
- Runs container with stdio transport (
docker run -i) - Establishes MCP connection via stdin/stdout
Shutdown
When MCPProxy stops, containers are cleaned up with a 30-second timeout:
- Graceful Stop:
docker stop(sends SIGTERM to container) - Force Kill:
docker killif container doesn't stop gracefully
Containers are labeled with com.mcpproxy.managed=true for identification
and com.mcpproxy.server=<server name> (the raw, unsanitised name) for
ownership.
Container ownership
Every container mcpproxy creates is named
mcpproxy-<sanitised server name>-<4 random chars>. The name alone does not
identify the server — a/b and a-b both sanitise to a-b — so every
cleanup path (the pre-start sweep for stale containers, the container
captured from --cidfile, the disconnect fallbacks by exact name, by name
pattern and by image name) inspects the container and stops or removes it
only when its com.mcpproxy.server label and canonical name both match
the server being cleaned up — and it re-inspects the container immediately
before every docker stop, kill or rm -f (the kill after a failed stop
included), never acting on an earlier listing, so a container renamed,
relabelled or replaced in between is left alone. Containers you started yourself with
docker run --name …, or that pre-date the label, are never touched by any
of these paths, and a container that merely shares an image with a server's
is never stopped on that server's behalf. Housekeeping records in the
per-server log carry container_owner (the label value read back from
Docker) so tail_log can attribute them to the
right server; the pre-start "Docker isolation configured" record names the
generated container name before Docker has created anything and carries no
owner. The child process's own output lines (the docker CLI's stderr
included) are written to the per-server log as the message field of a
stderr or launcher record marked child_output, never as the record
text.
Two consequences of the ownership rule are worth knowing:
- Servers you configure as
docker run …yourself (no isolation) get nocom.mcpproxy.serverlabel — MCPProxy only labels the containers it builds for isolation — so MCPProxy never stops or removes their container, not even through the--cidfileit injects into your command. Use--rm(and let the container exit when its stdin closes) or stop it by hand; earlier versions would stop it via the cidfile and, if that capture failed, every container on the same image, yours or not. - Renaming a server changes the label value a container must carry. A
container created under the old name is no longer owned by the new one,
so it is left alone by the pre-start sweep and must be removed manually
(
docker rm -f). - The shutdown and emergency sweeps (every
com.mcpproxy.managedcontainer on shutdown; every one carrying this instance's id when shutdown fails) apply the same rule: only containers canonically owned by a server in the current configuration are stopped or removed — each one re-inspected immediately before its stop, kill or removal through the same check every per-server cleanup uses, so a container renamed or relabelled after the sweep listed it is left alone — and the disconnect-timeout path re-checks the ownership of the id it tracked beforedocker rm -f. A container that merely carries the mcpproxy labels — one you labelled yourself, or an orphan of a server that is no longer configured — is left alone and only counted in a warning, never named. Remove such orphans by hand (see below).
Manual Cleanup
If containers remain after MCPProxy stops:
# List MCPProxy-managed containers
docker ps --filter "label=com.mcpproxy.managed=true"
# Remove all MCPProxy containers
docker rm -f $(docker ps -q --filter "label=com.mcpproxy.managed=true")
See Shutdown Behavior for detailed subprocess lifecycle documentation.
Honest limitations
sandbox mode is deliberately scoped. Known limitations:
- No uid/gid drop. Dropping to an unprivileged uid/gid requires
CAP_SETUID/CAP_SETGID(i.e. running as root). When mcpproxy runs unprivileged, the uid/gid drop is best-effort and typically a no-op — the sandboxed process keeps the launching user's identity. Landlock (filesystem) andsetrlimit(resource caps) still apply. Docker mode does drop to a container user. This is an honest trade-off, not a bug. - Linux-only. Landlock is a Linux 5.13+ feature. On older kernels the launcher degrades best-effort (fewer access-right bits enforced). On macOS/Windows
sandboxis a documented no-op and behaves likenone. - Filesystem + resources only. Landlock confines the filesystem write-allowlist; it does not provide network namespacing. For network-sensitive servers, use
dockermode withnetwork_mode: none. - Docker-based scanners do not run under
sandbox/none. They are skipped and the skip is surfaced via the informationaldeep_scandescriptor — it never downgrades the baseline verdict (Spec 077 FR-008). A native scanner runtime is a future enhancement.
Platform support matrix
| Platform | docker | sandbox | none | Docker scanner plugins |
|---|---|---|---|---|
| Linux (kernel ≥ 5.13) | ✅ (needs Docker daemon) | ✅ Landlock + rlimits (no uid/gid drop) | ✅ | ✅ under docker (deep scan on); skipped (informational, no verdict change) under sandbox/none |
| Linux (kernel < 5.13) | ✅ (needs Docker daemon) | ⚠️ best-effort: rlimits apply, Landlock partial/unavailable | ✅ | same as above |
| macOS | ✅ (Docker Desktop) | ⚠️ no-op ⇒ effectively none | ✅ | ✅ under docker; n/a otherwise |
| Windows | ✅ (Docker Desktop) | ⚠️ no-op ⇒ effectively none | ✅ | ✅ under docker; n/a otherwise |
Security Considerations
Docker isolation provides strong security boundaries but consider:
- Network Access: Containers can still access the network by default
- Resource Limits: Set appropriate memory/CPU limits
- Image Trust: Use trusted base images from official repositories
- Secrets: Environment variables are visible in container inspect output
For maximum security, consider:
- Using
"network_mode": "none"for servers that don't need network access - Adding
--cap-drop=ALLto extra_args to remove Linux capabilities - Using custom minimal images for specific use cases