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 container | the hub | you, in Compose |
| What the hub needs | a socket to the policy proxy | nothing |
| What the image needs | nothing | a one-line shim (socat) |
| Configuration lives in | mcp.json | mcp.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
"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.
| Field | Default | Notes |
|---|---|---|
image | (required) | Digest recommended. Mutable tags are supported, but log a warning and remain an accepted supply-chain risk. |
pull | never | missing 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. |
network | none | Docker 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. |
pidsLimit | 256 | Maximum number of processes in the sandbox. |
cpus | 1 | CPU quota; fractional positive values are accepted. |
readOnly | true | Read-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 atype: "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.
Envis 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/runor/var/lib/docker. - Allowed endpoints:
_ping,version, container create/start/stop/wait/ attach/remove within themcp-sandbox-namespace, a label-filtered container list, image inspection, andimages/createonly for an entry that asked for"pull": "missing". Everything else is403.
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
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:
"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:
"scary": { "type": "unix", "socket": "/run/mcp/scary.sock" },
"remote-sandbox": { "type": "tcp", "host": "sandbox-host", "port": 9000 } 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 eitherThe 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
| Symptom | Cause |
|---|---|
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.Memory | The 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 configuration | The proxy cannot see the config the hub sees — check that both mount the same config directory. |
connect EACCES /run/proxy/docker.sock | The 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 proxy | group_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 JSON | The server writes to stdout. Under stdio that is the protocol channel. |
| A rotated secret is not picked up | Check 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. |