qwen-code/packages/node-repl/README.md
顾盼 be891657f7
refactor(node-repl)!: deliver the persistent Node REPL as a standalone MCP server (#9499)
* feat(core): add persistent Node REPL runtime

* fix(core): align Node REPL phase-one contract

* fix(core): align Node REPL module compatibility

* fix(core): remove unused Node REPL host broker

* refactor(node-repl)!: deliver as a standalone MCP server, revert core tools

Replaces the three built-in `packages/core` node_repl tools with a
self-contained MCP server package, `@qwen-code/node-repl-mcp`.

Why
---
Issue #9333 triage accepted the runtime only "for exploration" and gated it
on an open maintainer decision: built-in core tool vs MCP-server-first.
Reversing OpenAI Codex 0.149.0 settled the shape — it ships code mode as a
standalone host with an in-process fallback, and its real-Node `js_repl`
(not the restricted V8 `exec`) is the analogue this roadmap needs, because
stage 3 (#9335) imports cua-driver's N-API addons, which only real Node can
load. Delivering out-of-core also keeps a security-relevant subsystem out of
the maintainer-gated `packages/core` until its value is proven.

What changed
------------
- New `packages/node-repl`: the kernel, module loader, cell transform,
  protocol and kernel manager, ported and now dependency-free (local
  `debug-log`/`win-path`/`tokenizer` replace core utils; `output-adapter`
  emits MCP content blocks in place of `result-converter`).
- Reverted every `packages/core` change, the ~11 packaging files that existed
  only to ship the runtime assets into six distribution layouts, and the two
  design/plan docs describing the core delivery.
- Deleted the trusted-package/sha256 layer: it was empty and unreachable in
  production (`module-loader.mjs` 882 -> 483 lines).
- Wired the package into `scripts/build.js` and the root `vitest.config.ts`.

Net effect: `packages/core` is untouched relative to main; the tool is opt-in
via `mcpServers` instead of registered unconditionally for every user.

Correctness fixes made while porting
------------------------------------
- Stack line numbers were wrong and drifted with binding count (source line 4
  reported as 87). The prelude is now one physical line and the cell compiles
  with `lineOffset: -1`.
- Top-level `var` nested in blocks/try/switch/loops was silently dropped;
  collection now walks the statement subtree, pruning at function boundaries.
  Verified against real Node for 16 constructs.
- A binding named `nodeRepl` permanently broke the output channel; such cells
  are now rejected. Ordinary globals stay shadowable, matching plain Node.
- Errors thrown by imported modules lost their class, `code` and stack.
- A throwing frame handler could discard buffered protocol frames.
- A hoisted `var` assigned before a throw is now kept, as Node does.
- Unhandled rejections settling after a cell no longer vanish.
- Live sandbox timers are capped, so one runaway loop cannot saturate the
  session's event loop.
- Image MIME types are matched case-insensitively on both input paths.
- Binding sort no longer depends on host locale collation.
- The published bin lacked a shebang and mis-detected its entry point, and the
  server plus its kernel leaked on every host disconnect.

Tests: 146 across 14 files, including a compiled N-API addon fixture, 100
consecutive cells, 10 concurrent isolated kernels, stack-line fidelity, and
hoisting semantics. Three smoke scripts cover the adapter, the MCP wire and
process lifecycle; the packed tarball was installed into a clean project and
driven end to end.

Refs #9333

* fix(node-repl): pin zod to the hoisted 3.x so the workspace build type-checks

`packages/node-repl` declared `zod ^4.1.13`, so `npm ci` installed a nested
zod 4.4.3 for it while `@modelcontextprotocol/sdk` resolved the hoisted
zod 3.25.76. Two zod type identities in one compilation made every
`registerTool` input schema unassignable (`Type 'ZodString' is not assignable
to type 'AnySchema'`), failing `npm run build` for this workspace — and with
it the install step of every CI job that builds workspaces.

The SDK accepts `^3.25 || ^4.0`, so pin the hoisted 3.x line and drop the
now-stale nested lockfile entry. One deduped zod, no behaviour change.

This only reproduced with a lockfile-driven install; the local tree had no
nested copy, which is why the build passed locally and failed in CI.

* fix(lint): lint package .mjs node scripts with the node env

The eslint "scripts we run with node" override matched
`packages/*/scripts/**/*.js` but not `.mjs`, nor a package-root `build.mjs`.
`packages/node-repl` is `type: module`, so its `build.mjs` and `scripts/*.mjs`
were linted as browser code and `eslint .` failed with `'process' is not
defined` / `'console' is not defined` / `'setTimeout' is not defined`.

The pre-commit hook only lints `*.{js,jsx,ts,tsx}`, so `.mjs` files are not
checked locally — this only surfaced in the root CI lint step.

Add `packages/*/scripts/**/*.mjs` and `packages/*/build.mjs` to the override,
matching the existing `.js` entries. Generic, additive, no behaviour change.

* fix(node-repl): address round-3 review findings (resolution, error identity, image bound)

Triaged the bot's round-3 critical findings against the ported code and fixed
the three that were genuine defects here; each has a regression test.

- R3-5 (resolution): a symlinked `<cwd>/node_modules` — the norm under pnpm,
  monorepo hoisting and shared CI caches — was silently dropped, so the
  documented zero-config `await import('pkg')` failed with "cannot resolve from
  0 module roots" while plain Node resolved it. The implicit cwd root was
  applying a re-link self-comparison that only makes sense for registered roots
  (which carry a registration-time canonical baseline). Follow the symlink for
  the implicit root, but skip it entirely when the cwd node_modules is itself a
  registered root, so the registered root's revocation guard is not undermined.

- R3-3 (error identity): an error thrown by a host builtin inside imported code
  (e.g. `fs.readFileSync` → ENOENT) was rewrapped message-only, dropping
  `code`/`errno`/`syscall`/`cause`/`stack` — so `catch (e) { if (e.code ===
  'ENOENT') }`, a ubiquitous Node idiom, silently took the wrong branch. Carry
  those fields onto the realm-wrapped error.

- R3-6 (image bound): image-frame `mimeType` was unbounded — only `data.length`
  counted against the raw image budget — so a malformed/forged frame could
  retain a huge string that also got interpolated into a notice and tokenized.
  Bound the MIME at frame ingestion (a real image MIME is a few dozen chars).

- R3-4 is by design (the runtime is not a security boundary), but the tool
  description recommended `createRequire` without noting it is not subject to
  the process denial or module-root containment the import path enforces; the
  description now says so.

- R3-1 / R3-2 (the `.mjs` lint failure) were already fixed in an earlier commit.

Suite: 148 tests (added symlinked-cwd resolution and host-builtin error-code
regressions). The symlink fix initially defeated the registered-root revocation
test; the registered-root deferral above resolves both.

* docs(node-repl): note top-level function/class re-declaration needs a fresh name

Round-3 review R3-12/R3-33: re-declaring an existing top-level function or class
in a later cell is a link-time SyntaxError (they persist as `let`, which the
prelude re-declares), whereas a plain Node REPL accepts it. A correct fix needs
declaration-kind tracking the manager does not carry today; until then the tool
description's rerun guidance ('prefer var') is corrected, since a function cannot
be made rerunnable via var.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-23 14:20:39 +00:00

4.3 KiB

@qwen-code/node-repl-mcp

A standalone Model Context Protocol server that exposes a session-persistent Node.js REPL as three MCP tools. It runs a real Node.js kernel in a dedicated child process; top-level bindings, closures, and module state persist across calls within a session.

This package is fully independent of @qwen-code/qwen-code-core — any MCP client (Qwen Code via mcpServers, Claude, Codex, etc.) can run it.

Tools

Tool Description
node_repl Execute JavaScript. { code, timeout_ms?, title? }. Bindings persist across calls; top-level await supported.
node_repl_reset Terminate the kernel process and discard all bindings/module state.
node_repl_add_node_module_dir Register an extra node_modules directory for bare-package resolution.

Cell semantics

  • Explicit output only: nodeRepl.write(value) for text, nodeRepl.emitImage(png|jpeg|webp) for images; console.* is captured. Plain expression results are not returned.
  • nodeRepl.cwd / homeDir / tmpDir and nodeRepl.getHeapStatus() are available.
  • Top-level static import is not allowed — use dynamic await import().
  • Bare packages resolve from the session cwd node_modules plus any directory registered via node_repl_add_node_module_dir; package entrypoints use Node singleton caching. Local .js/.mjs reload on each execution.
  • Node builtins are importable except process/node:process. Use (await import('node:module')).createRequire(import.meta.url) for CommonJS or native (N-API) addons.
  • Timeout, cancellation, node_repl_reset, or a crash replaces the kernel process and discards all bindings.

Isolation note: the VM context provides lifecycle/namespace isolation, not an OS security sandbox. Imported packages and builtins run with ordinary Node.js authority and inherit the parent environment. Grant this server only in trusted contexts.

Usage

Once published, the packaged bin is the simplest entry point:

// qwen-code settings.json (or any MCP client)
{
  "mcpServers": {
    "node-repl": {
      "command": "npx",
      "args": ["-y", "@qwen-code/node-repl-mcp"],
      "cwd": "/your/workspace",
      "env": { "QWEN_NODE_REPL_ROOTS": "/extra/node_modules/parent" },
    },
  },
}

For local development against a build in this repo, point at dist/index.js:

{
  "mcpServers": {
    "node-repl": {
      "command": "node",
      "args": ["/path/to/packages/node-repl/dist/index.js"],
      "cwd": "/your/workspace",
    },
  },
}

The cwd you set is the kernel's working directory and the base for bare-package resolution (<cwd>/node_modules).

Environment:

  • QWEN_NODE_REPL_ROOTS — extra readable roots (path-list, OS delimiter).
  • QWEN_NODE_REPL_DEBUG — set truthy for stderr debug logging.

Build & test

npm run build         # tsc (tsconfig.build.json) + copy runtime assets into dist/runtime
npm run typecheck     # includes the test files
npm test              # vitest: kernel integration, N-API, 100-cell + 10-kernel scale,
                      # line fidelity, hoisting semantics, MCP surface
npm run smoke           # kernel manager + output adapter, in-process
npm run smoke:mcp       # real MCP client <-> built stdio server
npm run smoke:lifecycle # proves the kernel child is reaped on stdin EOF / signals

Run vitest from inside this package. A bare npx vitest run from the repo root executes every workspace project (tens of thousands of tests) and can exhaust the default heap.

Provenance

Ported from the Qwen Code PR #9499 Node REPL core (kernel, module loader, cell transform, protocol, security policy, kernel manager). The qwen-coupled result converter was replaced by output-adapter.ts, which emits MCP content blocks. The empty-in-production trusted-package / sha256-pinning layer was removed entirely: this runtime has no trusted-package or capability mechanism.