Skip to content

Sandboxing untrusted servers

A stdio server configured in mcp.json runs inside the hub's container, as the hub's user. It can read /data/jwt-key.pem, the state file with every OAuth client, /proc/1/environ with every other server's credentials, and whatever the network policy allows. That is fine for code you wrote or read. It is not fine for the interesting half of the MCP ecosystem.

The usual answer is to run such a server elsewhere and connect it as a remote HTTP server. It works, but it asks a lot of a program that only speaks stdio: an HTTP listener, a bearer token you now have to store in two places, membership in a shared network, and — if the server has no HTTP mode at all — a bridge process inside the image, which is one more piece of unreviewed code at exactly the trust boundary you were trying to draw.

mcp-hub offers two ways to keep the isolation and drop the HTTP:

type: "docker"type: "unix" / "tcp"
Who starts the containerthe hubyou, in Compose
What the hub needsa socket to the policy proxynothing
What the image needsnothinga one-line shim (socat)
Configuration lives inmcp.jsonmcp.json + your Compose file

Both carry the same protocol: newline-delimited JSON-RPC, the framing the specification asks custom transports to reuse when they run over a byte stream rather than a pipe. No adapter, no translation, no second dialect.

Docker servers

json
"scraper": {
  "type": "docker",
  "image": "registry.example/scraper-mcp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "command": ["python3", "-m", "scraper_mcp"],
  "env": { "HOME": "/data" },
  "secretsFrom": "scraper",
  "volumes": ["/srv/scraper/data:/data"],
  "ports": ["127.0.0.1:8686:8000"],
  "network": "scraper-net",
  "memory": "384m",
  "pidsLimit": 128,
  "cpus": 0.75
}

The hub creates that container, attaches to its stdin and stdout, starts it, and talks MCP across the boundary. When the server dies the container is removed and the supervisor restarts it with the usual backoff; when the entry changes, the container is replaced.

FieldDefaultNotes
image(required)Digest recommended. Mutable tags are supported, but log a warning and remain an accepted supply-chain risk.
pullnevermissing lets the hub fetch the image. never fails loudly instead of running whatever a registry serves today.
command / entrypoint(image default)Arrays of strings, like Docker's Cmd/Entrypoint.
env{}Passed to the container only. ${VAR} is expanded from the hub's environment.
secretsFrom(none)Name of an env file the proxy holds — see secrets.
volumes[]source:/target[:ro]. Source is an absolute host path or a named volume. Every entry is an explicit host-access grant by the operator.
ports[][ip:]hostPort:containerPort[/proto]. An omitted address means 127.0.0.1, not every interface.
networknoneDocker network name. Selecting a network is an explicit operator grant; none means no interface at all.
memory"512m"RAM limit as "384m", "1g" or a byte count.
pidsLimit256Maximum number of processes in the sandbox.
cpus1CPU quota; fractional positive values are accepted.
readOnlytrueRead-only root filesystem.
tmpfs["/tmp"]"/path" or "/path:options".
user(image default)"1000:1000" or a name.

Every container is created with all capabilities dropped, no-new-privileges, Privileged: false, no restart policy and AutoRemove. None of that is configurable: a knob that can only weaken the sandbox is a knob the policy would have to defend.

Only env values may use ${VAR}

Everything else — the image, mounts, ports, network, user, command — must be written out literally. The proxy validates those fields against this file and holds none of your secrets, so a variable there would be a field it could not check. That is exactly the field an attacker would pick.

Three things worth knowing

stdout belongs to the protocol. A server that prints to stdout under stdio corrupts the stream — the hub logs not JSON and drops that message. Logging must go to stderr, where the hub prefixes it [name] and passes it through to its own stderr, just like a stdio child's.

The container is recreated on every start. Stopping a sandbox always means removing the container, and starting one always means creating it fresh — this is exactly what on-demand lifecycling leans on: a sleeping sandbox holds no container at all, and a wake pays the full container start. A server with a long startup pays it again after a hub restart too. The hub answers 503 on that server's path meanwhile; nothing else waits for it.

