Session-stateful Node.js REPL exposed as an MCP 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.
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
requirecache, killable individually. - Native REPL semantics — the worker drives the server's own default evaluator (
REPLServer.prototype.eval), solet/constredeclaration, multi-line continuation (Recoverable), on-demand core-module loading, and top-levelawaitbehave exactly like the interactivenodeREPL. - 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 withNODEJS_REPL_SESSIONS_DIR); the worker'scwdandrequireresolution root live there.install_packagesrunsnpm installinto that directory only.
| 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 |
The prebuilt image on GHCR (multi-arch: amd64 + arm64) needs no local build. MCP stdio transport needs stdin, so run with -i:
{
"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:
claude mcp add nodejs-repl -- docker run -i --rm -v nodejs-repl-data:/data ghcr.io/unfallenwill/nodejs-repl-mcp:latestPin a release with :X.Y.Z instead of :latest for reproducible setups.
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.
Pushing a tag vX.Y.Z triggers .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):
git tag v0.1.0 && git push origin v0.1.0The first push creates the package as private; flip it to public in the package's visibility settings for unauthenticated pulls.
The image ships with Playwright — 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.
npm install
npm run build
node dist/index.js # MCP stdio serverclaude mcp add nodejs-repl -- node /path/to/nodejs-repl-mcp/dist/index.jsServe 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.
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):
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-secretConnect any streamable-HTTP MCP client to http://127.0.0.1:3000/mcp; with a token configured, send it as a bearer header:
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.
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" }
Session-state persistence for REPLs has four known approaches (see research in .firecrawl/):
- History replay (
.save/.load, Nesh) — simple, but replayingMath.random()or side-effecting statements yields wrong state. - Runtime serialization — impractical in JS: closures are opaque; sockets/native handles can't be serialized.
- Process snapshotting (VM/CRIU, the RunKit/Tonic approach) — perfect semantics ("time travel") but heavy infrastructure.
- 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-clicollapses into the server itself, and each session is a detached worker with a real REPL context.
- Sessions are process state: they do not survive server restarts or container restarts (workspace directories do, via the
/datavolume). exechas no built-in timeout by design (long evaluations are legitimate). A wedged session can be killed withremove_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
vmmodule is not a security boundary — vm2 escape). In HTTP mode, treat the endpoint as RCE-by-design: keep it on loopback or require--token.
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 containerThe 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.