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.
Who speaks MCP today
| Agent / IDE | MCP Support | Transport | Notes |
|---|---|---|---|
| Claude Code | Native | stdio | First-class support since launch |
| Claude Desktop | Native | stdio | Settings → MCP Servers |
| Cursor | Native | stdio | Settings → MCP → Add Server |
| Gemini CLI | Native | stdio | Supported via settings.json |
| Codex CLI (OpenAI) | Native | stdio | MCP support added Q1 2026 |
| JetBrains IDEs | Plugin | stdio | AI Assistant → MCP plugin |
| VS Code (Copilot) | Native | stdio | Copilot Chat reads MCP tools |
| Windsurf | Native | stdio | Cascade 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:
- Tools — Functions the agent can call.
sandbox_create,sandbox_execute,sandbox_browse. The agent sees their name, description, and JSON schema for parameters. This is what you use 90% of the time. - Resources — Read-only data the agent can pull into its context. Think "current sandbox status" or "list of running sandboxes." Resources are like GET endpoints — they provide information, not actions.
- Prompts — Pre-built prompt templates the agent can use. "Set up a coding environment" or "Run a web research workflow." Prompts give the agent structured starting points for common tasks.
sandbox_execute
sandbox_browse
sandbox_list
sandbox_stop
sandbox_save_file
sandbox://list
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.
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.
""" 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())
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:
{
"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.
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:
> 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
- Open Cursor Settings (Cmd+Shift+J on Mac, Ctrl+Shift+J on Linux/Windows)
- Navigate to MCP in the left sidebar
- Click "Add new MCP server"
- Enter server name:
sandbox - Type: stdio
- Command:
python /absolute/path/to/sandbox_mcp_server.py - Add environment variables:
SANDBOX_API_KEYandSANDBOX_API_URL
Option B: Project config file
{
"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.
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.
{
"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.
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:
{
"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:
- Open Settings/Preferences → Tools → AI Assistant → MCP Servers
- Click "+" to add a new server
- Name:
sandbox - Command:
python - Arguments:
/absolute/path/to/sandbox_mcp_server.py - Add environment variables for
SANDBOX_API_KEYandSANDBOX_API_URL
Alternatively, add it to your project's .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.
Install mcp-inspector
It is a Node.js tool. Install it globally.
npx @modelcontextprotocol/inspector
Connect to your server
The inspector opens a web UI at http://localhost:6274. Configure it to connect to your server via stdio.
# 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
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:
- sandbox_list — Verify you can connect to the API. Should return an empty list or existing sandboxes.
- sandbox_create — Create a test sandbox. Name it
mcp-test, useab0t.micro(cheapest), setauto_stop_minutes: 15. - sandbox_execute — Wait about 45 seconds for boot, then run
echo "hello from MCP". Verify stdout contains the message. - sandbox_save_file — Write a file: path
/workspace/test.py, contentprint("it works"). - sandbox_execute — Run
python /workspace/test.py. Verify output isit works. - sandbox_stop — Stop the sandbox. Verify status changes to
stopped.
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.
@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:
@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 context | The 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 context | Data 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:
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():
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
# 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
# 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
# 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.
| Scenario | Instance | Duration | Cost |
|---|---|---|---|
| Quick test (create, execute, stop) | ab0t.micro | ~5 min | $0.001 |
| Coding session via Claude Code | ab0t.medium | ~2 hrs | $0.08 |
| Browser research via Gemini CLI | ab0t.small | ~30 min | $0.01 |
| Test matrix: 5 sandboxes | 5x ab0t.micro | ~15 min each | $0.01 |
| Full day, switching between 3 agents | ab0t.medium, auto-stop | ~4 hrs active | $0.16 |
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:
# 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:
- Use environment variables that are set in your shell profile (
~/.bashrcor~/.zshrc) - Use a secrets manager (1Password CLI,
op run, or macOS Keychain) - Put the config in
~/.claude/settings.json(gitignored by default) rather than project-level.claude/settings.json
Add error context to tool responses
When an API call fails, the agent needs enough context to recover. Add structured error handling:
# 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:
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:
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:
- Is the Python path correct and absolute? Relative paths fail in some agents.
- Is
SANDBOX_API_KEYset? The server exits immediately without it. - Are
mcpandhttpxinstalled in the Python environment the config points to? - Run the server manually (
python sandbox_mcp_server.py) to see if it errors on startup.
Tools appear but calls fail with "connection refused"
The MCP server starts but can't reach the Sandbox Platform API. Check:
- Is
SANDBOX_API_URLcorrect? Don't include a trailing slash. - Is your network allowing HTTPS connections to the API?
- Test manually:
curl -H "Authorization: Bearer $SANDBOX_API_KEY" $SANDBOX_API_URL/api/sandboxes
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:
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:
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:
| Tool | Use case | API endpoint |
|---|---|---|
sandbox_restart | Restart a stopped sandbox | POST /api/sandboxes/{id}/restart |
sandbox_read_file | Read a file from the sandbox | GET /api/sandboxes/{id}/files?path=... |
sandbox_upload | Upload a binary file (datasets, images) | POST /api/sandboxes/{id}/upload |
sandbox_screenshot | Take a screenshot of the sandbox desktop | GET /api/sandboxes/{id}/screenshot |
sandbox_resize | Change instance type on-the-fly | POST /api/sandboxes/{id}/resize |
sandbox_clone | Duplicate a sandbox with its state | POST /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