---
title: "Build an MCP server with an execute tool backed by a sandbox"
description: "A complete MCP server in TypeScript and Python whose execute tool runs model-written code in a Promigence sandbox of its own per session, with no network, a spend cap and a timeout."
url: "https://www.promigence.ai/docs/guides/mcp-execute-tool"
site: "Promigence"
---

# Build an MCP server with an execute tool backed by a sandbox

A complete MCP server in TypeScript and Python whose execute tool runs model-written code in a Promigence sandbox of its own per session, with no network, a spend cap and a timeout.

## The short answer

Register one MCP tool, `execute`, that writes the model's code to a file inside a Promigence sandbox, runs it there and returns what happened. Give every MCP session a sandbox of its own, with no network, a spend cap and a timeout, and end the sandbox when the session ends. If you only want Claude Code or another MCP client to use sandboxes, you can skip the code: `promigence mcp` is a ready-made server.

## What you build

The finished server offers a single tool. A model calls it with a program and a language, and gets back the exit code, the output streams and a flag that says whether output was cut.

The rules it follows:

- One sandbox per MCP session, started on the first call and reused after it, so files the code writes under `/work` are still there on the next call.
- No network inside the sandbox. Model-written code cannot send your data anywhere or download anything.
- A spend cap of $1 and a lifetime of 30 minutes on every sandbox, so a runaway loop has a ceiling.
- A non-zero exit comes back as a normal result. The program ran and failed, and the model needs to read why. Only a timeout or a failure of the sandbox itself is reported as a tool error.
- After a failure of the sandbox (its cap or timeout reached, no credit left), the server ends it and the next call starts a fresh one.

Over stdio, the MCP client starts one server process per session, so one sandbox per process is one sandbox per session. Serving many users over HTTP is covered further down.

## Before you start

You need a Promigence account. Sign up or sign in once with the CLI, and both SDKs read the key it stored; exporting `PROMIGENCE_API_KEY` works too. Claim the free credit before the first sandbox, or the first call stops with `credit_unclaimed`:

```bash
npm i -g promigence
promigence signup          # or: promigence login, for an account you already have
promigence credits claim
```

The TypeScript server needs Node.js 20 or newer. For Python, use 3.10 or newer, since the MCP package requires it. The code below was compiled and type-checked against `@promigence/sdk` 0.1.0-beta.7 with `@modelcontextprotocol/sdk` 1.32.1, and against `promigence` 0.1.0b7 with `mcp` 2.3.0.

## TypeScript

Install the two SDKs and zod, which the MCP SDK uses for tool schemas:

```bash
mkdir sandbox-execute && cd sandbox-execute
npm init -y
npm pkg set type=module
npm install @promigence/sdk @modelcontextprotocol/sdk zod
npm install -D typescript @types/node @types/express
```

A `tsconfig.json` that compiles `src/` to `build/`:

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "build",
    "rootDir": "src",
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src"]
}
```

The tool lives in `src/execute.ts`. `createExecuteServer()` returns one session: an `McpServer` with the tool registered, and a `close` function that ends the session's sandbox.

```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CommandExitError, CommandTimeoutError, PromigenceError, Sandbox } from "@promigence/sdk";
import { z } from "zod";

// How each language runs inside the sandbox.
const RUNTIMES = {
  python: { ext: "py", run: "python3" },
  javascript: { ext: "mjs", run: "node" },
  bash: { ext: "sh", run: "bash" },
} as const;

