Notes ·
Thirteen tools through a sandbox, and the race that hid them
A scanner can warn you about an untrusted MCP server. It cannot stop the code once it runs. What I learned from moving the server into a throwaway VM, and from the one bug that only a real cloud runtime could show me.
Code you run with your agent’s privileges
The usual way to add an MCP server is one line: npx -y some-mcp-server. The package comes from a registry, it starts on your machine, and it runs with everything you have. It can read ~/.ssh, your browser profile, every secret exported in your shell, and it can reach your local network. Nobody reads the code first.
I had already written a tool that finds this kind of risk in agent configuration: unpinned packages, wildcard permissions, secrets inline. It is useful, and it has a limit. Static analysis tells you that a line is dangerous. It does nothing once the process starts. A warning is a report about the danger. It does not remove it.
So I wanted the second half: let the server run, but put it somewhere where the worst it can do does not matter.
Move the server, not the client
The idea in mcp-sandbox is small. A stdio bridge starts a disposable E2B micro-VM, runs the MCP server inside it and passes JSON-RPC both ways. The client talks to the bridge as if it were the server. It does not know the difference, and it does not need to, because the protocol goes through unchanged.
That one decision gave me most of what I wanted. There is no code change on either side. The bridge goes in front of the command you already had: npx mcp-sandbox run -- <server>. The server gets no access to your filesystem, your environment or your local network. A TTL reaps the VM no matter what happens inside it.
The part I like most is the environment. The normal default is that a child process inherits everything. The bridge inverts it. Nothing crosses unless you name it with --env KEY or --env KEY=value. A server that needs one API key gets one API key, not the whole shell.
After that, a hostile server can only harm its own throwaway VM. That is a different kind of claim from “the scanner did not find anything”.
Tests that run offline, and a check that does not
The E2B integration sits behind one injected interface, SandboxBackend. That let me write the 11-test suite against an in-memory fake, so it runs offline and fast. The bridge is line-buffered, so the client never sees half a JSON-RPC frame.
But a fake only knows what I told it. Mine had no download and no restart. So I did not call the tool done until it ran against a live sandbox. I put @modelcontextprotocol/server-everything behind the bridge. It completed the initialize handshake and returned its 13 tools, and --trace wrote every message to a JSONL file I could read afterwards.
Getting to those 13 tools is where the real lesson was.
The race a fake cannot have
The first live runs failed at the start. The client sent initialize at once, and the request was lost.
The cause was npx itself. On the first run it downloads the package and then re-executes mid-startup. For a short window the process I had started was not the process that would answer. The client’s initialize arrived in that window and was lost with the old process.
I found it by probing the sandbox directly instead of guessing from the client side. I confirmed which runtime was in the VM, then narrowed down the window where the PID was not stable. Once I understood why the process identity changed, the fix was plain: install the package in a setup step, then run the binary that the install produced. There is no download and no re-exec, so no window.
None of this could show up in the offline suite. The fake had no cold start, because I had not thought to give it one. That is the honest limit of a fake: it tests your model of the world, and the bug was in the part of the world I had not modelled.
What I keep from it
Contain where you can, and flag where you cannot. A report about a dangerous server is worth having, but a server that runs somewhere it cannot hurt you is worth more.
Invert the default. “Inherit everything” is the setting that makes a compromised tool expensive. An allowlist costs one flag per secret and makes the blast radius something you chose.
Keep the fake, and do not trust it alone. The injected backend gave me fast tests for the logic I understood. Only the live runtime showed me the logic I did not. When a bug makes no sense, I now go to the process itself and ask it what it is, before I touch the code that talks to it.