Skip to main content
Glama
Rodriguespn

e2b-code-mode

by Rodriguespn
README.md
# e2b MCP Code Mode

A [Model Context Protocol](https://modelcontextprotocol.io) server that gives an agent **three tools instead of dozens** for driving the [e2b](https://e2b.dev) REST API. The agent writes a short JavaScript function that runs **inside an e2b sandbox** and calls the API through a thin `api.request()` client — looping, filtering, and chaining calls in one round-trip.

This applies Cloudflare's **[Code Mode](https://blog.cloudflare.com/code-mode-mcp/)** pattern to e2b:

- LLMs write API-calling code more reliably than long chains of individual tool calls.
- Intermediate data stays in the sandbox, out of the model's context.
- The agent discovers endpoints with `search` (the OpenAPI spec), then calls them with `execute_read`/`execute_write`.

---

## Quickstart

**Prerequisites:** Node.js ≥ 20.12, [pnpm](https://pnpm.io) (`corepack enable` provides it), and an e2b API key ([dashboard](https://e2b.dev/dashboard?tab=keys)).

```bash
pnpm install
pnpm build
pnpm start          # or: pnpm dev  (no build step)
```

The server starts a **stateless streamable-HTTP** MCP endpoint on `http://127.0.0.1:3000/mcp`. It has no API key of its own — each request carries the caller's e2b key as a bearer token.

Connect an MCP client (the key travels as `Authorization: Bearer <e2b-key>`; this repo ships a `.mcp.json`):

```json
{
  "mcpServers": {
    "e2b-code-mode": {
      "type": "http",
      "url": "http://127.0.0.1:3000/mcp",
      "headers": { "Authorization": "Bearer ${E2B_API_KEY}" }
    }
  }
}
```

Copy `.env.example` to `.env` and set `E2B_API_KEY` so `${E2B_API_KEY}` expands and the live tests can run.

**Try it** — call `execute_read` with an async arrow function:

```js
async () => {
  const res = await api.request({ method: 'GET', path: '/sandboxes' })
  return { status: res.status, running: res.data.length }
}
```

You get back e.g. `{ "status": 200, "running": 2 }` — produced by code the agent wrote, run in a sandbox that can reach only the e2b API. See [`TEST-PROMPT.md`](./TEST-PROMPT.md) for a richer, dashboard-visible demo task.

---

## The three tools

Each takes a single `code` string — an **async arrow function that returns a value**. `console.log` output comes back separately as `stdout`.

| Tool | What it does | In scope | Annotation |
| --- | --- | --- | --- |
| `search` | Query the e2b OpenAPI `spec` (all `$ref`s resolved) to find endpoints and their shapes. Network-isolated. | `spec` | read-only |
| `execute_read` | Call the e2b API via `api.request()` — **GET only**. | `api` | read-only |
| `execute_write` | Call the e2b API via `api.request()` — any method (POST/PUT/PATCH/DELETE). | `api` | destructive |

The workflow is: `search` to find an endpoint's path and request shape, then `execute_read`/`execute_write` to call it. The client is `api.request({ method, path, query?, body?, contentType?, rawBody? })`, which returns `{ status, ok, data }`.

---

## How it works

```
MCP client ──HTTP (stateless JSON)──▶ express /mcp ──▶ McpServer (3 tools)
   Bearer: e2b key                         │
                          ┌────────────────┼────────────────┐
                        search        execute_read       execute_write
                          │                └───────┬───────┘
                          ▼                        ▼
                 fresh sandbox            fresh sandbox
                 (egress DENIED)          (egress → api.e2b.dev only;
                 embeds `spec`             E2B_API_KEY injected;
                                           `api.request()` client)
```

- **Transport** — stateless JSON: a fresh `McpServer` + transport per `POST /mcp` (`sessionIdGenerator: undefined`, `enableJsonResponse: true` — no sessions, no SSE), so the server holds no protocol state and scales horizontally; `GET`/`DELETE /mcp` return 405.
- **Per call** — a fresh e2b sandbox, killed in `finally`. No SDK is installed inside it, so there's nothing to amortize with a warm pool.
- **`search` sandbox** — all egress denied; the slimmed OpenAPI spec is embedded as `spec` for the agent's read-only code to query.
- **`execute_*` sandbox** — egress limited to `api.e2b.dev`; the caller's key is injected as `E2B_API_KEY` and used by `api.request()`. The read/write split is enforced in the client: `execute_read` throws on any non-GET *before* the fetch (annotated read-only), while `execute_write` allows every method (destructive).
- **Runner** — agent code is written to a file and run with `node` (never `eval`); output returns via a result file. The code sits on its own lines in the invoke scaffold, so a trailing `// comment` can't swallow the closing tokens.

The source is small and layered by responsibility:

| File | Responsibility |
| --- | --- |
| `src/index.ts` | Entry point: loads `.env`, starts the HTTP listener, configures loopback DNS-rebinding protection. |
| `src/http.ts` | Express app: streamable-HTTP transport, bearer auth, Host/Origin validation, `/healthz`, `/debug/endpoints`. |
| `src/server.ts` | The MCP surface: the three tools, response formatting, the untrusted-data boundary. |
| `src/sandbox.ts` | Sandbox plumbing: runner construction, the two sandbox shapes, the injected `api.request()` client, output caps, the credential/admin guard. |
| `src/spec.ts` | Fetches, `$ref`-resolves, slims, and caches the e2b OpenAPI spec for `search`. |
| `src/log.ts` | pino logger + credential redaction. |

#### Security notes

Agent code is untrusted but runs inside an e2b Firecracker microVM and authenticates with the caller's *own* e2b key — so by design it can use that key against the e2b API. Beyond the sandbox isolation, per-tool egress, and read/write split above:

- **Untrusted-data boundary** — all execute output (`result`/`stdout`/`stderr`) may embed API content (logs, metadata), so it's wrapped in a `<untrusted-data-{uuid}>…</>` envelope (error path too), with the model told not to follow instructions inside. Truncation happens before wrapping, so a size cap can't sever the closing tag.
- **Credential/admin block** — paths containing `api-keys`, `access-tokens`, `teams`, or `admin` (incl. `/admin/teams/{id}/api-keys`) are refused. The path is first normalized to a fixpoint (percent-decode, re-resolve dot-segments, collapse slashes, lowercase), so encoded spellings (`/%61pi-keys`, `//access-tokens`, `/x%2F..%2Fteams`, `/TEAMS`) still match; it only over-blocks.
- **HTTP hardening** — bearer token required on `/mcp` (`401` + `WWW-Authenticate` otherwise); loopback binds get DNS-rebinding protection (Host/Origin allowlist, incl. `[::1]`); 1 MB body cap.
- **Output caps** — bounded inside the sandbox before reaching the server: runner result cap (400k chars), `head -c` reads (500k file / 20k per stream), final server truncate (100k result / 10k logs).
- **Token location (vs Cloudflare)** — Cloudflare keeps the token out of the isolate via an outbound-fetch proxy; e2b has no such hook, so `E2B_API_KEY` lives in the sandbox env (agent code can read it). Safety rests on isolation + egress — the key only reaches `api.e2b.dev` — not token secrecy.

---

## Configuration

All optional except the caller's bearer token.

| Env var | Default | Meaning |
| --- | --- | --- |
| `PORT` | `3000` | HTTP listen port |
| `HOST` | `127.0.0.1` | Bind address (loopback enables DNS-rebinding protection) |
| `E2B_TEMPLATE` | `base` | e2b template the sandboxes boot from (needs Node with global `fetch`; secure-mode compatible) |
| `E2B_EXEC_TIMEOUT_MS` | `120000` | Per-call budget for the agent's code |
| `E2B_OPENAPI_URL` | e2b infra spec | Override the OpenAPI spec URL the `search` tool loads |
| `E2B_API_KEY` | — | Only used by local tooling/tests; HTTP callers send it as the bearer token |
| `LOG_LEVEL` | `info` | `trace`/`debug`/`info`/`warn`/`error`/`fatal`/`silent` |
| `LOG_FORMAT` | `json` | `json` (structured) or `pretty` (dev) |
| `MCP_DEBUG_ENDPOINT` | `true` | Set `false` to disable `GET /debug/endpoints` |

---

## Observability

- **Structured logs** via [pino](https://getpino.io) to stderr: one line per HTTP request (method, path, status, `responseTime`, `req.id`; 4xx→warn, 5xx→error) plus per-call lifecycle lines carrying the `reqId`, redacted key, duration, and `calledEndpoints` count. Use `LOG_FORMAT=pretty` locally. Credentials are never logged.
- **`GET /debug/endpoints`** — a no-auth JSON listing of every route (method, path, auth, description), the tool names, and a small status block (version, pid, uptime, log level). No secrets; disable with `MCP_DEBUG_ENDPOINT=false`.

  ```bash
  curl -s localhost:3000/debug/endpoints | jq
  ```

---

## Testing

```bash
pnpm test
```

- **`test/server.test.ts`** — the MCP surface via an in-memory client/server pair (tool list, annotations, error shapes). No key.
- **`test/http.test.ts`** — the express app over a real ephemeral port: stateless JSON shape, the bearer-auth gate, non-Bearer rejection, token→handler plumbing, 405s, DNS-rebinding enforcement, and the `/debug/endpoints` manifest. No key.
- **`test/live.test.ts`** — end-to-end against real e2b: spec search, `execute_read` GET plus non-GET rejection, trailing-comment tolerance, error/`SyntaxError` surfacing inside the boundary, the credential/admin guard (including encoded bypasses), an `execute_write` create+delete round-trip, egress denial, and the stdout size cap. Runs only when `E2B_API_KEY` is set.