Guide #5 Getting Started 25 min read March 2026

Build an MCP Server That Gives Any Agent a Sandbox

Claude Code, Cursor, Gemini CLI, Codex CLI, JetBrains — they all speak Model Context Protocol. Write one MCP server that exposes sandbox operations and every MCP-compatible agent gets cloud compute, browsers, and file I/O without a single integration change.

This guide builds a complete MCP server in ~200 lines of Python. You configure it once, and every agent you use — today and tomorrow — can create sandboxes, execute commands, browse the web, and manage files. Write once, every agent can use it.

What MCP is and why it matters

Model Context Protocol (MCP) is an open standard for connecting AI agents to external tools. Anthropic created it in late 2024 and donated it to the Linux Foundation in March 2025. It is now governed as an open-source project with contributions from Google, Microsoft, OpenAI, Amazon, and dozens of other companies.

The problem MCP solves is simple: every agent has its own way of calling tools. Claude Code uses tool definitions. LangChain has Tool objects. Cursor has its own plugin system. Without MCP, you write the same "create a sandbox" integration five times for five agents.

MCP is the USB-C of agent tooling. One connector, every device.

Claude Code Cursor Gemini CLI Codex CLI JetBrains
MCP Protocol (JSON-RPC over stdio/SSE)
Your MCP Server Sandbox Platform API
One server. Every agent. Zero per-client integration code.

Who speaks MCP today

Agent / IDEMCP SupportTransportNotes
Claude CodeNativestdioFirst-class support since launch
Claude DesktopNativestdioSettings → MCP Servers
CursorNativestdioSettings → MCP → Add Server
Gemini CLINativestdioSupported via settings.json
Codex CLI (OpenAI)NativestdioMCP support added Q1 2026
JetBrains IDEsPluginstdioAI Assistant → MCP plugin
VS Code (Copilot)NativestdioCopilot Chat reads MCP tools
WindsurfNativestdioCascade supports MCP tools

If an agent speaks MCP, it can use your server. No SDK, no plugin, no adapter. The MCP server you build in this guide works with all of them.

MCP server architecture

An MCP server exposes three types of capabilities to clients:

MCP Server
Tools sandbox_create
sandbox_execute
sandbox_browse
sandbox_list
sandbox_stop
sandbox_save_file
Resources sandbox://status
sandbox://list
Prompts coding-workflow
web-research

The transport layer is typically stdio for local MCP servers. The agent spawns your server as a child process and communicates over stdin/stdout using JSON-RPC 2.0. No HTTP, no ports, no firewall rules. Just a process that reads and writes JSON.

Full MCP server implementation

Here is the complete MCP server. It is about 200 lines of Python. It exposes six tools that let any MCP-compatible agent create sandboxes, execute commands, browse URLs, list running sandboxes, stop sandboxes, and save files. Copy this, save it, and you have a working MCP server.

Prerequisites

You need Python 3.10+ and a Sandbox Platform API key. Install the dependencies with:

pip install mcp httpx

The mcp package is the official MCP Python SDK maintained by the Linux Foundation working group.

python — sandbox_mcp_server.py
"""
MCP Server: Sandbox Platform

Exposes sandbox operations (create, execute, browse, list, stop, save_file)
to any MCP-compatible agent: Claude Code, Cursor, Gemini CLI, Codex CLI,
JetBrains, and more.

Usage:
    python sandbox_mcp_server.py

Configure in your agent's MCP settings to use this as a stdio server.
"""

import os
import json
import asyncio
from typing import Any

import httpx
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import (
    Tool,
    TextContent,
    Resource,
    ResourceTemplate,
    Prompt,
    PromptMessage,
    PromptArgument,
)

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

SANDBOX_API_URL = os.environ.get("SANDBOX_API_URL", "https://sandbox.dev.ab0t.com")
SANDBOX_API_KEY = os.environ.get("SANDBOX_API_KEY", "")

if not SANDBOX_API_KEY:
    raise ValueError("SANDBOX_API_KEY environment variable is required")


def _headers() -> dict[str, str]:
    return {
        "Authorization": f"Bearer {SANDBOX_API_KEY}",
        "Content-Type": "application/json",
    }


