LLM agents are remarkably good at writing code and remarkably unlucky at running it. The moment you let a model execute arbitrary Python on the same machine that holds your SSH keys, your .env files, and your production kubeconfig, you have built a very polite remote-code-execution service. Yet code execution is one of the highest-leverage tools you can give an agent: data wrangling, quick numeric checks, parsing odd file formats, validating its own hypotheses — all of it works dramatically better when the model can actually run what it writes.

The answer is not “don’t execute code.” The answer is: execute it somewhere that doesn’t matter.

This article walks through building a Model Context Protocol (MCP) server that runs Python in a sandbox, using FastMCP to keep the examples compact. We’ll look at two isolation strategies and spend most of our time on the stricter one:

  1. Deno + Pyodide (WebAssembly) — Python compiled to WASM, running inside a runtime that is deny-by-default: no network, no filesystem, no environment, unless you explicitly grant each one. This is the headline approach.
  2. Docker container — OS-level isolation for when you need real CPython, native wheels, or heavy compute.

They compose, too — but first, why the WASM route deserves the spotlight.

Why MCP for this?

MCP gives you a standard contract between the agent and the tool. Instead of every team inventing its own “run this snippet” HTTP endpoint with its own auth and its own JSON shape, you expose one well-described tool that any MCP-capable client (Claude Code, IDE agents, custom orchestrators) can discover and call. The client sees a tool named run_python, a description telling the model when and how to use it, a typed input schema, and structured output it can reason about.

The sandboxing itself is invisible to the model — and that’s the point. The model just “runs Python.” The isolation boundary is your problem, solved once, server-side.

The headline approach: Deno + Pyodide

Most sandboxing stories are subtractive: start with a full OS process that can do everything, then take capabilities away — drop this, block that, mount nothing, hope you remembered every hole. The Deno + Pyodide combination inverts the model. It is additive: the code starts with nothing and you grant capabilities one flag at a time.

Two layers make that true:

Pyodide is CPython compiled to WebAssembly. The Python interpreter itself runs inside a WASM linear-memory sandbox: it has no concept of your host filesystem, your network interfaces, or your process table. open("/etc/passwd") doesn’t fail because of a permission check you configured — it fails because there is no such file in the WASM world. The “filesystem” Python sees is an in-memory emulation (Emscripten MEMFS) that exists only inside the interpreter instance and vanishes with it.

Deno is the JavaScript/TypeScript runtime hosting that WASM module — and unlike Node, Deno is sandboxed by default. No file reads, no file writes, no network, no environment variables, no subprocess spawning, unless the corresponding --allow-* flag is passed at startup. Even if some future Pyodide bug let Python reach the JavaScript host layer, the escaped code lands in a runtime that also can’t touch anything.

So the layering looks like this:

+----------------------------------------------------------+
|  Host                                                     |
|                                                            |
|   FastMCP server (Python) --- stdio/HTTP --- MCP client    |
|        |                                                   |
|        | subprocess (timeout, output caps)                 |
|        v                                                   |
|   +---------------------------------------------------+    |
|   | Deno  (deny-by-default: no net, no fs, no env)    |    |
|   |   +-------------------------------------------+   |    |
|   |   | Pyodide (CPython in WASM)                 |   |    |
|   |   |   - in-memory virtual filesystem only     |   |    |
|   |   |   - no sockets, no host syscalls          |   |    |
|   |   |   - runs the agent's script               |   |    |
|   |   +-------------------------------------------+   |    |
|   +---------------------------------------------------+    |
+----------------------------------------------------------+

Notice what’s absent from this diagram: there is no network rule to get wrong, no volume mount to forget, no root user to demote. “No network and no files” isn’t a configuration you apply — it’s the starting state. That property is why this approach deserves to be the default, especially for the most common agent workload: pure computation over data the agent already has in-context.

The Pyodide runner

One small TypeScript file is the entire execution engine. It reads a script from stdin, runs it inside Pyodide, and emits a JSON result on stdout:

// runner.ts
import { loadPyodide } from "npm:pyodide";

const decoder = new TextDecoder();
const code = decoder.decode(await new Response(Deno.stdin.readable).arrayBuffer());

const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];

const pyodide = await loadPyodide({
  stdout: (line: string) => stdoutChunks.push(line),
  stderr: (line: string) => stderrChunks.push(line),
});

// Optional: let scripts use pure-Python wheels bundled with Pyodide
// (numpy, pandas, etc.) — loaded from the local package cache, not the network.
await pyodide.loadPackagesFromImports(code);

let exitCode = 0;
try {
  await pyodide.runPythonAsync(code);
} catch (err) {
  stderrChunks.push(String(err));
  exitCode = 1;
}

console.log(JSON.stringify({
  stdout: stdoutChunks.join("\n"),
  stderr: stderrChunks.join("\n"),
  exit_code: exitCode,
}));