/** One MCP session: a server with the `execute` tool and a sandbox of its own. */
export function createExecuteServer(): { server: McpServer; close: () => Promise<void> } {
  const server = new McpServer({ name: "sandbox-execute", version: "1.0.0" });

  // Created on the first call and reused after it, so files the code writes
  // under /work are still there on the next call in this session.
  let sandbox: Promise<Sandbox> | undefined;
  let cell = 0;

  const getSandbox = () =>
    (sandbox ??= Sandbox.create({
      allowInternetAccess: false, // the code gets no network at all
      spendCap: 1, // US dollars: the most this sandbox may spend
      timeoutMs: 30 * 60_000, // it ends on its own after 30 minutes
    }));

  // Forget the sandbox and end it; the next call starts a fresh one.
  const close = async () => {
    const current = sandbox;
    sandbox = undefined;
    await current?.then((s) => s.kill()).catch(() => undefined);
  };

  server.registerTool(
    "execute",
    {
      title: "Execute code",
      description:
        "Run Python, JavaScript or a bash script in a remote, isolated sandbox with no network access, " +
        "and return its exit code along with stdout and stderr. The working directory is /work, and files written " +
        "there are kept for later calls in this session.",
      inputSchema: {
        code: z.string().min(1).describe("The program to run."),
        language: z.enum(["python", "javascript", "bash"]).default("python"),
        timeout_seconds: z.number().int().min(1).max(300).default(60)
          .describe("Stop the program if it runs longer than this."),
      },
      outputSchema: {
        exit_code: z.number().int(),
        stdout: z.string(),
        stderr: z.string(),
        truncated: z.boolean(),
      },
      annotations: { readOnlyHint: false, openWorldHint: false },
    },
    async ({ code, language, timeout_seconds }) => {
      const { ext, run } = RUNTIMES[language];
      try {
        const sbx = await getSandbox();
        const file = `/tmp/cell-${++cell}.${ext}`;
        await sbx.files.write(file, code);
        let result: { exitCode: number; stdout: string; stderr: string; truncated?: boolean };
        try {
          result = await sbx.commands.run(`${run} ${file}`, { cwd: "/work", timeoutMs: timeout_seconds * 1000 });
        } catch (e) {
          if (e instanceof CommandTimeoutError) {
            return { content: [{ type: "text", text: `Stopped after ${timeout_seconds} s, its timeout.` }], isError: true };
          }
          if (!(e instanceof CommandExitError)) throw e;
          result = e; // a non-zero exit is an answer for the model, not a failure of the tool
        }
        const output = {
          exit_code: result.exitCode,
          stdout: result.stdout,
          stderr: result.stderr,
          truncated: result.truncated ?? false,
        };
        return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output };
      } catch (e) {
        // The sandbox could not run it: no credit, its cap or timeout reached, the sandbox gone.
        await close();
        const text = e instanceof PromigenceError ? `${e.code}: ${e.message} ${e.fix}` : String(e);
        return { content: [{ type: "text", text }], isError: true };
      }
    },
  );

  return { server, close };
}
```

`src/stdio.ts` serves one session over stdio and ends its sandbox when the client goes away:

```typescript
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createExecuteServer } from "./execute.js";

// Over stdio the client starts one process per session, so one server here is one session.
const { server, close } = createExecuteServer();

// End the sandbox when the client disconnects or the process is stopped.
async function shutdown(): Promise<never> {
  await close();
  process.exit(0);
}
process.stdin.on("end", shutdown);
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

await server.connect(new StdioServerTransport());
console.error("sandbox-execute: ready on stdio"); // stdout carries the protocol, so log to stderr
```

Compile it, then add it to Claude Code. Everything after `--` is the command that starts the server:

```bash
npx tsc
claude mcp add sandbox-execute -- node /absolute/path/to/sandbox-execute/build/stdio.js
```

Run `/mcp` inside a Claude Code session to check that `sandbox-execute` is connected with its one tool.

## Python

The Python version uses the `MCPServer` class from the `mcp` package. Its lifespan holds the session's sandbox and ends it when the server stops, and a `ToolError` is how a tool tells the model that something went wrong.

```bash
pip install mcp promigence
```

Save this as `server.py`:

```python
import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Annotated, Literal

from pydantic import BaseModel, Field

from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.exceptions import ToolError
from promigence import AsyncSandbox, CommandExitError, CommandTimeoutError, PromigenceError

# How each language runs inside the sandbox: file extension, command.
RUNTIMES = {"python": ("py", "python3"), "javascript": ("mjs", "node"), "bash": ("sh", "bash")}

class ExecuteResult(BaseModel):
    exit_code: int
    stdout: str
    stderr: str
    truncated: bool

@dataclass
class Session:
    """One sandbox per server process, created on the first call and reused after it."""

    sandbox: AsyncSandbox | None = None
    cells: int = 0
    lock: asyncio.Lock = field(default_factory=asyncio.Lock)

    async def get(self) -> AsyncSandbox:
        async with self.lock:
            if self.sandbox is None:
                self.sandbox = await AsyncSandbox.create(
                    allow_internet_access=False,  # the code gets no network at all
                    spend_cap=1,  # US dollars: the most this sandbox may spend
                    timeout_ms=30 * 60_000,  # it ends on its own after 30 minutes
                )
            return self.sandbox

    async def drop(self) -> None:
        """Forget the sandbox and end it; the next call starts a fresh one."""
        sandbox, self.sandbox = self.sandbox, None
        if sandbox is not None:
            try:
                await sandbox.kill()
            except PromigenceError:
                pass

