MCP bridge

The E2B SDK is a library the agent author imports. MCP is the other direction — a line protocol the agent host speaks, so any MCP-aware client (Claude Desktop, Claude Code, Cursor, the Inspector) can pick up new capabilities without shipping a new SDK. If Coppice is going to live in an agent’s loop the way e2b-code-interpreter does, MCP is the cheaper integration surface.

What the bridge does

coppice-mcp is a thin binary at e2b-compat/src/bin/coppice-mcp.rs. It implements the server half of MCP (spec), built on rmcp 1.5, and translates each MCP tool call into one or more REST calls against the running gateway. There is no gateway patch; no new wire format; no sandbox-side agent. The whole binary is ~700 lines of Rust and carries a single reqwest::Client plus a principal tag.

┌──────────────┐   stdio    ┌──────────────┐   HTTP    ┌─────────────────┐
│ Claude Code  │  JSON-RPC  │ coppice-mcp  │   :3000   │  e2b-compat     │
│  (host)      │ ────────▶  │  (bridge)    │ ───────▶  │  gateway        │
└──────────────┘            └──────────────┘           └─────────────────┘

Wiring it up

If you have the binary on the gateway host (the usual case — the gateway is root-privileged on honor), you don’t need to run coppice-mcp locally at all. Claude Code can launch it over SSH:

claude mcp add coppice -- ssh honor /usr/local/bin/coppice-mcp

For local development:

cargo install --path e2b-compat --bin coppice-mcp --locked
claude mcp add coppice -- coppice-mcp --gateway http://honor:3000

The gateway URL resolves in this order: —gateway <url>, $COPPICE_GATEWAY, then http://localhost:3000. Envd is assumed to live on the same host, port 49999; override with —envd or $COPPICE_ENVD if you’ve moved it.

The nine tools

Each tool’s input schema is published by tools/list — MCP clients pick up the exact field names from there. This table is the human summary.

ToolInputResult
coppice_sandbox_createtemplate, cpu?, memory_mb?, timeout_s?, metadata?{ id, short_id, ip, created_at }
coppice_sandbox_execid, cmd, cwd?, env?, timeout_s?{ stdout, stderr, exit_code }
coppice_sandbox_read_fileid, path{ content_base64, size }
coppice_sandbox_write_fileid, path, content_base64, mode?{ ok: true }
coppice_sandbox_run_codeid, code, lang?, context_id?{ stdout, stderr, results, error? }
coppice_sandbox_snapshotid, description?{ snapshot_id, zfs_snapshot, created_at }
coppice_sandbox_forksnapshot_id, cpu?, memory_mb?{ id, short_id, ip }
coppice_sandbox_killid{ ok: true }
coppice_sandbox_liststate_filter?, limit?[{ sandboxID, state, templateID, … }]

The first four map onto obvious REST + envd endpoints; run_code streams NDJSON frames from envd’s /execute and aggregates them into a single reply (results and error-frames preserved as arrays). snapshot / fork hit the durable-snapshot surface from #durable; both are same-host, millisecond-level ZFS operations.

The four resources

MCP also has a separate resources surface — read-only identified blobs that a host can surface as a tab or as context. The bridge exposes four:

URIContent
coppice://sandboxes/List of sandboxes scoped to this principal.
coppice://sandboxes/<id>Detail + last 200 log lines (best-effort).
coppice://templates/Available templates.
coppice://templates/<name>One template’s metadata.

MCP resource-templates (the URI-with-placeholders kind) aren’t fully wired in rmcp 1.5; we list the root URIs and let read_resource pattern-match the full path. Clients that understand resources/read with an arbitrary URI — Claude Code does — get the per-id/per-name views for free.

Principal isolation

The bridge’s one original bit of logic. Every sandbox coppice-mcp creates gets an extra metadata field:

"metadata": { "coppice_mcp_principal": "local-stdio",  }

Before serving exec, read_file, write_file, run_code, snapshot, or kill, the bridge first GET /sandboxes/:id and checks the tag. A mismatch returns a structured isError: true result that the agent can read (and ideally not retry). list filters on the same tag before returning.

For stdio (today’s only transport) the principal is the literal string local-stdio. One process, one agent, no ambiguity — we get the isolation property for free, and the tag is mostly a shape so the day we hook up HTTP+SSE the data model already distinguishes callers.

For HTTP — a v2 stretch — the plan is to derive the principal from the bearer token (a stable 12-character FNV-1a prefix, not the raw secret) so sandbox metadata stays privacy-preserving even if a gateway operator eyeballs GET /v2/sandboxes. The token loads from —auth-token, $COPPICE_MCP_TOKEN, or /etc/coppice/mcp.token in that order.

What’s supported today

What’s not (yet)

Receipt

examples/15-mcp-demo.sh spins up the binary, runs the handshake, and walks create → run_code → write_file → read_file → kill in ~5 seconds against the gateway on honor. The integration test is the same dance against a local mock — it takes 80 ms to complete, including spawning the compiled binary.