And the launch command is where the security posture becomes legible:

deno run \
  --allow-read=./node_modules \
  --node-modules-dir=auto \
  runner.ts

That’s the complete permission grant: read access to the local Pyodide package cache, nothing else. No --allow-net. No --allow-write. No --allow-env. No --allow-run. The entire security review of this sandbox is one shell line — compare that to auditing a container spec, and you see the appeal. (Run it once with --allow-net to populate the package cache at build/install time; from then on, the runtime invocation stays offline.)

The FastMCP server on top

The MCP layer is a thin wrapper: hand the script to the runner, enforce a wall-clock timeout, cap the output, return structure.

# server.py
import asyncio
import json
from dataclasses import dataclass

from fastmcp import FastMCP

mcp = FastMCP("python-sandbox")

EXECUTION_TIMEOUT_SECONDS = 30
MAX_OUTPUT_CHARS = 50_000

DENO_CMD = [
    "deno", "run",
    "--allow-read=./node_modules",
    "--node-modules-dir=auto",
    "runner.ts",
]


@dataclass
class ExecutionResult:
    stdout: str
    stderr: str
    exit_code: int
    timed_out: bool


def _truncate(text: str) -> str:
    if len(text) <= MAX_OUTPUT_CHARS:
        return text
    return text[:MAX_OUTPUT_CHARS] + f"\n... [truncated, {len(text)} chars total]"


