nodejs-repl-mcp
# 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
Scored across 6 tools
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.
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.
Six tools is a well-scoped set for managing persistent Node.js REPL sessions. Each tool serves a clear need without redundancy or bloat.
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.