PandaStack

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:

ElementWhat it does
FastMCP("python-runner")the server; the positional arg is its name
@mcp.tool()registers run_python — note the parentheses
docstringbecomes the tool description the model reads
code: str annotationauto-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, the code-interpreter kernel captures the guest's stdout into ex.stdout and hands it back as a string — it never touches your server's stdout, which is exactly why running the code in the sandbox (instead of exec-ing it locally and leaking subprocess output) keeps the transport clean. Send any diagnostics to sys.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 .results
const 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.py

Send 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 both command (run which python) and the script path must be absolute. The same applies if you launch via uv ("command": "uv", "args": ["--directory", "/ABS/PATH/python-runner", "run", "server.py"]).
  • Pass the key in env. The subprocess doesn't see the export from your interactive shell — set PANDASTACK_API_KEY in the config's env block (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 _ctx is 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_seconds budget, but _ctx.close() and sandbox.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

On this page