@asynccontextmanager
async def lifespan(server: MCPServer) -> AsyncIterator[Session]:
    session = Session()
    try:
        yield session
    finally:
        await session.drop()  # end the sandbox when the server stops

mcp = MCPServer("sandbox-execute", lifespan=lifespan)

@mcp.tool(title="Execute code")
async def execute(
    code: Annotated[str, Field(min_length=1, description="The program to run.")],
    ctx: Context[Session],
    language: Literal["python", "javascript", "bash"] = "python",
    timeout_seconds: Annotated[int, Field(ge=1, le=300, description="Stop the program if it runs longer than this.")] = 60,
) -> ExecuteResult:
    """Run Python, JavaScript or a bash script in a remote, isolated sandbox with no network
    access, and return its exit code along with stdout and stderr. The working directory is
    /work, and files written there are kept for later calls in this session."""
    session = ctx.request_context.lifespan_context
    ext, run = RUNTIMES[language]
    try:
        sandbox = await session.get()
        session.cells += 1
        path = f"/tmp/cell-{session.cells}.{ext}"
        await sandbox.files.write(path, code)
        result = await sandbox.commands.run(f"{run} {path}", cwd="/work", timeout_ms=timeout_seconds * 1000)
    except CommandTimeoutError:
        raise ToolError(f"Stopped after {timeout_seconds} s, its timeout.")
    except CommandExitError as e:
        # A non-zero exit is an answer for the model, not a failure of the tool.
        return ExecuteResult(exit_code=e.exit_code, stdout=e.stdout, stderr=e.stderr, truncated=bool(e.result.truncated))
    except PromigenceError as e:
        # The sandbox could not run it: no credit, its cap or timeout reached, the sandbox gone.
        await session.drop()
        raise ToolError(f"{e.code}: {e.message} {e.fix}")
    return ExecuteResult(exit_code=0, stdout=result.stdout, stderr=result.stderr, truncated=bool(result.truncated))

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

Order matters in the `except` clauses: a timeout is a kind of non-zero exit, and both are Promigence errors, so the most specific comes first. The success path can say `exit_code=0` because `commands.run` raises on any other exit.

Claude Code starts it with the command after `--`. `uv run --with` builds a fresh environment with both packages, so the command works from any directory. If you added the TypeScript server above under the same name, run `claude mcp remove sandbox-execute` first:

```bash
claude mcp add sandbox-execute -- uv run --with "mcp[cli]" --with promigence mcp run /absolute/path/to/server.py
```

`python server.py` starts the same server if both packages are installed where you run it.

## One sandbox per session over HTTP

To offer the tool to the users of your product, serve it over Streamable HTTP. Each MCP session gets its own transport, its own server and so its own sandbox, and a session that ends takes its sandbox with it. This `src/http.ts` reuses `createExecuteServer()` from above:

```typescript
import { randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { createExecuteServer } from "./execute.js";

// Anyone who can call this endpoint can run code on your bill: require a token.
const TOKEN = process.env.MCP_TOKEN;
if (!TOKEN) throw new Error("Set MCP_TOKEN; clients send it as 'Authorization: Bearer <token>'.");

const app = createMcpExpressApp(); // binds 127.0.0.1, with DNS rebinding protection
const sessions = new Map<string, StreamableHTTPServerTransport>();

app.use((req, res, next) => {
  if (req.headers.authorization !== `Bearer ${TOKEN}`) {
    res.status(401).end();
    return;
  }
  next();
});

app.post("/mcp", async (req, res) => {
  const id = req.header("mcp-session-id");
  let transport = id ? sessions.get(id) : undefined;
  if (!transport) {
    if (id || !isInitializeRequest(req.body)) {
      res.status(400).json({ error: "Unknown session: send initialize first." });
      return;
    }
    const { server, close } = createExecuteServer(); // this session's own sandbox
    const created = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (sid) => {
        sessions.set(sid, created);
      },
    });
    created.onclose = () => {
      if (created.sessionId) sessions.delete(created.sessionId);
      void close(); // the session ended, so its sandbox ends too
    };
    await server.connect(created);
    transport = created;
  }
  await transport.handleRequest(req, res, req.body);
});

// GET opens a session's notification stream; DELETE ends the session and its sandbox.
async function existingSession(req: Request, res: Response) {
  const transport = sessions.get(req.header("mcp-session-id") ?? "");
  if (!transport) {
    res.status(404).end();
    return;
  }
  await transport.handleRequest(req, res);
}
app.get("/mcp", existingSession);
app.delete("/mcp", existingSession);

app.listen(3000, () => console.error("sandbox-execute: http://127.0.0.1:3000/mcp"));
```

