Skip to content

Repository files navigation

nodejs-repl-mcp

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.

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:

{
  "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:latest

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

Build From Source

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 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.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 — 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

npm install
npm run build
node dist/index.js          # MCP stdio server
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.

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-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:

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

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.

About

Session-stateful Node.js REPL as an MCP server, packaged as a Docker image

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages