Answers · MCP security
How do I sandbox a local stdio MCP server so it can only touch the files and network hosts it needs?
Last updated
TL;DR: Wrap the server's launch command, not the agent. A stdio MCP server is an ordinary child process of your MCP client: the official MCP Local Server Security guide says it inherits your environment variables, can read anything your account can read and can open outbound connections anywhere, that the stdio transport is not a sandbox, and that any boundary is yours to enforce. So change the command in mcp.json or claude_desktop_config.json from "npx some-server" to a sandbox that launches it: a container (docker run -i --rm with only the project mounted, :ro where it only reads, --network none when it needs no network), or an OS sandbox such as bubblewrap or firejail on Linux, or Anthropic's open-source sandbox-runtime (srt), which generates Seatbelt profiles on macOS and uses bubblewrap on Linux, denies writes and network by default and allows only the domains you list. Stdio keeps working because the client still talks to the wrapped process over stdin and stdout. Then pass only that server's own credential into the sandbox, and test the boundary from inside it.
What should I check, step by step?
1. Inventory what each local server really needs: which directory (read-only or writable), which hosts, which one credential. A server that only wraps a remote API needs no files; the MCP guide says to prefer its remote version and skip the local install.
- Change the launch command, not the server: in your client config, set command to the sandbox (docker, bwrap, firejail or srt) and move the original command into args. One wrapper per server, so each gets its own limits.
- Container baseline: docker run -i --rm (-i keeps stdin open for stdio), mount only the project directory (-v "/abs/path/project:/work:ro" if it only reads), add --read-only for the container's root filesystem, --cap-drop ALL and --security-opt no-new-privileges, and pin the image by digest (image@sha256:...).
- No network unless needed: --network none for file-only servers. For servers that call one API, put the container on a docker network created with --internal and route it through an egress proxy that allows only that API's host, or use srt's network.allowedDomains, which starts empty and denies everything else.
- Without containers on Linux: bwrap with --ro-bind for system paths, --bind only for the one writable directory, --unshare-net (or --unshare-all) and --die-with-parent; or firejail with --private=<dir> or --whitelist, --net=none, --caps.drop=all and --nonewprivs.
- On macOS: srt wraps the command with sandbox-exec and a generated Seatbelt profile (allowWrite empty by default, denyRead for ~/.ssh, ~/.aws and similar, allowedDomains for egress). Apple's sandbox-exec man page marks it deprecated, so treat it as a best-available layer and prefer a container or VM for servers you trust least.
- Separate the account, too: run especially untrusted servers under a dedicated OS user (the MCP guide's "going further" step) so even an escape lands in an account with no keys, browser profiles or home directory of yours.
- Hand in only its own secret: pass a per-server token with docker -e NAME (value read from the client's env for that server) rather than inheriting your whole shell environment, and never mount ~/.ssh, ~/.aws, the Docker socket or your home directory.
- Test from inside: ask the agent to use the server to read ~/.ssh/id_ed25519, write outside the mounted folder and fetch an unlisted host. All three should fail; if any succeeds, the wrapper is not in effect.
Why isn't the MCP stdio transport a sandbox for a local server?
The MCP Local Server Security guide states the trust model plainly: the client and a stdio server share one trust domain, neither the protocol nor the SDKs defend one against the other, and isolation is the responsibility of the host application and whoever operates the machine. The security best practices add that clients offering one-click local install SHOULD launch servers with restricted file, network and system access, using platform sandboxing such as containers or application sandboxes.
- The threats the guide lists are a malicious or typosquatted server, an overcollecting server, a compromised dependency, a poisoned tool catalog and a vulnerable bridge. Isolation is the one control that limits all of them at once: it turns "this server can read my SSH keys" into "this server can read an empty directory".
- Configuration-level allowlists, such as the directory list you pass a filesystem server, are cooperative. The guide warns that nothing stops a malicious or compromised server from reading whatever its process can reach; enforcement comes from a sandbox where the allowed directory is the only filesystem that exists.
How do I wrap a local MCP server's launch command in a container, bubblewrap, firejail or srt?
Containers are the guide's baseline and stdio works unchanged. GitHub's own MCP server documents the pattern: command docker, args run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server, with the token supplied through the client's env block. Add your mounts, --read-only, --cap-drop ALL and --network none or an internal network to that same args list.
- bubblewrap builds a fresh mount namespace from only the paths you bind (--ro-bind for read-only, --bind for writable) and --unshare-net removes network access. firejail's --net=none gives the process a new network namespace with only a loopback interface, and --private or --whitelist limit which home files it can see.
- Anthropic's sandbox-runtime (srt) was built for this use case; its README shows sandboxing the filesystem MCP server by changing command to srt and putting npx and the package in args, then setting filesystem.allowWrite, denyWrite, denyRead and network.allowedDomains in ~/.srt-settings.json. Writes and network are denied by default; reads are allowed by default until you add denyRead entries, so add them.
What does an egress allowlist for a local MCP server stop, and what doesn't it stop?
Exfiltration needs an exit. The MCP guide's default is no network for servers that don't need it, and an outbound firewall or egress proxy with a domain allowlist for the rest, logging attempts that fall outside it.
- A domain allowlist decides on the hostname requested. Allowing a broad domain (all of github.com, a public paste or storage host) leaves a place to send data. Keep entries to the exact API hosts the server calls.
- A sandbox does not fix a poisoned tool description or a prompt-injected agent calling an honest tool. Pair it with pinned versions, a reviewed tool list and confirmation on write tools.
How do I ask Rightshold about this (free, no key)?
Add the MCP server https://www.rightshold.com/mcp to Claude, Cursor, ChatGPT, Grok or VS Code, then ask: “Record the boundary I set for my local Postgres MCP server (the one folder it may read and the one host it may reach) as a declared intent, so anyone reviewing later can compare what it was allowed against what it did.”
{
"tool": "declare_intent",
"arguments": {
"agent_id": "<one of your agents>",
"purpose": "other",
"description": "Local stdio MCP server 'postgres-readonly' runs in a container: reads ~/work/reports (read-only mount), reaches db.internal.example.com:5432 only; no other files or hosts",
"operations": [
"record_ai_run"
],
"ttl_hours": 720,
"targets": {
"domains": [
"db.internal.example.com"
],
"resources": [
"mcp-server:postgres-readonly",
"folder:~/work/reports:ro"
]
}
}
}- Needs your account API key. Rightshold returns a canonical intent statement for the agent's key to sign (sign_intent); a child intent declared beneath it must keep its domains and resources within the parent's.
- This is a record, not a sandbox. Rightshold doesn't launch, contain or watch your local MCP servers; the file and network limits are enforced by the container or OS sandbox you configure above.
- record_ai_run can seal a hash of the server's exact launch command and sandbox settings for a session, so a later change to the wrapper is visible when you compare runs.
- precall_check (free) takes an https URL, so it applies when a server also has a remote version you are considering instead of the local install.
Rightshold records declared intents and runs you send it, and checks remote MCP servers by URL. It does not run, configure or monitor local MCP servers, containers, sandboxes or firewalls on your machine. Not legal advice.
What are the sources?
- MCP docs: Local Server Security (stdio trust model, isolation, credentials, filesystem, egress)
- MCP specification: Security Best Practices (Local MCP Server Compromise)
- Anthropic sandbox-runtime (srt): sandboxing MCP servers
- bubblewrap
- Firejail
- Docker docs: docker container run (-i, --read-only, --cap-drop, --security-opt, --network)
- Docker docs: docker network create (--internal)
- GitHub MCP Server: running with Docker over stdio
- sandbox-exec(1) man page (Xcode man pages mirror)
Related
- How to sandbox an AI agent to one folder with a network allowlist
- How to protect a local MCP server from DNS rebinding
- How to pin MCP server versions and review changes before upgrading
- How to vet an MCP server before installing it
Not legal advice. JSON · Markdown · All answers
Get a free API key in one call
No card and no email needed. 100 free credits; the license and policy checks stay free without a key.
curl -s -X POST https://www.rightshold.com/v1/accounts/signup -H "Content-Type: application/json" -d '{"accept_terms":true}'
export RIGHTSHOLD_API_KEY=rs_... # the api_key from the reply (shown once)
curl -s https://www.rightshold.com/v1/licenses/check -H "Authorization: Bearer $RIGHTSHOLD_API_KEY" \
-H "Content-Type: application/json" -d '{"license":"MIT","intended_use":{"commercial_use":true}}'