One Docker host, one hub. Sandbox containers are named mcp-sandbox-<server> and labelled io.mcp-hub.owner=mcp-hub, and on startup the hub removes owned containers that its configuration no longer mentions. Two hubs sharing a daemon would therefore fight over the namespace and reap each other's sandboxes. If you run two (a test instance beside a live one, say), give them separate Docker hosts — or keep type: "docker" in one of them and use socket servers in the other.

The policy proxy

Creating containers means talking to the Docker daemon, and the daemon's API is root: one POST /containers/create with Privileged: true or a /:/host bind owns the machine. The hub is the internet-facing component. It must not have that socket.

So it does not get it. mcp-hub-docker-proxy is a second, much smaller image that holds the daemon socket and exposes a Unix socket to the hub. It reads the same mcp.json — read-only, owned by the host — and allows exactly the container operations that file describes.

┌─────────┐  unix socket   ┌──────────────┐  /var/run/    ┌────────┐
│ mcp-hub │───────────────▶│ docker-proxy │──docker.sock─▶│dockerd │
│ no sock │                │ policy from  │               └───┬────┘
└─────────┘                │   mcp.json   │                   │
     ▲                     └──────────────┘          ┌────────▼────────┐
     └────────── stdio over the attach stream ───────│ mcp-sandbox-scraper │
                                                     └─────────────────┘

The file is a trusted policy input: anyone who can edit mcp.json can grant a sandbox more host data or network reach. What the proxy enforces:

  • The container name must be mcp-sandbox-<server>, and <server> must be a type: "docker" entry in the config.
  • Hub and proxy complete a versioned policy handshake before the first Docker operation. Missing DOCKER_HOST, a direct daemon socket, an unreachable daemon, or a different policy version is refused.
  • Every start, stop, attach, wait and remove is preceded by a daemon-side inspect; both owner and server labels must match exactly.
  • The whole create request must match the one derived from that entry — image, mounts, ports, limits, flags. It is compared against a request rebuilt by the very function the hub used to build it, so the policy cannot drift from the code that sends the request.
  • Env is compared by key. Values belong to the hub; the proxy neither needs nor wants them.
  • Refused regardless of the config: Privileged, CapAdd, Devices, Mounts, host namespaces, joining another container's network, and any bind under /, /proc, /sys, /dev, /etc, /boot, /root, /run, /var/run or /var/lib/docker.
  • Allowed endpoints: _ping, version, container create/start/stop/wait/ attach/remove within the mcp-sandbox- namespace, a label-filtered container list, image inspection, and images/create only for an entry that asked for "pull": "missing". Everything else is 403.

Nothing is forwarded verbatim. Every allowed request is rebuilt — method, path, query, body — from the decision, so a duplicate query parameter, an extra JSON key or a second Content-Length has nothing to ride on.

What survives a fully compromised hub, then, is the ability to run exactly the containers mcp.json describes. Not a privileged one, not one with the host filesystem mounted, not one built from another image.

Compose

yaml
services:
  docker-proxy:
    image: ghcr.io/ni-c/mcp-hub-docker-proxy:0.7.0   # pin a digest in production
    container_name: mcp-hub-docker-proxy
    restart: unless-stopped
    # Access to the socket comes from the group, not from running as root.
    group_add: ["<gid of the docker group>"]
    read_only: true
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    volumes:
      - "./config:/config:ro"
      - "./secrets:/run/secrets:ro"
      - "/var/run/docker.sock:/var/run/docker.sock"
      - "proxy-sock:/run/proxy"

  mcp-hub:
    image: ghcr.io/ni-c/mcp-hub:0.10.0   # pin a digest in production
    depends_on: [docker-proxy]
    environment:
      DOCKER_HOST: "unix:///run/proxy/docker.sock"
    volumes:
      - "./config:/config:ro"
      - "./data:/data"
      - "proxy-sock:/run/proxy"

volumes:
  proxy-sock:

Run the two at the same version. They read the same file with the same parser, and they are published from the same pipeline under the same tags for that reason.

Find the group id with getent group docker. Both containers must agree on the socket volume; nothing else is shared between them.