async def _api(method: str, path: str, body: dict | None = None) -> dict:
    """Make an API call to the Sandbox Platform."""
    async with httpx.AsyncClient(timeout=120) as client:
        resp = await client.request(
            method,
            f"{SANDBOX_API_URL}{path}",
            headers=_headers(),
            json=body,
        )
        resp.raise_for_status()
        return resp.json()


# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------

server = Server("sandbox-platform")


# ---- Tools ----------------------------------------------------------------

@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="sandbox_create",
            description="Create a new sandbox. Returns sandbox_id and connection info. "
                        "Use instance_type ab0t.micro for light tasks, ab0t.medium for coding, "
                        "ab0t.large for heavy builds.",
            inputSchema={
                "type": "object",
                "properties": {
                    "name": {"type": "string", "description": "Human-readable sandbox name"},
                    "instance_type": {
                        "type": "string",
                        "default": "ab0t.medium",
                        "description": "EC2 instance type",
                    },
                    "auto_stop_minutes": {
                        "type": "integer",
                        "default": 60,
                        "description": "Auto-stop after N minutes of inactivity (0 = never)",
                    },
                },
                "required": ["name"],
            },
        ),
        Tool(
            name="sandbox_execute",
            description="Execute a shell command in a running sandbox. "
                        "Returns stdout, stderr, and exit code. "
                        "Use for running code, installing packages, git operations.",
            inputSchema={
                "type": "object",
                "properties": {
                    "sandbox_id": {"type": "string", "description": "Sandbox ID"},
                    "command": {"type": "string", "description": "Shell command to execute"},
                    "timeout": {
                        "type": "integer",
                        "default": 300,
                        "description": "Timeout in seconds",
                    },
                },
                "required": ["sandbox_id", "command"],
            },
        ),
        Tool(
            name="sandbox_browse",
            description="Open a URL in a headless browser running in the sandbox. "
                        "Returns page title, text content, and a screenshot (base64). "
                        "Use for web research, scraping, and testing web apps.",
            inputSchema={
                "type": "object",
                "properties": {
                    "sandbox_id": {"type": "string", "description": "Sandbox ID"},
                    "url": {"type": "string", "description": "URL to navigate to"},
                    "wait_seconds": {
                        "type": "integer",
                        "default": 5,
                        "description": "Wait N seconds after page load for JS rendering",
                    },
                },
                "required": ["sandbox_id", "url"],
            },
        ),
        Tool(
            name="sandbox_list",
            description="List all sandboxes. Returns sandbox_id, name, status, "
                        "instance_type, and uptime for each.",
            inputSchema={
                "type": "object",
                "properties": {},
            },
        ),
        Tool(
            name="sandbox_stop",
            description="Stop a running sandbox. Stops billing. "
                        "The sandbox can be restarted later with its state preserved.",
            inputSchema={
                "type": "object",
                "properties": {
                    "sandbox_id": {"type": "string", "description": "Sandbox ID"},
                },
                "required": ["sandbox_id"],
            },
        ),
        Tool(
            name="sandbox_save_file",
            description="Write a file to the sandbox filesystem. "
                        "Creates parent directories automatically. "
                        "Use for saving code, configs, scripts.",
            inputSchema={
                "type": "object",
                "properties": {
                    "sandbox_id": {"type": "string", "description": "Sandbox ID"},
                    "path": {"type": "string", "description": "Absolute file path in the sandbox"},
                    "content": {"type": "string", "description": "File content"},
                },
                "required": ["sandbox_id", "path", "content"],
            },
        ),
    ]


