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.
| Tool | Input | Result |
|---|---|---|
coppice_sandbox_create | template, cpu?, memory_mb?, timeout_s?, metadata? | { id, short_id, ip, created_at } |
coppice_sandbox_exec | id, cmd, cwd?, env?, timeout_s? | { stdout, stderr, exit_code } |
coppice_sandbox_read_file | id, path | { content_base64, size } |
coppice_sandbox_write_file | id, path, content_base64, mode? | { ok: true } |
coppice_sandbox_run_code | id, code, lang?, context_id? | { stdout, stderr, results, error? } |
coppice_sandbox_snapshot | id, description? | { snapshot_id, zfs_snapshot, created_at } |
coppice_sandbox_fork | snapshot_id, cpu?, memory_mb? | { id, short_id, ip } |
coppice_sandbox_kill | id | { ok: true } |
coppice_sandbox_list | state_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:
| URI | Content |
|---|---|
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
- Transport: stdio. The primary surface every MCP client speaks.
- Tools: 9, all listed above, all principal-checked.
- Resources: 4, principal-scoped for sandboxes, wide open for templates.
- Handshake:
initializereports protocol version2024-11-05, capabilitiestools + resources. Verified against the MCP Inspector and a hand-rolled JSON-RPC rig (examples/15-mcp-demo.sh). - Tests: 8 unit tests (principal logic, token hashing, port-swap)
plus a full JSON-RPC roundtrip against a mock gateway in
e2b-compat/tests/mcp.rs.
What’s not (yet)
- HTTP + SSE transport. The flag is parsed (
—transport http —http-listen …); the handler refuses with an explanatory error. The auth story we want there (bearer → principal derivation) is sketched above but not wired; the point of shipping v1 stdio-only is that the one-user-per-process model sidesteps the auth question entirely. - Per-principal quotas. The gateway has no rate limiter today — principal isolation gives you ownership, not a budget. parity-gaps tracks the durable enforcement work.
- Resource subscriptions. Clients that want
notifications/resources/updatedon a sandbox state change currently see nothing. The gateway emits the span already (B1 tracing); wiring it through the MCP session is a v2 item.
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.