A shared token is the least you can do. A product with real users should put proper sign-in in front of the endpoint and give each user their own cap. A client that disappears without ending its session leaves a sandbox behind until its 30-minute timeout, and the $1 cap bounds what that costs.

The stdio server also runs on the 2.x line of the MCP TypeScript SDK with two changes: import `McpServer` from `@modelcontextprotocol/server` and `StdioServerTransport` from `@modelcontextprotocol/server/stdio`, and wrap each schema in `z.object(...)`. It was compiled and started that way against `@modelcontextprotocol/server` 2.3.1. The HTTP example above is for the 1.x line only.

## Promigence already ships one

The CLI includes an MCP server of its own. It gives an MCP client tools to create sandboxes and use them, with no code to write:

```bash
claude mcp add promigence -- promigence mcp
```

On Windows, end the command with `-- cmd /c promigence mcp` instead. Its flags shape what the client may do:

| Flag | What it does |
|---|---|
| `--read-only` | Offers only the tools that read; they still return what a sandbox holds |
| `--root PATH` | Keeps the file tools to one directory of the sandbox; commands are not limited to it |
| `--allow-tools A,B` | Offers only the tools you name |
| `--cap USD` | The most each sandbox or batch it starts may spend |
| `--keep` | Leaves the session's sandboxes running when the server stops; by default they are ended |

Pick `promigence mcp` when a coding assistant on your machine should drive sandboxes for you. Write your own server when you want one narrow tool with your rules fixed in code, or when the tool is part of a product you serve to other people.

## Make it yours

A few changes turn the example into something you can ship.

Start from your own environment. Build a snapshot that already has your libraries, checked by a command that must exit 0, and pass its alias to `Sandbox.create`:

```bash
promigence snapshot create --image python:3.12 --setup "pip install pandas" \
  --verify "python3 -c 'import pandas'" --alias analysis --wait
```

Then `Sandbox.create("analysis", { ... })` starts every session from it. The build is billed per second while it runs, up to $0.17 at the default small size, and one that fails its verify command stores nothing.

If the code must install packages, replace "no network" with an outbound allow-list: `allow_hosts=[...]` on `AsyncSandbox.create` in Python, `allowHosts` on `Sandbox.fork` in TypeScript. Keep keys out of the code the model runs. Store one with `promigence secrets set NAME --from-env VAR --host HOST --no-raw` and the sandbox never holds its value; requests to that host are authenticated for you. Last, size the budget to the user: set the per-sandbox cap from their plan, and give the whole project a monthly ceiling with `promigence project cap PROJECT USD`.

## At a glance

| Question | Answer |
|---|---|
| Tool input | `code`, `language` (python, javascript or bash), `timeout_seconds` (1 to 300, default 60) |
| Tool output | `exit_code`, `stdout`, `stderr`, `truncated` |
| Sandbox per | MCP session: one per stdio process, one per HTTP session id |
| Network inside the sandbox | None |
| Spend cap and lifetime | $1 and 30 minutes per sandbox, ended with the session |
| Non-zero exit | Returned as a normal result for the model to read |
| Timeout or platform failure | A tool error; the next call starts a fresh sandbox |
| Tested with | `@promigence/sdk` 0.1.0-beta.7, `@modelcontextprotocol/sdk` 1.32.1, `promigence` 0.1.0b7, `mcp` 2.3.0 |
| Ready-made alternative | `promigence mcp` |

**An execute tool backed by a sandbox**

The MCP client calls the execute tool on your server, which writes the code into the session's own sandbox and runs it. The exit code and output go back to the model as the tool result.