@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
    """Route tool calls to the Sandbox Platform API."""

    if name == "sandbox_create":
        result = await _api("POST", "/api/sandboxes", {
            "name": arguments["name"],
            "instance_type": arguments.get("instance_type", "ab0t.medium"),
            "auto_stop_minutes": arguments.get("auto_stop_minutes", 60),
        })

    elif name == "sandbox_execute":
        result = await _api("POST", f"/api/sandboxes/{arguments['sandbox_id']}/execute", {
            "command": arguments["command"],
            "timeout": arguments.get("timeout", 300),
        })

    elif name == "sandbox_browse":
        result = await _api("POST", f"/api/sandboxes/{arguments['sandbox_id']}/browse", {
            "url": arguments["url"],
            "wait_seconds": arguments.get("wait_seconds", 5),
        })

    elif name == "sandbox_list":
        result = await _api("GET", "/api/sandboxes")

    elif name == "sandbox_stop":
        result = await _api("POST", f"/api/sandboxes/{arguments['sandbox_id']}/stop")

    elif name == "sandbox_save_file":
        result = await _api("POST", f"/api/sandboxes/{arguments['sandbox_id']}/files", {
            "path": arguments["path"],
            "content": arguments["content"],
        })

    else:
        return [TextContent(type="text", text=f"Unknown tool: {name}")]

    return [TextContent(type="text", text=json.dumps(result, indent=2))]


# ---- Resources ------------------------------------------------------------

@server.list_resources()
async def list_resources() -> list[Resource]:
    return [
        Resource(
            uri="sandbox://list",
            name="Running Sandboxes",
            description="List of all active sandboxes with status and uptime",
            mimeType="application/json",
        ),
    ]


@server.read_resource()
async def read_resource(uri: str) -> str:
    if uri == "sandbox://list":
        result = await _api("GET", "/api/sandboxes")
        return json.dumps(result, indent=2)
    raise ValueError(f"Unknown resource: {uri}")


# ---- Prompts --------------------------------------------------------------

@server.list_prompts()
async def list_prompts() -> list[Prompt]:
    return [
        Prompt(
            name="coding-workflow",
            description="Set up a sandbox, clone a repo, and start a coding session",
            arguments=[
                PromptArgument(
                    name="repo_url",
                    description="Git repository URL to clone",
                    required=True,
                ),
                PromptArgument(
                    name="task",
                    description="What to work on",
                    required=True,
                ),
            ],
        ),
    ]


@server.get_prompt()
async def get_prompt(name: str, arguments: dict) -> list[PromptMessage]:
    if name == "coding-workflow":
        return [
            PromptMessage(
                role="user",
                content=TextContent(
                    type="text",
                    text=(
                        f"1. Create a sandbox named 'coding-session'\n"
                        f"2. Execute: git clone {arguments['repo_url']} /workspace/repo\n"
                        f"3. Execute: cd /workspace/repo && ls -la\n"
                        f"4. Now work on: {arguments['task']}\n"
                        f"5. Run tests after each change\n"
                        f"6. When done, commit and stop the sandbox"
                    ),
                ),
            )
        ]
    raise ValueError(f"Unknown prompt: {name}")


# ---- Entrypoint -----------------------------------------------------------

async def main():
    async with stdio_server() as (read_stream, write_stream):
        await server.run(read_stream, write_stream)


if __name__ == "__main__":
    asyncio.run(main())
That is the entire server.

About 200 lines of Python. Six tools, one resource, one prompt. Save it as sandbox_mcp_server.py, install the two dependencies (pip install mcp httpx), and you have a working MCP server that any agent can use.

How the code works

Let's walk through the key decisions in the implementation.

The API wrapper

The _api() function is a thin async HTTP client that talks to the Sandbox Platform REST API. Every tool call maps to one API call. No state management, no caching, no complexity. The MCP server is a translator, not a controller.

Tool descriptions matter

The description strings in each Tool object are not for humans — they are for the LLM. The agent reads these descriptions to decide which tool to use and how to call it. Good descriptions reduce hallucinated parameters and wrong tool selection. Notice how each description includes when to use the tool: "Use for running code, installing packages, git operations."

The input schema is JSON Schema

MCP uses standard JSON Schema to define tool parameters. The agent generates a JSON object matching this schema. Required fields, defaults, types, descriptions — the LLM uses all of it. Skimp on descriptions and the agent will guess wrong.

stdio transport

The stdio_server() context manager handles all the JSON-RPC protocol details. The agent spawns the server as a subprocess, writes JSON to stdin, reads JSON from stdout. This is the simplest and most secure transport — no network exposure, no authentication on the MCP layer (the Sandbox API key handles auth).

