How to host MCP servers for your agents in sandboxes
Start MCP servers inside a sandbox, give your agent each server's Streamable HTTP URL and bearer token, and keep API keys out of it.
On Runtime one call starts a catalog MCP server in a microVM, and its API key never enters the machine. The catalog holds 14 servers, each MIT or Apache-2.0 with a pinned version: GitHub, Postgres, a Playwright browser, filesystem, fetch, git, time, memory, Notion, Context7, Brave Search, Exa, Firecrawl and Supabase (JavaScript SDK, 25 September 2026). A key stored as a Runtime secret reaches the server as a placeholder, and the host's proxy adds the value on the way out.
Why run MCP servers in a sandbox
The Model Context Protocol defines two standard transports: stdio, where the client launches the server as a subprocess, and Streamable HTTP, where the server is an independent process that many clients reach at one endpoint (MCP specification). A stdio server launched by your agent runs with your agent's rights, on its machine, holding whatever keys you configured. Moving the server into a sandbox changes three things:
- The server's code, and anything a tool call makes it do, runs in a Firecracker microVM with its own kernel, not beside your agent.
- Its network is the sandbox's: you choose which hosts it may reach.
- Its credentials stay with Runtime's proxy, so a prompt injection that makes the server print its environment shows a worthless placeholder.
The short answer
Store the key once as a secret for the hosts it belongs to, start the servers, and hand the URLs and headers to your agent's MCP client:
TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();await runtime.secrets.set("GITHUB_TOKEN", { value: process.env.GITHUB_TOKEN ?? "", hosts: ["api.github.com"],});const sbx = await runtime.sandboxes.create({ name: "mcp-gateway", idlePauseSeconds: 900, labels: { role: "mcp" },});await sbx.mcp.start([ { id: "github", secrets: { GITHUB_PERSONAL_ACCESS_TOKEN: "GITHUB_TOKEN" } }, { id: "fetch" }, { name: "tickets", command: ["python3", "/workspace/tickets_server.py"] }, // your own stdio server]);const gateway = await sbx.mcp.ready(); // waits while servers installfor (const server of gateway.servers) console.log(server.name, server.status, server.url);console.log(gateway.headers); // { Authorization: "Bearer ..." }, on every requestPythonimport osfrom withruntime import Runtimeruntime = Runtime()runtime.secrets.set("GITHUB_TOKEN", value=os.environ.get("GITHUB_TOKEN", ""), hosts=["api.github.com"])sbx = runtime.sandboxes.create(name="mcp-gateway", idle_pause_seconds=900, labels={"role": "mcp"})sbx.mcp.start([ {"id": "github", "secrets": {"GITHUB_PERSONAL_ACCESS_TOKEN": "GITHUB_TOKEN"}}, {"id": "fetch"}, {"name": "tickets", "command": ["python3", "/workspace/tickets_server.py"]},])gateway = sbx.mcp.ready()for server in gateway["servers"]: print(server["name"], server["status"], server["url"])print(gateway["headers"])Your own stdio server runs the same way as a catalog one: upload its files first, name it, and give the command. Each server gets its own Streamable HTTP URL through a private preview link, and every request also needs the gateway's bearer token.
Connect a client
Any MCP client that speaks Streamable HTTP takes the URL and the header. A
first request, written out by hand, is an initialize POST:
TypeScriptimport { Sandbox } from "withruntime";const sbx = await Sandbox.getOrCreate("mcp-gateway");const gateway = await sbx.mcp.ready();const github = gateway.servers.find((server) => server.name === "github");const reply = await fetch(github!.url!, { method: "POST", headers: { ...gateway.headers, "content-type": "application/json", accept: "application/json, text/event-stream", }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "my-agent", version: "1.0.0" }, }, }),});console.log(reply.status, reply.headers.get("content-type"));From a terminal, runtime sandbox mcp <id> start github fetch prints each
server's URL, the Authorization header and the claude mcp add line to
paste into Claude Code:
Terminalruntime sandbox mcp catalogruntime sandbox mcp "${id}" start github fetch --secret github.GITHUB_PERSONAL_ACCESS_TOKEN=GITHUB_TOKENruntime sandbox mcp "${id}"Limit what each server can reach
runtime.mcp.catalog() lists, for each server, its settings and the hosts it
calls. Once the servers are installed, narrow the sandbox to those hosts, and
a server that is tricked into fetching somewhere else is refused on the host:
TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const catalog = await runtime.mcp.catalog();const github = catalog.find((entry) => entry.id === "github")!;console.log(github.license, github.version, github.egress);await using sbx = await runtime.sandboxes.create();await sbx.mcp.start([{ id: "github", secrets: { GITHUB_PERSONAL_ACCESS_TOKEN: "GITHUB_TOKEN" } }]);await sbx.mcp.ready(); // installed while the web was openawait sbx.network.set({ internet: true, allow: github.egress });The rule applies at once, to open connections too.
The servers run as the sandbox's user behind its network rules. A host the
rules refuse shows up as a warning when the servers start; the rules do not
change to let it through. The Postgres server's DATABASE_URI is the one
setting passed as given, because a database password cannot be swapped by an
HTTPS proxy.
One gateway per user or per agent
For a product where each customer connects their own accounts, give each
customer a named sandbox with their own servers. idlePauseSeconds pauses a
gateway nobody is calling, keeping its memory and running servers, and a
request to one of its URLs wakes it again, usually in about half a second.
| Need | How Runtime covers it |
|---|---|
| Well-known servers, no install | sbx.mcp.start([{ id }]) from a catalog of 14, versions pinned |
| Your own server | { name, command } runs any stdio server in the sandbox |
| Keys a server must not hold | Runtime secrets: a placeholder inside, the value added by the host's proxy |
| Only the hosts a server needs | allow lists from the catalog's hosts, binding root in the guest |
| Authenticated URLs | A private preview link plus the gateway's bearer token |
| Idle gateways | idlePauseSeconds; a request to a URL wakes it |
| A gateway per customer | Sandbox.getOrCreate(name) answers the same sandbox every time |
| Isolation between customers | A Firecracker microVM with its own kernel each |
What it costs
Take 100 customers, each with a 1 vCPU, 2 GiB gateway that is busy 2 hours a day for 30 days, at 0.1 of a vCPU on average, and paused the other 660 hours with 0.5 GB of its own stored:
TextCPU: 100 × 60 h × 0.1 vCPU × $0.025 = $15.00Memory: 100 × 60 h × 2 GiB × $0.0075 = $90.00Paused: 100 × 0.5 GB × $0.08 × 660 h / 720 h = $3.67Total: $108.67That is about $1.09 a customer a month. A gateway that waits costs $0.01625 an hour, its memory plus a CPU floor of a twentieth of a vCPU, and a paused one costs storage alone (pricing). Secrets are included; an account holds up to 50. New accounts get 50 free sandbox hours, no card.
Start
Terminalnpx withruntime sandbox run --trial --keep -- echo readyThe first run prints a link to approve in your browser; then
runtime sandbox mcp <id> start fetch gives you a server to connect to.
Related: what the Model Context Protocol is, egress control, a browser automation agent, Claude Code in a sandbox, pause and resume a sandbox.
Sources
Checked 25 September 2026.
Facts on this page were checked on 25 September 2026.