# Should Each MCP Server Run in Its Own Sandbox? How to Set It Up

> Yes, for any server you didn't write or that holds a secret. Run each one in its own --no-env sandbox with only its own -e variables, bridge stdio servers to Streamable HTTP on 127.0.0.1, host each port privately, and connect your client to every URL with its own __sbx_auth cookie header.

Source: https://sandboxer.com/guides/run-each-mcp-server-in-its-own-sandbox. Updated 2026-10-27.

**Short answer:** Yes, for any server you didn't write or that holds a secret. Run each one in its own --no-env sandbox with only its own -e variables, bridge stdio servers to Streamable HTTP on 127.0.0.1, host each port privately, and connect your client to every URL with its own __sbx_auth cookie header.

An agent with five MCP servers on your laptop runs five programs as you. Each one can read your home directory, your shell's environment and every other server's config file, and each one can reach your local network. Most servers need none of that. This page sets up the opposite: one microVM per server, so a server only has what you gave it.

If you want to try a server before you trust it, test it first with the [MCP Inspector in a sandbox](/guides/test-an-mcp-server-with-the-mcp-inspector). If you want an agent to *create* sandboxes through MCP, that's [Sandboxer's own MCP server](/guides/local-or-hosted-mcp-for-cursor-claude-code-codex), a different thing.

## What one sandbox per server buys you

| Shared by servers on one machine | One sandbox per server |
| - | - |
| Filesystem: any server can read the others' files and config | Each VM has its own disk. Server B's sandbox had no `/home/user/project/notes.txt`, which server A serves |
| Secrets: every variable in the environment, every token file on disk | Each sandbox holds only its own `-e` values. Server B saw `EVERYTHING_SERVER_TOKEN`, never `FILES_SERVER_TOKEN` |
| Network: servers can talk to each other on localhost | Sandbox to sandbox is blocked. From B, A's SSH port and port 8000 both timed out |
| One access path for all of them | Each hosted URL has its own token. A's URL answered `401` to B's cookie |
| A crash, a runaway process or a memory leak hits all of them | Each server has its own 2 vCPU and 4 GB (`small`) |