Configuring for Claude Code

Claude Code reads MCP server configuration from .claude/settings.json in your project directory or ~/.claude/settings.json globally. Add the sandbox server like this:

json — .claude/settings.json
{
  "mcpServers": {
    "sandbox": {
      "command": "python",
      "args": ["/absolute/path/to/sandbox_mcp_server.py"],
      "env": {
        "SANDBOX_API_KEY": "ab0t_sk_live_YOUR_KEY_HERE",
        "SANDBOX_API_URL": "https://sandbox.dev.ab0t.com"
      }
    }
  }
}

Restart Claude Code. It will discover the server and list the six tools. You can verify by asking Claude Code: "What MCP tools do you have access to?" It should list sandbox_create, sandbox_execute, sandbox_browse, sandbox_list, sandbox_stop, and sandbox_save_file.

Global vs project settings

Put the MCP config in ~/.claude/settings.json if you want the sandbox tools available in every project. Put it in .claude/settings.json (project root) if you only want it for a specific repo. Project settings override global settings.

Now use it. Tell Claude Code to create a sandbox and run something:

prompt to Claude Code
> Create a sandbox called "experiment", clone https://github.com/fastapi/fastapi,
> run the test suite, and tell me what percentage passes.

Claude Code will call sandbox_create, then sandbox_execute multiple times (clone, install deps, run pytest), and report the results. All execution happens on the remote sandbox. Your laptop is untouched.

Configuring for Cursor

Cursor reads MCP servers from its settings UI and from .cursor/mcp.json in your project root.

Option A: Settings UI

  1. Open Cursor Settings (Cmd+Shift+J on Mac, Ctrl+Shift+J on Linux/Windows)
  2. Navigate to MCP in the left sidebar
  3. Click "Add new MCP server"
  4. Enter server name: sandbox
  5. Type: stdio
  6. Command: python /absolute/path/to/sandbox_mcp_server.py
  7. Add environment variables: SANDBOX_API_KEY and SANDBOX_API_URL

Option B: Project config file

json — .cursor/mcp.json
{
  "mcpServers": {
    "sandbox": {
      "command": "python",
      "args": ["/absolute/path/to/sandbox_mcp_server.py"],
      "env": {
        "SANDBOX_API_KEY": "ab0t_sk_live_YOUR_KEY_HERE",
        "SANDBOX_API_URL": "https://sandbox.dev.ab0t.com"
      }
    }
  }
}

Restart Cursor. The MCP tools will appear in Cursor's Composer (Cmd+I). When you ask Cursor to "run this code in a sandbox," it will use your MCP server to create a sandbox and execute the command remotely.

Cursor's agent mode

Make sure you are using Cursor's Agent mode (not normal chat) for tool use. In Composer, toggle to Agent mode. This allows Cursor to call MCP tools autonomously. In normal chat mode, MCP tools are not invoked.

Configuring for Gemini CLI

