Build an MCP code execution server on Firecracker
Build your own MCP code execution server in Python whose run_python tool runs every snippet in a Firecracker-isolated PandaStack sandbox.
This recipe builds your own MCP server — a tiny FastMCP server, written with the official mcp package, that exposes a single run_python tool. The tool body runs whatever code the model sends inside a PandaStack code interpreter sandbox, so any MCP client (Claude Desktop, Cursor, your own agent) gets Firecracker-isolated Python execution with rich-output capture for free.
Not the built-in endpoint. PandaStack itself already speaks MCP — the per-sandbox endpoint turns one sandbox into a tool, and the hosted MCP server exposes your whole workspace. Those expose PandaStack as MCP tools. This recipe is the inverse: you author the MCP server, and a sandbox is just the execution backend behind your tool.
Why a sandbox behind the tool
The naive run_python everyone writes first is exec(code, {}) in-process. That runs the model's code in your server's interpreter, with no isolation — a hallucinated shutil.rmtree("/") is now your problem, and os.environ leaks your secrets straight to the model. Don't ship that.
The fix is to run the code somewhere disposable. PandaStack runs it in a Firecracker microVM with netns-isolated egress, and — because the code-interpreter template bakes IPython, matplotlib, plotly, kaleido, and pandas — a plt.show() or a bare DataFrame comes back as a chart object (ex.png, ex.results[].html), not stdout the client has to parse.
Setup
export PANDASTACK_API_KEY=pds_...
pip install pandastack "mcp[cli]"The SDK reads PANDASTACK_API_KEY from the environment — never hardcode it. The MCP package is mcp; the [cli] extra adds the mcp dev tooling (mcp dev, mcp install) and the import path is mcp.server.fastmcp. FastMCP ships inside the official SDK — do not pip install fastmcp, which is a different, standalone project.
The server
One FastMCP instance, one tool. Create a persistent CodeContext up front — a long-lived Jupyter-style kernel — and reuse it across every run_python call so the client can define a variable in one call and use it in the next, exactly like a notebook.
# server.py
import sys
from mcp.server.fastmcp import FastMCP
from pandastack import Sandbox
mcp = FastMCP("python-runner")
# One sandbox + one persistent kernel for the whole server process.
# Created lazily so importing the module doesn't spin up a live VM.
_ctx = None
def _kernel():
global _ctx
if _ctx is None:
sandbox = Sandbox.create(
template="code-interpreter",
ttl_seconds=3600,
metadata={"server": "mcp-python-runner"},
)
_ctx = sandbox.create_code_context(language="python")
return _ctx
@mcp.tool()
def run_python(code: str) -> str:
"""Execute Python in an isolated sandbox kernel and return its output.
State (variables, imports, DataFrames) persists across calls. Use
matplotlib/plotly normally — charts are captured automatically; you
do not need to savefig or write files. If a cell raises, the traceback
is returned so you can fix it and retry.
"""
ex = _kernel().run_code(code)
if ex.error:
return f"ERROR:\n{ex.error}\n\nstdout:\n{ex.stdout}".strip()
parts = []
if ex.stdout:
parts.append(ex.stdout.rstrip())
if ex.text and ex.text not in (ex.stdout or ""):
parts.append(f"[result] {ex.text}")
if ex.png:
# A chart came back as a base64 PNG object — not stdout to parse.
parts.append("[chart] PNG produced (base64 on the Execution)")
return "\n\n".join(parts) or "(no output)"
if __name__ == "__main__":
mcp.run(transport="stdio")That is the whole server. The pieces that matter:
| Element | What it does |
|---|---|
FastMCP("python-runner") | the server; the positional arg is its name |
@mcp.tool() | registers run_python — note the parentheses |
| docstring | becomes the tool description the model reads |
code: str annotation | auto-derives the JSON input schema (Pydantic) — type hints are required |
mcp.run(transport="stdio") | speaks MCP over stdin/stdout; this is what Claude Desktop launches |
A few traps specific to a stdio server that runs code:
- Use
()on the decorator. Write@mcp.tool(), not bare@mcp.tool— the parenthesized form is the documented, version-safe one. - Never write to stdout. stdout is the MCP transport channel; a stray
print()corrupts the protocol framing. Crucially, thecode-interpreterkernel captures the guest's stdout intoex.stdoutand hands it back as a string — it never touches your server's stdout, which is exactly why running the code in the sandbox (instead ofexec-ing it locally and leaking subprocess output) keeps the transport clean. Send any diagnostics tosys.stderr. "stdio", not"sse". SSE is legacy; the HTTP transport is now"streamable-http". Claude Desktop launches the server over stdio.
PandaStack call mapping
The single PandaStack call this server depends on is symmetric across SDKs, if you build the equivalent in a TypeScript MCP server:
ex = ctx.run_code(code) # Execution: .stdout .stderr .error .text .png .resultsconst ex = await ctx.runCode(code); // Execution: { stdout, stderr, error?, text?, png?, results }Try it locally before wiring a client
The [cli] extra ships an inspector. Point it at the file and you get a UI to list tools and call run_python by hand:
mcp dev server.pySend print(2 + 2) and you should get 4 back; send df = __import__("pandas").DataFrame({"x":[1,2,3]}); df.describe() and the result comes back as the kernel's repr. Confirm the tool name and arg name here rather than assuming them.
Connect Claude Desktop
Claude Desktop spawns the server as a subprocess and speaks MCP over its stdin/stdout. Add an entry under mcpServers in the config file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%AppData%\Claude\claude_desktop_config.json
{
"mcpServers": {
"python-runner": {
"command": "/ABSOLUTE/PATH/TO/python",
"args": ["/ABSOLUTE/PATH/TO/server.py"],
"env": {
"PANDASTACK_API_KEY": "pds_..."
}
}
}
}Two things bite everyone here:
- Absolute paths are mandatory. Claude Desktop does not inherit your shell
PATH, so bothcommand(runwhich python) and the script path must be absolute. The same applies if you launch viauv("command": "uv", "args": ["--directory", "/ABS/PATH/python-runner", "run", "server.py"]). - Pass the key in
env. The subprocess doesn't see theexportfrom your interactive shell — setPANDASTACK_API_KEYin the config'senvblock (or have the server read it from a file).
Restart Claude Desktop after editing the config. python-runner appears in the 🔌 tools menu, and asking "compute the first 20 primes and plot the gaps between them" will trigger a run_python call that executes inside the microVM. As a shortcut, mcp install server.py writes this entry for you.
What the model gets back
Because the kernel is persistent, a follow-up like "now plot the same data on a log scale" reuses the variables from the previous call — no re-import, no re-upload. And the rich-output capture is the wedge: when the model's code draws a chart, ex.png is a ready-to-render base64 PNG and ex.results[0].html is a DataFrame rendered as an HTML table. The tool above reports [chart] PNG produced; to surface the image itself, return an MCP image content block (or persist the PNG and return a path) instead of the text marker. Every Execution also exposes .results, the full list of typed Result objects (.png, .svg, .html, .json, .markdown, .text).
Lifecycle and isolation notes
- One kernel per server process. The module-level
_ctxis shared across all tool calls, so state persists for the life of the server. For per-client isolation, run one server process per client. - Tear down on exit. Idle kernels are reaped on the
ttl_secondsbudget, but_ctx.close()andsandbox.kill()are cheaper if you trap shutdown. - Resumable sessions. To make a session survive a server restart, snapshot the sandbox instead of killing it and restore on next launch — kernel state comes back with it.
- Real isolation. Every snippet runs in its own Firecracker VM with netns-isolated egress. A model that writes
rm -rf /or probes for host files is contained to the guest.
Next steps
- Code interpreter — the pillar guide: code contexts, the rich-result model, one-shot exec, and file uploads.
- Run code from a LangGraph agent — the same execution wedge wired into a graph runtime instead of an MCP server.
- CrewAI code execution — a custom sandbox tool for CrewAI agents.
- Build a data-analyst agent — upload a CSV and stream charts back to the model.
Mastra agent code execution (TypeScript)
Give a Mastra AI agent a code execution sandbox tool in TypeScript — a createTool() backed by a PandaStack Firecracker microVM that hands the model charts as objects, not stdout.
Persistent sandbox sessions across agent turns
Run an LLM agent loop with a persistent sandbox session — keep a code-interpreter kernel warm across turns, resume its memory after a restart, and fork it for parallel rollouts.