Secrets the hub never sees

A sandboxed server usually needs credentials, and passing them through the hub would put them back in the process every stdio child can read. So it does not have to:

json
"scraper": { "type": "docker", "image": "registry.example/scraper-mcp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "secretsFrom": "scraper" }
secrets/scraper.env      # chmod 640, mounted into the proxy only
  SCRAPER_API_KEY=...
  SCRAPER_API_SECRET=...

The proxy appends those variables to the create request after it has validated it. The hub's mcp.json names the file, never its contents; the hub process never holds the values. Files larger than 64 KiB, symlinks, unsafe permissions, NUL bytes, duplicate keys and sets above 100 variables are refused. A key that collides with the entry's own env keys is refused too. Invalid secrets prevent startup and config reload rather than failing only on create.

Rotating a secret is an edit, not a restart. The proxy watches the secrets directory: when the content of a referenced file changes, it stops the affected sandbox container, and the hub's supervisor recreates it — the replacement create reads the file fresh, like every create does. A touch or a comment-only edit changes nothing and triggers nothing. A broken edit (bad permissions, a symlink, a parse error, a key that collides with env) is logged and ignored, so it cannot crash-loop a running server; fix the file and the next valid content applies. Set SANDBOX_SECRETS_WATCH=false on the proxy to opt out — changes then apply on the next container create only.

Socket servers

If you would rather not give any component the Docker socket, run the container yourself and let the hub connect to a socket:

json
"scary": { "type": "unix", "socket": "/run/mcp/scary.sock" },
"remote-sandbox": { "type": "tcp", "host": "sandbox-host", "port": 9000 }
yaml
  scary-mcp:
    image: ghcr.io/example/scary-mcp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
    command: socat UNIX-LISTEN:/run/mcp/scary.sock,fork,mode=0660 EXEC:"scary-mcp"
    user: "1000:1000"          # same uid as the hub, so it may open the socket
    network_mode: none          # possible precisely because there is no HTTP
    read_only: true
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    volumes: ["mcp-sockets:/run/mcp"]
    environment:
      SCARY_TOKEN: "${SCARY_TOKEN}"   # the hub never learns this either

The shim is whatever pipes a socket to a process — socat is one line, and any image that has a shell and a static helper will do. The hub connects, retries with backoff if the socket is not there yet, and treats the server exactly like any other.

network_mode: none is worth pausing on: an HTTP upstream can never have it, because HTTP needs an interface to listen on. A server that has no business reaching the network can be given no network at all and still be a first-class MCP server here.

Which one to use

Use type: "docker" when you want the sandbox described in one file, next to the server it belongs to, and you are willing to run the proxy.

Use type: "unix" when you want no component to hold the Docker socket, when the sandbox is managed by something other than this hub, or when it lives on another host (type: "tcp").

Use a remote HTTP server when the upstream is genuinely a network service that happens to speak MCP.

Troubleshooting

SymptomCause
image "x" is not present and "pull" is "never"Build or pull it, or set "pull": "missing".
403 … create request does not match the configuration — .HostConfig.MemoryThe proxy is running an older mcp.json than the hub. Both poll the file; it resolves itself on the next retry, within seconds.
403 … is not a docker server in the configurationThe proxy cannot see the config the hub sees — check that both mount the same config directory.
connect EACCES /run/proxy/docker.sockThe socket volume is not shared with the hub, or the proxy's SOCKET_MODE/uid does not let it in.
permission denied … /var/run/docker.sock in the proxygroup_add is missing or has the wrong gid.
Server flaps up / down (container exited)The server exits on its own. Its stderr is in the hub's log, prefixed with the server name.
Handshake never completes, log shows not JSONThe server writes to stdout. Under stdio that is the protocol channel.
A rotated secret is not picked upCheck the proxy log: ignoring broken secrets update means the edit was invalid and the old values stay; no line at all means the content did not actually change (comments and whitespace do not count), or SANDBOX_SECRETS_WATCH=false is set.

Released under the MIT License. Not affiliated with Anthropic; “Claude” is a trademark of Anthropic PBC.