@mcp.tool
async def run_python(code: str) -> ExecutionResult:
    """Execute a Python script in an isolated WebAssembly sandbox.

    The script runs in a fresh interpreter with NO network access and
    NO access to any real filesystem: it cannot read host files, make
    HTTP requests, or open sockets. Common scientific packages
    (numpy, pandas) are available via normal imports. Each call is
    independent — variables do not persist between calls. Execution
    is killed after 30 seconds.
    """
    proc = await asyncio.create_subprocess_exec(
        *DENO_CMD,
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    try:
        raw_out, raw_err = await asyncio.wait_for(
            proc.communicate(code.encode()), timeout=EXECUTION_TIMEOUT_SECONDS
        )
    except asyncio.TimeoutError:
        proc.kill()
        await proc.wait()
        return ExecutionResult(stdout="", stderr="", exit_code=-1, timed_out=True)

    if proc.returncode != 0:
        # Runner itself failed (not the user script) — surface it plainly.
        return ExecutionResult(
            stdout="",
            stderr=_truncate(raw_err.decode(errors="replace")),
            exit_code=proc.returncode,
            timed_out=False,
        )

    result = json.loads(raw_out)
    return ExecutionResult(
        stdout=_truncate(result["stdout"]),
        stderr=_truncate(result["stderr"]),
        exit_code=result["exit_code"],
        timed_out=False,
    )


if __name__ == "__main__":
    mcp.run()

A few deliberate choices worth calling out:

  • The docstring is a prompt. The model decides how to use your tool based on that text. Saying “NO network, NO filesystem” up front stops the agent from wasting turns on requests.get(...) attempts that were never going to work.
  • Structured result, not a blob. Separate stdout / stderr / exit_code / timed_out fields let the model distinguish “printed nothing” from “crashed” from “hung” without parsing prose.
  • Fresh interpreter per call. No shared globals, no “it worked because a previous call imported pandas.” Stateless calls are far easier for a model to reason about.
  • Truncation with a note. Silently cutting output confuses the model; telling it “truncated, 2,400,000 chars total” lets it adapt.
  • Timeout kills the whole Deno process. WASM has no preemption story for runaway loops; the leash lives one level up, in the subprocess boundary — which is exactly where it’s cheapest.

What the agent’s script can and cannot do

Attempt What happens
open("/etc/passwd") FileNotFoundError — the path doesn’t exist in the in-memory FS
requests.get(...) / raw sockets Fails — no network stack reaches the host; Deno has no --allow-net
os.environ Empty/emulated — Deno never granted --allow-env
subprocess.run("bash") No such capability in WASM; Deno has no --allow-run
Write 10 GB of “files” Fills the in-memory FS inside one interpreter, dies with it
while True: pass Killed by the server-side 30 s timeout
Hypothetical Pyodide escape to JS Lands inside Deno, which still can’t touch net/fs/env

The honest limitations, so you can decide when this approach fits:

  • Packages: pure-Python wheels and the scientific set Pyodide ships (numpy, pandas, scipy, matplotlib, scikit-learn, and hundreds more) work; arbitrary native extensions (psycopg2, torch) do not.
  • Speed: expect roughly 1–3× slower than native CPython, plus interpreter startup per call. Irrelevant for “check this calculation,” relevant for heavy number crunching.
  • Threads/processes: multiprocessing and friends aren’t available.

For the dominant agent use case — run this self-contained snippet on data I gave you and show me the output — none of these bite, and you get the strongest isolation available for the least configuration.

The second approach: a Docker container

When you genuinely need native CPython — GPU work, database drivers, multiprocessing, packages with C extensions — you fall back from “the syscalls don’t exist” to “the syscalls are confined.” The MCP server itself changes very little: the same FastMCP wrapper pattern applies, just executing python script.py as the subprocess instead of the Deno runner. What changes is that the security now lives in the container spec, and every line of it is load-bearing:

FROM python:3.12-slim

RUN pip install --no-cache-dir fastmcp numpy pandas matplotlib

# Non-root: a sandbox running as root is a sandbox in name only.
RUN useradd --create-home --shell /usr/sbin/nologin sandbox \
    && mkdir /workspace && chown sandbox:sandbox /workspace

COPY server.py /app/server.py
USER sandbox
WORKDIR /workspace
ENTRYPOINT ["python", "/app/server.py"]
# docker-compose.yml
services:
  python-sandbox:
    build: .
    read_only: true                 # root fs immutable
    tmpfs:
      - /workspace:size=256m        # writable scratch, RAM-backed, size-capped
      - /tmp:size=64m
    network_mode: "none"            # no exfiltration, no surprises
    mem_limit: 512m
    pids_limit: 128                 # fork bombs die here
    cpus: "1.0"
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    stdin_open: true

This is a perfectly respectable sandbox — but notice how the burden shifted. With Pyodide, isolation was the starting state and you granted exceptions. Here, the process starts life able to do everything, and each YAML line subtracts one attack class: forget network_mode: none and scripts can exfiltrate; forget pids_limit and a fork bomb wedges the host; mount a volume for convenience and you’ve drilled a hole in the wall. Subtractive security fails open; additive security fails closed. That asymmetry is the whole argument for reaching for WASM first.

Belt and suspenders

The two approaches aren’t rivals. For a hardened deployment, run the Deno + Pyodide server inside the locked-down container: the WASM boundary handles the untrusted script, the container handles the hypothetical runtime escape, and neither layer’s failure alone is enough. Since the Pyodide layer needs no network and no meaningful filesystem at runtime, the container config loses nothing by staying maximally strict.

Choosing between them

Deno + Pyodide (WASM) Docker container
Security model Additive — capabilities granted one flag at a time Subtractive — capabilities removed line by line
No-network guarantee Inherent (never granted) Configured (network_mode: none)
No-files guarantee Inherent (in-memory FS only) Configured (no mounts + read-only + tmpfs)
Misconfiguration risk One shell line to audit Every flag is load-bearing
Python compatibility Pure-Python + Pyodide’s scientific set Full CPython, any wheel
Performance ~1–3× slower, per-call startup Native
Infra prerequisites A Deno binary Docker daemon
Best for Default choice: calculations, data munging, self-contained snippets Native deps, heavy compute, long-lived workspaces

Start with WASM. Move individual workloads to the container only when a concrete requirement (a native package, a performance ceiling) forces the trade.

Wiring it into an MCP client

With stdio transport, the client launches the server and speaks MCP over its stdin/stdout:

{
  "mcpServers": {
    "python-sandbox": {
      "command": "python",
      "args": ["/opt/python-sandbox/server.py"]
    }
  }
}

For shared or remote deployments, switch FastMCP to HTTP transport:

if __name__ == "__main__":
    mcp.run(transport="http", host="0.0.0.0", port=8000)

then front it with your normal ingress, TLS, and authentication. The tool code doesn’t change at all — transport is a deployment detail in MCP, which is exactly how it should be.

The security checklist

  • Execution engine is deny-by-default: no --allow-net, no --allow-write, no --allow-env, no --allow-run
  • Package cache pre-populated at install time; runtime invocations stay fully offline
  • Wall-clock timeout on every execution, killing the whole runner process
  • Output truncation so a print loop can’t flood the transport
  • Tool description tells the model the constraints (no net, no files, no cross-call state) so it doesn’t fight the sandbox
  • If containerized: non-root, cap_drop: ALL, no-new-privileges, no volume mounts, network_mode: none, mem/CPU/PID limits, read-only root fs
  • No secrets in the server’s environment or install directory — a sandbox with nothing to steal makes most attacks pointless by construction

Closing thought

The deepest lesson here isn’t about Python, Deno, or Docker — it’s about which direction your security model points. A sandbox you build by taking capabilities away is only as strong as your memory of what to take away. A sandbox you build by granting capabilities is as strong as the shortness of the grant list — and for running an agent’s throwaway Python, that list can be almost empty: read one package cache, nothing else.

Give your agent a room where it can break things. With the WASM approach, the room isn’t just locked — most of the doors were never built. The agent will break what it can, learn from the stack trace, and hand you working code, and the blast radius will be an in-memory filesystem that was going to vanish anyway.