Steps:
1. MCP client: Claude Code or your agent
2. Your MCP server: the execute tool
3. A Promigence sandbox for this session
4. Exit code, stdout, stderr

Connections:
1. MCP client: Claude Code or your agent → Your MCP server: the execute tool: calls execute
2. Your MCP server: the execute tool → A Promigence sandbox for this session: writes the code, runs it
3. A Promigence sandbox for this session → Exit code, stdout, stderr: the command's result
4. Exit code, stdout, stderr → MCP client: Claude Code or your agent: returned as the tool result

_Each MCP session gets its own sandbox; the model sees the program's result._

## FAQ

### How do I write an MCP server that exposes an `execute` tool which runs code in a remote isolated sandbox per session?

Use the MCP SDK to register one tool whose handler writes the code into a Promigence sandbox, runs it there and returns the exit code and output. The sandbox belongs to the session: over stdio that is one per server process, and over HTTP one per session id. The complete TypeScript and Python servers are above.

### Should a non-zero exit code be an MCP tool error?

No. A program that ran and exited 1 produced an answer, and the model needs its stderr to fix the code, so return it as a normal result with the exit code in it. Tool errors are for what the output cannot explain: a timeout, or a sandbox that could not run the program at all.

### Does Promigence already have an MCP server?

Yes. `promigence mcp` runs one over stdio, and `claude mcp add promigence -- promigence mcp` adds it to Claude Code. Flags such as `--read-only`, `--allow-tools` and `--cap` narrow what the client may do, and the sandboxes it started are ended when it stops unless you pass `--keep`.

### Can the code reach the internet?

Not in this example: the sandbox starts with no network, so model-written code can neither send your data out nor fetch anything. When it needs a package index or one API, start the sandbox behind an outbound allow-list instead. For a sandbox with an allow-list, `promigence sandbox network SID` shows what it may reach and the hosts it was refused.

### What does one session cost?

`promigence/base` starts at the small size, 2 vCPU and 4 GiB, which lists at $0.17 an hour, billed per second. A session that keeps its sandbox for the full 30 minutes costs about $0.08, and the $1 cap is the most it could ever cost.

## Sources

Every page below was read on 17 Oct 2026.

| What | Source |
|---|---|
| `promigence mcp` and its flags, `snapshot create`, `secrets set`, `project cap`, `sandbox network`, the small size of `promigence/base` | [Promigence CLI reference](https://www.promigence.ai/docs/cli) |
| `Sandbox.create`, `spendCap`, the stored key the SDK reads | [Promigence TypeScript SDK](https://www.promigence.ai/docs/sdk) |
| Spend caps, session length on a new account | [Promigence limits and notices](https://www.promigence.ai/docs/limits) |
| A failed verify stores nothing; a build is billed per second, up to $0.17 at small | [Promigence quickstart](https://www.promigence.ai/docs/quickstart) |
| $0.0504 per vCPU-hour, $0.0162 per GiB-hour, $0.17 an hour for small | [Promigence pricing](https://www.promigence.ai/#pricing) |
| The published Python package, 0.1.0b7 | [promigence on PyPI](https://pypi.org/project/promigence/) |
| The published TypeScript package, 0.1.0-beta.7 | [@promigence/sdk on npm](https://www.npmjs.com/package/@promigence/sdk) |
| `McpServer`, `registerTool`, `StdioServerTransport`, `isError` | [MCP TypeScript SDK 1.x, server guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.x/docs/server.md) |
| The 1.x line and the 2.x packages | [MCP TypeScript SDK](https://ts.sdk.modelcontextprotocol.io/) and its [2.x migration guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2) |
| `MCPServer`, tools from type hints, `Field` constraints | [MCP Python SDK, Tools](https://py.sdk.modelcontextprotocol.io/servers/tools/) |
| `ToolError` and when to raise it | [MCP Python SDK, Handling errors](https://py.sdk.modelcontextprotocol.io/servers/handling-errors/) |
| The lifespan that holds the sandbox | [MCP Python SDK, Lifespan](https://py.sdk.modelcontextprotocol.io/handlers/lifespan/) |
| `claude mcp add`, `uv run --with`, `/mcp` | [MCP Python SDK, Connect to a real host](https://py.sdk.modelcontextprotocol.io/get-started/real-host/) |

Updated 2026-10-17