The env row matters more than it looks. The reference [everything](https://github.com/modelcontextprotocol/servers/tree/main/src/everything) server has a `get-env` tool that returns its whole process environment to the client. Plenty of real servers can read files or run shell commands, which amounts to the same thing. Whatever a server can read, the model can be talked into returning.

When it isn't worth it: a server you wrote that only calls one API with a key you'd give the agent anyway. A sandbox per server costs a hosted URL to manage and one network round trip per call (about 130 ms in our test, below).

## What we ran

On production, October 27, 2026, from a laptop in Miami. Two `small` sandboxes, each made with `--no-env`:

- **Sandbox A**: the reference [filesystem](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem) server 2.0.1. It speaks stdio only, so [supergateway](https://github.com/supercorp-ai/supergateway) 4.2.1 serves it as Streamable HTTP.
- **Sandbox B**: the reference everything server 2.0.1, behind supergateway too, so both bind `127.0.0.1` the same way.
- **The client**: the MCP [TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) 1.32.2 on the laptop, and Claude Code 2.1.311.

## 1. One sandbox per server, each with only its own secret

```bash
sbx new --name mcp-files --type small --no-env -e FILES_SERVER_TOKEN=not-a-real-secret-files
sbx new --name mcp-everything --type small --no-env -e EVERYTHING_SERVER_TOKEN=not-a-real-secret-everything
```

`--no-env` means the sandbox gets none of your account's variables, secret files, GitHub token or agent logins ([environments](/docs/environments)). The `-e` value is the one secret that server needs. Use the variable name your server's README asks for. Ours are fake.

A `--no-env` sandbox still gets a `SANDBOXER_TOKEN`. It can read only that one sandbox and host its ports. It can't run commands, create sandboxes or read your account ([pass secrets safely](/guides/pass-secrets-to-a-sandbox-safely) shows what it can and can't do). So a server that leaks its environment leaks its own secret and a token that can't touch anything else.

Sandboxes stop after an hour by default. For a server you want up all day, add `--ttl 28800` (8 hours) or more.

## 2. Run the server as a service on 127.0.0.1

This is sandbox A's setup. We piped it in with `sbx exec -i <id> --timeout 600 "bash -s" < setup-files.sh`:

```bash
set -e
sudo npm i -g --silent supergateway@4.2.1 @modelcontextprotocol/server-filesystem@2.0.1
mkdir -p /home/user/project && echo "only the files server can read this" > /home/user/project/notes.txt
sudo tee /etc/systemd/system/mcp-files.service >/dev/null <<'UNIT'
[Unit]
Description=MCP filesystem server over Streamable HTTP
After=network-online.target

[Service]
User=user
EnvironmentFile=/etc/sandboxer/env
ExecStart=/usr/bin/supergateway --stdio "mcp-server-filesystem /home/user/project" --outputTransport streamableHttp --host 127.0.0.1 --port 8000 --stateful --sessionTimeout 600000 --logLevel none
Restart=always

[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload && sudo systemctl enable --now mcp-files
sleep 3
sudo ss -ltnp | grep ':8000 '
```

```text
Created symlink /etc/systemd/system/multi-user.target.wants/mcp-files.service → /etc/systemd/system/mcp-files.service.
LISTEN 0      511        127.0.0.1:8000      0.0.0.0:*    users:(("node",pid=636,fd=21))
```

It took 10 seconds. Sandbox B is the same script with `@modelcontextprotocol/server-everything@2.0.1` and `--stdio "mcp-server-everything stdio"`. Three lines in that unit are easy to miss:

- **`EnvironmentFile=/etc/sandboxer/env`.** systemd starts services with a clean environment, so without this line the server never sees its `-e` variable.
- **`--host 127.0.0.1`.** Each sandbox has a public IPv6 address. Its firewall is closed except SSH ([security](/docs/security#network)), but binding to loopback means the server isn't listening there at all.
- **`--stateful`.** Without it, supergateway starts a fresh copy of the stdio server for every request. Its log showed `Child exited` after each call, and calls took 270–410 ms. With it, one server process serves the whole session, and calls took 131 ms, about one round trip from Miami.

We also set `--logLevel none`. At the default level, supergateway writes every request and response body to the journal, including file contents. Leave it on while you debug (`journalctl -u mcp-files`), then turn it off.

## 3. Give each server a private URL

```bash
sbx host <files-id> 8000 --localhost --title "MCP files"
sbx host <everything-id> 8000 --localhost --title "MCP everything"
```

```text
https://quick-clear-canyon-8000.box.sandboxer.com?_token=<token>
https://clean-chief-hare-8000.box.sandboxer.com?_token=<token>
```

`--localhost` makes the sandbox relay the hosted port to `127.0.0.1:8000` ([hosting](/docs/hosting#apps-that-listen-on-localhost)). Hosted URLs are private by default. A browser trades `_token` for a cookie, but an MCP client sends no cookies of its own. So take the token out of the URL and send it on every request as `Cookie: __sbx_auth=<token>`. Sandboxer's proxy strips it before the request reaches the server.

Keep each token in its own environment variable on your laptop. We used `FILES_MCP_AUTH` and `EVERYTHING_MCP_AUTH`. The proxy checks each URL against its own token:

```bash
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
H=(-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream')
A=https://quick-clear-canyon-8000.box.sandboxer.com/mcp
curl -s -o /dev/null -w "no token: %{http_code}\n" -X POST $A "${H[@]}" -d "$INIT"
curl -s -o /dev/null -w "B's cookie: %{http_code}\n" -X POST $A "${H[@]}" -H "Cookie: __sbx_auth=$EVERYTHING_MCP_AUTH" -d "$INIT"
curl -s -w "\nown cookie: %{http_code}\n" -X POST $A "${H[@]}" -H "Cookie: __sbx_auth=$FILES_MCP_AUTH" -d "$INIT"
```

```text
no token: 401
B's cookie: 401
event: message
data: {"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"secure-filesystem-server","version":"2.0.1"}},"jsonrpc":"2.0","id":1}

own cookie: 200
```

## 4. Connect one client to both

With the TypeScript SDK, pass the header in `requestInit`:

```javascript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

// One entry per sandbox. Each hosted URL has its own token, read from the environment.
const servers = {
  files: { url: "https://quick-clear-canyon-8000.box.sandboxer.com/mcp", token: process.env.FILES_MCP_AUTH },
  everything: { url: "https://clean-chief-hare-8000.box.sandboxer.com/mcp", token: process.env.EVERYTHING_MCP_AUTH },
};

async function connect(name, { url, token }) {
  const transport = new StreamableHTTPClientTransport(new URL(url), {
    requestInit: { headers: { Cookie: `__sbx_auth=${token}` } },
  });
  const client = new Client({ name: "laptop", version: "1.0.0" });
  await client.connect(transport);
  return client;
}

const files = await connect("files", servers.files);
const everything = await connect("everything", servers.everything);
const text = (r) => r.content.map((c) => c.text).join("\n");

console.log((await files.listTools()).tools.length, (await everything.listTools()).tools.length);
console.log(text(await files.callTool({ name: "read_text_file", arguments: { path: "/home/user/project/notes.txt" } })));
console.log(text(await everything.callTool({ name: "echo", arguments: { message: "hi" } })));

// Which secrets can the everything server see? Print names only, never values.
const env = JSON.parse(text(await everything.callTool({ name: "get-env", arguments: {} })));
console.log(Object.keys(env).filter((k) => /TOKEN|SECRET|KEY/.test(k)).join(" "));
```

Our run, with timings added:

```text
files: connected in 557 ms secure-filesystem-server 2.0.1
everything: connected in 615 ms mcp-servers/everything 2.0.1
files tools (399 ms): 14
everything tools (323 ms): 16
files read_text_file (275 ms): only the files server can read this
everything echo (305 ms): Echo: hi
everything get-env, names only (305 ms): SANDBOXER_TOKEN EVERYTHING_SERVER_TOKEN
```

That was before `--stateful`. With it, five calls in a row took `208 131 131 130 131` ms on A and `298 131 131 132 131` ms on B. The ping from the laptop to the node was 123 ms, so nearly all of that is distance.

The last line is the point of the whole setup. The everything server can dump its environment, and its environment holds its own fake token and a sandbox token that can't act. `FILES_SERVER_TOKEN` isn't there, because it's in a different VM.

For Claude Code, write `${VAR}` into the header and let Claude Code fill it in from your environment. The single quotes keep the shell from expanding it, so the token never lands in `~/.claude.json`:

```bash
claude mcp add-json --scope user files '{"type":"http","url":"https://quick-clear-canyon-8000.box.sandboxer.com/mcp","headers":{"Cookie":"__sbx_auth=${FILES_MCP_AUTH}"}}'
claude mcp add-json --scope user everything '{"type":"http","url":"https://clean-chief-hare-8000.box.sandboxer.com/mcp","headers":{"Cookie":"__sbx_auth=${EVERYTHING_MCP_AUTH}"}}'
claude mcp list
```

```text
files: https://quick-clear-canyon-8000.box.sandboxer.com/mcp (HTTP) - ✔ Connected
everything: https://clean-chief-hare-8000.box.sandboxer.com/mcp (HTTP) - ✔ Connected
```

The config file held `"Cookie": "__sbx_auth=${FILES_MCP_AUTH}"`, not the token. With a wrong value in `FILES_MCP_AUTH`, the check said `✘ Failed to connect — Dynamic Client Registration rejected (HTTP 401)`. Claude Code reads a `401` as a cue to try OAuth, which a hosted port doesn't offer. If you see that message, check the variable. Other clients that let you set headers on an HTTP server work the same way; see [local or hosted MCP](/guides/local-or-hosted-mcp-for-cursor-claude-code-codex) for where Codex and Cursor keep theirs.

## What it costs

Each `small` sandbox is $0.018/hour while it runs and bills no running time while stopped or paused ([pricing](/pricing)). Two servers:

| How you run them | Running time billed | Cost |
| - | - | - |
| An 8-hour workday | 2 × 8 h | $0.29 a day |
| Around the clock, 30 days | 2 × 720 h | $25.92 a month |
| With idle auto-pause | Only while they're in use | Less, by how long they sit idle |

Idle auto-pause is on by default. A sandbox with no activity pauses with its memory kept. The next request with the URL's token wakes it, and the proxy holds that request while it resumes, up to 45 seconds ([wake on use](/docs/long-running#idle-auto-pause-and-wake-on-use)). For an MCP server that's a slow first call, not an error. We set sandbox B to pause after 1 minute (`sbx idle <id> 1`). It paused, and a new client connected through the paused URL in 1,188 ms. The next call took 293 ms. Afterwards the sandbox reported `memoryRestored: true`, and the server still had the PID it started with. `sbx idle <id> 60` sets the delay in minutes. Paused and stopped sandboxes still count toward your plan's included storage.

## FAQ

### Can I run several MCP servers in one sandbox instead?

Yes. Group servers that you trust equally and that need the same secrets, like two servers for one internal API. You save a sandbox, but each of them can read the other's files and environment.

### Why not just give the server the API key through the client config?

For a stdio server on your laptop, the client passes that key in the server's environment. The server process still runs as you and can read every other server's config. A sandbox limits what it can reach, not just what it's handed.

### Do I need supergateway if my server already speaks Streamable HTTP?

No. Run the server's own HTTP mode and host its port. If it can't bind to `127.0.0.1`, host it without `--localhost`. Only hosted ports are reachable through the proxy, and the sandbox's IPv6 firewall stays closed except SSH.

### Can the agent reach a server's sandbox some other way?

Only through the hosted URL you gave it. Sandboxes can't reach each other, and each URL needs its own token. `sbx host hide <id> 8000` takes a URL down. Hosting the port again gives the old token back, and `--private` mints a new one.

### What happens to an open MCP session when the sandbox pauses?

It carries on. We opened a session to sandbox B, left it until the sandbox paused, then called `echo` on the same session. The call took 916 ms instead of 131 ms and returned `Echo: same session after pause`. Memory was restored, so the server process and its session were still there.

## Related

- [Test an MCP server with the MCP Inspector](/guides/test-an-mcp-server-with-the-mcp-inspector)
- [Pass secrets to a sandbox safely](/guides/pass-secrets-to-a-sandbox-safely)
- [Local or hosted MCP for Cursor, Claude Code and Codex](/guides/local-or-hosted-mcp-for-cursor-claude-code-codex)
- [Hosting docs](/docs/hosting)
