Skip to main content
Glama
unfallenwill

nodejs-repl-mcp

by unfallenwill
README.md
# nodejs-repl-mcp

Session-stateful Node.js REPL exposed as an [MCP](https://modelcontextprotocol.io) server, packaged as a Docker image.

Each named session keeps a **real `node:repl` context alive in its own child process**: `let`/`const` declarations, top-level `await`, required modules, and per-session npm packages persist across tool calls until the session is removed. Designed for AI agents and automation that need to *build up state* across many small evaluations — no terminal UI, no stdin scraping.

## Architecture

```
MCP client ──stdio / streamable HTTP──> MCP server (this process)
                          │  session table (in-memory)
                          ├── worker: node child process ── repl.REPLServer (own context)
                          ├── worker: node child process ── repl.REPLServer (own context)
                          └── ...
```

- **One worker process per session** — crash isolation, independent `require` cache, killable individually.
- **Native REPL semantics** — the worker drives the server's own default evaluator (`REPLServer.prototype.eval`), so `let`/`const` redeclaration, multi-line continuation (`Recoverable`), on-demand core-module loading, and top-level `await` behave exactly like the interactive `node` REPL.
- **Runtime errors** — the REPL's internal domain prints `Uncaught ...` without invoking the eval callback; the worker taps its output stream and still returns a structured error, so the session survives bugs in evaluated code.
- **Per-session workspace** — each session gets `~/.nodejs-repl/sessions/<name>` (override with `NODEJS_REPL_SESSIONS_DIR`); the worker's `cwd` and `require` resolution root live there. `install_packages` runs `npm install` into that directory only.

## Tools

| Tool | Arguments | Description |
|---|---|---|
| `create_session` | `name` | Create a persistent session (`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`) |
| `exec` | `name`, `code` | Evaluate JS in the session; syntax-incomplete input is buffered and continues on the next `exec`. Returns `{ ok, output, error?, incomplete?, console? }` |
| `history` | `name`, `limit?` | Evaluation history (inputs, outputs, errors, captured console) |
| `list_sessions` | — | All sessions with pid, workspace, liveness |
| `remove_session` | `name` | Kill the worker. Workspace dir is kept (deps/files survive) |
| `install_packages` | `name`, `packages[]`, `saveDev?` | `npm install` into that session's workspace |

## Usage

### Docker (recommended)

The prebuilt image on GHCR (multi-arch: amd64 + arm64) needs no local build. MCP stdio transport needs stdin, so run with `-i`:

```json
{
  "mcpServers": {
    "nodejs-repl": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "ghcr.io/unfallenwill/nodejs-repl-mcp:latest"],
      "volumes": ["nodejs-repl-data:/data"]
    }
  }
}
```

Or with Claude Code:

```bash
claude mcp add nodejs-repl -- docker run -i --rm -v nodejs-repl-data:/data ghcr.io/unfallenwill/nodejs-repl-mcp:latest
```

Pin a release with `:X.Y.Z` instead of `:latest` for reproducible setups.

#### Build From Source

```bash
git clone https://github.com/unfallenwill/nodejs-repl-mcp.git
cd nodejs-repl-mcp
docker build -t nodejs-repl-mcp .
```

Then use the config above with the local tag, e.g. `args: ["run", "-i", "--rm", "nodejs-repl-mcp"]`. A different Playwright version can be baked in with `--build-arg PLAYWRIGHT_VERSION=x.y.z`.

#### Releases

Pushing a tag `vX.Y.Z` triggers [.github/workflows/publish.yml](.github/workflows/publish.yml) and publishes `ghcr.io/unfallenwill/nodejs-repl-mcp:{X.Y.Z, X.Y, latest}` (pre-release tags get the exact version only):

```bash
git tag v0.1.0 && git push origin v0.1.0
```

The first push creates the package as private; flip it to public in the package's visibility settings for unauthenticated pulls.

#### Preinstalled Packages

The image ships with [Playwright](https://playwright.dev) — package, headless Chromium build, and the system libraries it needs — so browser automation works out of the box, no `install_packages` required:

```
create_session { "name": "browser" }
exec { "name": "browser", "code": "const { chromium } = require('playwright'); const b = await chromium.launch(); const p = await b.newPage(); await p.setContent('<h1>hi</h1>'); await p.textContent('h1')" }   // 'hi'
```

The preinstalled set lives at `/opt/session-deps` and is exposed to sessions via `NODE_PATH`, so packages installed with `install_packages` (session-local `node_modules`) still take precedence. Pin a different version at build time with `--build-arg PLAYWRIGHT_VERSION=x.y.z`.

### Local

```bash
npm install
npm run build
node dist/index.js          # MCP stdio server
```

```bash
claude mcp add nodejs-repl -- node /path/to/nodejs-repl-mcp/dist/index.js
```

### HTTP Transport

Serve streamable HTTP instead of stdio — for remote workbenches, shared hosts, or clients that can't spawn a child process. REPL sessions live in the server's `SessionManager`, not in the MCP connection, so state survives across independent HTTP requests even though each request is served by a fresh MCP server instance.

```bash
node dist/index.js --transport http --host 127.0.0.1 --port 3000 --token my-secret
```

| Option | Env fallback | Default | Notes |
|---|---|---|---|
| `--transport <stdio\|http>` | `MCP_TRANSPORT` | `stdio` | `stdio` keeps existing configs working unchanged |
| `--host <address>` | `MCP_HOST` | `127.0.0.1` | Loopback binds get the SDK's Host/Origin DNS-rebinding guards |
| `--port <number>` | `MCP_PORT` / `PORT` | `3000` | |
| `--token <secret>` | `MCP_TOKEN` | none | Requires `Authorization: Bearer <secret>` on every request |

With Docker (note `--host 0.0.0.0` — loopback inside the container is unreachable from outside):

```bash
docker run --rm -p 3000:3000 -v nodejs-repl-data:/data \
  ghcr.io/unfallenwill/nodejs-repl-mcp:latest \
  node dist/index.js --transport http --host 0.0.0.0 --port 3000 --token my-secret
```

Connect any streamable-HTTP MCP client to `http://127.0.0.1:3000/mcp`; with a token configured, send it as a bearer header:

```bash
claude mcp add --transport http nodejs-repl http://127.0.0.1:3000/mcp --header "Authorization: Bearer my-secret"
```

**Use `--token` whenever the server is reachable beyond loopback.** The server executes arbitrary code by design; a missing token on a non-loopback bind prints a warning at startup.

### Example session

```
create_session { "name": "analysis" }
exec { "name": "analysis", "code": "let data = [3,1,2]; data.sort()" }
exec { "name": "analysis", "code": "data" }                        // state persists: [1,2,3]
exec { "name": "analysis", "code": "const r = await fetch('https://example.com').then(r => r.status); r" }
install_packages { "name": "analysis", "packages": ["lodash@^4"] }
exec { "name": "analysis", "code": "require('lodash').chunk([1,2,3,4], 2)" }
history { "name": "analysis" }
remove_session { "name": "analysis" }
```

## Design notes

Session-state persistence for REPLs has four known approaches (see research in `.firecrawl/`):

1. **History replay** (`.save`/`.load`, Nesh) — simple, but replaying `Math.random()` or side-effecting statements yields wrong state.
2. **Runtime serialization** — impractical in JS: closures are opaque; sockets/native handles can't be serialized.
3. **Process snapshotting** (VM/CRIU, the RunKit/Tonic approach) — perfect semantics ("time travel") but heavy infrastructure.
4. **Session residency** — keep the session process alive; state persists naturally. **This is what nodejs-repl-mcp implements**: the MCP server is already a long-lived process, so the coordinator layer of designs like `node-repl-cli` collapses into the server itself, and each session is a detached worker with a real REPL context.

### Limitations

- Sessions are process state: they do not survive server restarts or container restarts (workspace directories do, via the `/data` volume).
- `exec` has no built-in timeout by design (long evaluations are legitimate). A wedged session can be killed with `remove_session`.
- Workers run with full Node.js capabilities — no sandboxing. Do not expose the server to untrusted input; for untrusted code use a container/gVM-level boundary (the `vm` module is *not* a security boundary — vm2 escape). In HTTP mode, treat the endpoint as RCE-by-design: keep it on loopback or require `--token`.

## Development

```bash
npm run build
node scripts/smoke.mjs                                  # local stdio smoke test (22 assertions)
node scripts/smoke-http.mjs                             # local streamable HTTP smoke test (22 assertions)
SMOKE_CMD=docker SMOKE_ARGS="run -i --rm nodejs-repl-mcp" node scripts/smoke.mjs   # against the container
SMOKE_PLAYWRIGHT=1 SMOKE_CMD=docker SMOKE_ARGS="run -i --rm nodejs-repl-mcp" node scripts/smoke.mjs   # + preinstalled Playwright check
SMOKE_CMD=docker SMOKE_ARGS="run --rm -p 31007:31007 -v nodejs-repl-data:/data nodejs-repl-mcp node dist/index.js --transport http --host 0.0.0.0 --port 31007 --token smoke-secret" node scripts/smoke-http.mjs   # HTTP against the container
```

The HTTP smoke test covers bearer-token rejection (401 + `WWW-Authenticate`), the JSON-RPC/SSE stateless leg, state persistence across independent requests, the SDK client leg (skipped when devDependencies are absent, e.g. inside the image), and clean SIGTERM shutdown.

TDQS

A4.3/5.0

Scored across 6 tools

Disambiguation5/5

Each tool maps to a distinct operation: session lifecycle (create/list/remove), code execution, history retrieval, and package installation. There is no meaningful overlap or risk of selecting the wrong tool.

Naming Consistency4/5

Most tools follow a clear verb_noun pattern (create_session, list_sessions, remove_session, install_packages), but exec and history are terse single-word names that break the pattern. Overall still readable and predictable.

Tool Count5/5

Six tools is a well-scoped set for managing persistent Node.js REPL sessions. Each tool serves a clear need without redundancy or bloat.

Completeness5/5

The surface covers the full lifecycle: create a session, execute code in it, inspect history, install dependencies, list sessions, and remove sessions. No obvious dead ends or missing critical operations.

Maintenance

ActivityMaintained
ResponsivenessNo issues