Gemini CLI (Google's open-source terminal agent, similar to Claude Code) supports MCP servers via its settings file at ~/.gemini/settings.json.

json — ~/.gemini/settings.json
{
  "mcpServers": {
    "sandbox": {
      "command": "python",
      "args": ["/absolute/path/to/sandbox_mcp_server.py"],
      "env": {
        "SANDBOX_API_KEY": "ab0t_sk_live_YOUR_KEY_HERE",
        "SANDBOX_API_URL": "https://sandbox.dev.ab0t.com"
      }
    }
  }
}

Restart Gemini CLI. It will discover the sandbox tools automatically. The same server, the same config format, the same tools. That is the entire point of MCP.

Other MCP-compatible agents

The configuration pattern is nearly identical for every agent: point it at the Python script, pass the API key as an environment variable, use stdio transport. Check your agent's documentation for the exact config file path. Most use the same mcpServers JSON structure.

Configuring for Codex CLI and JetBrains

Codex CLI (OpenAI)

Codex CLI added MCP support in early 2026. Configuration lives in ~/.codex/config.json:

json — ~/.codex/config.json
{
  "mcp_servers": {
    "sandbox": {
      "command": "python",
      "args": ["/absolute/path/to/sandbox_mcp_server.py"],
      "env": {
        "SANDBOX_API_KEY": "ab0t_sk_live_YOUR_KEY_HERE",
        "SANDBOX_API_URL": "https://sandbox.dev.ab0t.com"
      }
    }
  }
}

JetBrains IDEs (IntelliJ, PyCharm, WebStorm, etc.)

JetBrains supports MCP through the AI Assistant plugin. The configuration is in the IDE settings:

  1. Open Settings/Preferences → Tools → AI Assistant → MCP Servers
  2. Click "+" to add a new server
  3. Name: sandbox
  4. Command: python
  5. Arguments: /absolute/path/to/sandbox_mcp_server.py
  6. Add environment variables for SANDBOX_API_KEY and SANDBOX_API_URL

Alternatively, add it to your project's .idea/mcp.json:

json — .idea/mcp.json
{
  "mcpServers": {
    "sandbox": {
      "command": "python",
      "args": ["/absolute/path/to/sandbox_mcp_server.py"],
      "env": {
        "SANDBOX_API_KEY": "ab0t_sk_live_YOUR_KEY_HERE",
        "SANDBOX_API_URL": "https://sandbox.dev.ab0t.com"
      }
    }
  }
}

Testing the server with mcp-inspector

Before connecting the server to an agent, test it interactively. The mcp-inspector tool (part of the MCP ecosystem) lets you call tools and see results in a web UI.

1

Install mcp-inspector

It is a Node.js tool. Install it globally.

bash
npx @modelcontextprotocol/inspector
2

Connect to your server

The inspector opens a web UI at http://localhost:6274. Configure it to connect to your server via stdio.

bash
# Set the API key in your environment first
export SANDBOX_API_KEY="ab0t_sk_live_YOUR_KEY_HERE"
export SANDBOX_API_URL="https://sandbox.dev.ab0t.com"

# Run the inspector, pointing it at your server
npx @modelcontextprotocol/inspector python sandbox_mcp_server.py
3

Test each tool

The inspector shows all six tools with their schemas. Click a tool, fill in parameters, and execute. Verify you get the expected response.

The testing sequence should be:

  1. sandbox_list — Verify you can connect to the API. Should return an empty list or existing sandboxes.
  2. sandbox_create — Create a test sandbox. Name it mcp-test, use ab0t.micro (cheapest), set auto_stop_minutes: 15.
  3. sandbox_execute — Wait about 45 seconds for boot, then run echo "hello from MCP". Verify stdout contains the message.
  4. sandbox_save_file — Write a file: path /workspace/test.py, content print("it works").
  5. sandbox_execute — Run python /workspace/test.py. Verify output is it works.
  6. sandbox_stop — Stop the sandbox. Verify status changes to stopped.
If all six steps pass, your MCP server is working.

You can now connect it to any agent. The inspector is also great for debugging — if an agent is behaving strangely, test the same tool call in the inspector to check whether the issue is in the server or in the agent's tool invocation.

Advanced: adding resources

Resources are read-only data that the agent can pull into its context. The server above includes one resource (sandbox://list), but you can add more for richer context.

Sandbox status resource

Give the agent real-time status on a specific sandbox. This uses MCP's resource template feature — the URI contains a variable that the agent fills in.

python — add to sandbox_mcp_server.py
@server.list_resource_templates()
async def list_resource_templates() -> list[ResourceTemplate]:
    return [
        ResourceTemplate(
            uriTemplate="sandbox://{sandbox_id}/status",
            name="Sandbox Status",
            description="Detailed status of a specific sandbox including "
                        "uptime, CPU/memory usage, and running processes",
            mimeType="application/json",
        ),
    ]

Then update the read_resource handler to match the template:

python — updated read_resource handler
@server.read_resource()
async def read_resource(uri: str) -> str:
    if uri == "sandbox://list":
        result = await _api("GET", "/api/sandboxes")
        return json.dumps(result, indent=2)

    # Match sandbox://{id}/status
    if uri.startswith("sandbox://") and uri.endswith("/status"):
        sandbox_id = uri.replace("sandbox://", "").replace("/status", "")
        result = await _api("GET", f"/api/sandboxes/{sandbox_id}")
        return json.dumps(result, indent=2)

    raise ValueError(f"Unknown resource: {uri}")

When an agent reads sandbox://sandbox_a1b2c3d4/status, it gets the full sandbox details — status, instance type, IP address, uptime, costs — directly into its context without needing to call a tool.

When to use resources vs tools

Use Resources when...Use Tools when...
The agent needs background contextThe agent needs to take an action
Data is read-only (status, config, lists)There are side effects (create, execute, stop)
Data should be pre-loaded into contextData is generated on-demand per request
Example: "Here are all running sandboxes"Example: "Create a new sandbox"

Advanced: adding prompts

Prompts give the agent a structured starting point for common workflows. The server above includes one prompt (coding-workflow). Here is a more complete example that adds a web research workflow:

python — add to list_prompts()
Prompt(
    name="web-research",
    description="Create a sandbox with a browser and research a topic. "
                "Browses multiple URLs and compiles findings.",
    arguments=[
        PromptArgument(
            name="topic",
            description="Topic to research",
            required=True,
        ),
        PromptArgument(
            name="urls",
            description="Comma-separated URLs to start from (optional)",
            required=False,
        ),
    ],
),
Prompt(
    name="test-matrix",
    description="Create N sandboxes and run a test suite across different configurations",
    arguments=[
        PromptArgument(
            name="repo_url",
            description="Git repository URL",
            required=True,
        ),
        PromptArgument(
            name="configurations",
            description="Comma-separated configs to test (e.g., python3.10,python3.11,python3.12)",
            required=True,
        ),
    ],
),

Then add the handler in get_prompt():

python — add to get_prompt()
elif name == "web-research":
    urls_hint = ""
    if arguments.get("urls"):
        urls_hint = f"\nStart by browsing these URLs: {arguments['urls']}"
    return [
        PromptMessage(
            role="user",
            content=TextContent(
                type="text",
                text=(
                    f"1. Create a sandbox named 'research'\n"
                    f"2. Research this topic: {arguments['topic']}\n"
                    f"{urls_hint}\n"
                    f"3. Browse at least 5 relevant URLs using sandbox_browse\n"
                    f"4. Compile findings into a structured markdown report\n"
                    f"5. Save the report to /workspace/research-report.md\n"
                    f"6. Stop the sandbox when done"
                ),
            ),
        )
    ]

elif name == "test-matrix":
    configs = [c.strip() for c in arguments["configurations"].split(",")]
    steps = f"1. Create {len(configs)} sandboxes, one for each configuration\n"
    for i, cfg in enumerate(configs, 1):
        steps += f"   - Sandbox {i}: '{cfg}'\n"
    steps += (
        f"2. In each sandbox, clone {arguments['repo_url']}\n"
        f"3. Set up the specific configuration (install deps, set env vars)\n"
        f"4. Run the test suite in each sandbox\n"
        f"5. Collect and compare results across all configurations\n"
        f"6. Stop all sandboxes when done"
    )
    return [
        PromptMessage(
            role="user",
            content=TextContent(type="text", text=steps),
        )
    ]

Prompts are underused but powerful. In Claude Code, you can invoke them with the /mcp slash command. In Cursor, they appear in the prompt library. They turn your MCP server from a bag of tools into a workflow engine.

Real-world example: one server, three agents

Here is what this looks like in practice. You have one MCP server running. Three different agents use it in the same day.

Morning: Claude Code creates a sandbox for a refactor

Claude Code session
# You tell Claude Code:
> Create a sandbox and refactor the auth module to use async/await.
> Clone git@github.com:myorg/backend.git, make the changes,
> run the tests, and commit.

# Claude Code calls:
# 1. sandbox_create(name="auth-refactor", instance_type="ab0t.medium")
# 2. sandbox_execute(sandbox_id="...", command="git clone ... && cd backend")
# 3. sandbox_execute(sandbox_id="...", command="pip install -r requirements.txt")
# 4. sandbox_execute(sandbox_id="...", command="python -m pytest tests/")
# 5. [makes changes, runs tests, iterates until green]
# 6. sandbox_execute(sandbox_id="...", command="git push origin auth-async")
# 7. sandbox_stop(sandbox_id="...")

Afternoon: Cursor tests a frontend change in an isolated browser

Cursor Composer (Agent mode)
# You tell Cursor:
> Spin up a sandbox, deploy the frontend build, and browse to
> localhost:3000. Check if the new login form renders correctly.

# Cursor calls:
# 1. sandbox_create(name="frontend-test")
# 2. sandbox_save_file(path="/workspace/dist/index.html", content="...")
# 3. sandbox_execute(command="cd /workspace/dist && python -m http.server 3000 &")
# 4. sandbox_browse(url="http://localhost:3000")
# 5. [analyzes the page content and reports back]
# 6. sandbox_stop(sandbox_id="...")

Evening: Gemini CLI runs a web research task

Gemini CLI session
# You tell Gemini CLI:
> Research the top 5 Python web frameworks in 2026.
> Browse their official sites and GitHub repos.
> Compile a comparison table with stars, downloads, and key features.

# Gemini CLI calls:
# 1. sandbox_create(name="research")
# 2. sandbox_browse(url="https://github.com/topics/python-web-framework")
# 3. sandbox_browse(url="https://fastapi.tiangolo.com")
# 4. sandbox_browse(url="https://www.djangoproject.com")
# 5. [browses more URLs, compiles findings]
# 6. sandbox_save_file(path="/workspace/report.md", content="...")
# 7. sandbox_stop(sandbox_id="...")

Same server. Same six tools. Three different agents. Zero integration code per agent.

What it costs

The MCP server itself is free — it runs as a local process on your machine. You pay only for sandbox compute time.

ScenarioInstanceDurationCost
Quick test (create, execute, stop)ab0t.micro~5 min$0.001
Coding session via Claude Codeab0t.medium~2 hrs$0.08
Browser research via Gemini CLIab0t.small~30 min$0.01
Test matrix: 5 sandboxes5x ab0t.micro~15 min each$0.01
Full day, switching between 3 agentsab0t.medium, auto-stop~4 hrs active$0.16
Auto-stop saves money

Set auto_stop_minutes to 15-30 for interactive work. The sandbox stops when you forget about it. Restarting a stopped sandbox takes about 30 seconds and is cheaper than paying for idle compute. For overnight or long-running tasks, set it to 0.

Tips for production use

Use a virtual environment for the server

Create a dedicated virtualenv for the MCP server so its dependencies (mcp, httpx) don't conflict with your project's packages. Point the config at the virtualenv's Python:

bash
# Create a dedicated virtualenv
python -m venv ~/.mcp-sandbox-env
~/.mcp-sandbox-env/bin/pip install mcp httpx

# Point your MCP config at this Python
# In .claude/settings.json:
#   "command": "/Users/you/.mcp-sandbox-env/bin/python"

Store the API key securely

Don't hardcode the API key in config files checked into git. Options:

Add error context to tool responses

When an API call fails, the agent needs enough context to recover. Add structured error handling:

python
# In call_tool(), wrap the API call:
try:
    result = await _api("POST", path, body)
except httpx.HTTPStatusError as e:
    error_detail = {
        "error": True,
        "status_code": e.response.status_code,
        "message": e.response.text,
        "hint": "Check if the sandbox is running (sandbox_list) "
                "and the sandbox_id is correct.",
    }
    return [TextContent(type="text", text=json.dumps(error_detail, indent=2))]

Log tool calls for debugging

MCP servers can write to stderr for debugging without interfering with the JSON-RPC protocol on stdout:

python
import sys

def log(msg: str):
    """Write to stderr for debugging. Does not interfere with MCP protocol."""
    print(f"[sandbox-mcp] {msg}", file=sys.stderr)

# In call_tool():
log(f"Tool call: {name} with args {json.dumps(arguments)}")

Add a health check tool

Add a lightweight sandbox_ping tool that tests connectivity to the API without creating resources. Useful for agents to verify the MCP server is working before starting a workflow:

python
Tool(
    name="sandbox_ping",
    description="Test connectivity to the Sandbox Platform API. "
                "Call this first to verify the MCP server is configured correctly.",
    inputSchema={"type": "object", "properties": {}},
),

# In call_tool():
elif name == "sandbox_ping":
    result = await _api("GET", "/health")
    result["mcp_server"] = "connected"

Troubleshooting

Agent says "no MCP tools available"

The server is not starting. Check:

Tools appear but calls fail with "connection refused"

The MCP server starts but can't reach the Sandbox Platform API. Check:

sandbox_execute times out

The default timeout is 300 seconds (5 minutes). Long-running commands (builds, test suites) may need more. The agent can pass timeout: 900 for 15 minutes. For commands that run indefinitely (servers, watchers), append & to background them.

Agent creates a sandbox but cannot execute commands

The sandbox takes 30-60 seconds to boot after creation. The MCP server doesn't wait for the sandbox to be ready — it returns immediately after the create call. The agent needs to either poll sandbox_list until the status is running, or wait before calling sandbox_execute. You can improve this by adding a wait loop in the sandbox_create handler:

python — optional wait-for-ready in sandbox_create
if name == "sandbox_create":
    result = await _api("POST", "/api/sandboxes", {
        "name": arguments["name"],
        "instance_type": arguments.get("instance_type", "ab0t.medium"),
        "auto_stop_minutes": arguments.get("auto_stop_minutes", 60),
    })
    sandbox_id = result["sandbox_id"]

    # Wait for sandbox to be running (up to 90 seconds)
    for _ in range(18):
        await asyncio.sleep(5)
        status = await _api("GET", f"/api/sandboxes/{sandbox_id}")
        if status.get("status") == "running":
            result["status"] = "running"
            result["ready"] = True
            break
    else:
        result["ready"] = False
        result["hint"] = "Sandbox is still booting. Wait a moment before executing."

Cursor doesn't call MCP tools

Ensure you are in Agent mode, not normal chat mode. In Composer (Cmd+I), check the mode toggle at the top. Also make sure the MCP server shows a green dot in Cursor's MCP settings — red means it failed to start.

Rate limiting errors (429)

The Sandbox Platform API has rate limits. If the agent is creating and destroying sandboxes rapidly (e.g., in a retry loop), it may hit them. Add retry logic with exponential backoff:

python
async def _api_with_retry(method, path, body=None, retries=3):
    for attempt in range(retries):
        try:
            return await _api(method, path, body)
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 429 and attempt < retries - 1:
                wait = 2 ** attempt
                log(f"Rate limited. Retrying in {wait}s...")
                await asyncio.sleep(wait)
            else:
                raise

How this compares to other approaches

Without MCP: per-agent integration

  • Write a Claude Code skill for sandbox access
  • Write a Cursor plugin for sandbox access
  • Write a Gemini CLI extension for sandbox access
  • Write a LangChain tool wrapper for sandbox access
  • Maintain 4 codebases that do the same thing

With MCP: one server, all agents

  • Write one MCP server (200 lines)
  • Configure each agent in 5 lines of JSON
  • New agent comes out? Add 5 lines of JSON
  • New tool needed? Add it once, all agents get it
  • Maintain 1 codebase

Extending the server

The six-tool server covers the common cases. Here are tools you might add for specific workflows:

ToolUse caseAPI endpoint
sandbox_restartRestart a stopped sandboxPOST /api/sandboxes/{id}/restart
sandbox_read_fileRead a file from the sandboxGET /api/sandboxes/{id}/files?path=...
sandbox_uploadUpload a binary file (datasets, images)POST /api/sandboxes/{id}/upload
sandbox_screenshotTake a screenshot of the sandbox desktopGET /api/sandboxes/{id}/screenshot
sandbox_resizeChange instance type on-the-flyPOST /api/sandboxes/{id}/resize
sandbox_cloneDuplicate a sandbox with its statePOST /api/sandboxes/{id}/clone

Adding a tool is a three-step process: define the Tool object in list_tools(), add the handler in call_tool(), and restart the server. Every connected agent sees the new tool immediately.

What's next

One server. Every agent.

Build it once in 200 lines of Python. Every MCP-compatible agent gets cloud compute, browsers, and file I/O.

Get Started Free