The JavaScript adapter provides full debugging support for Node.js applications using Microsoft's proven js-debug (pwa-node) debugger from VSCode. This includes support for:
- Node.js applications
- ES modules and CommonJS
- Child process debugging
- Multi-session debugging architecture
The JavaScript adapter uses a sophisticated multi-session architecture:
┌─────────────────┐
│ MCP Client │
└────────┬────────┘
│
┌────────▼────────┐
│ Session Manager │
└────────┬────────┘
│
┌────────▼────────┐
│ ProxyManager │──► Parent Session
└────────┬────────┘ (Initialization)
│
┌────────▼────────┐
│ChildSessionMgr │──► Child Session
└─────────────────┘ (Actual Debug Target)
- Parent Session: Handles initialization and adapter setup
- Child Session: Created via
startDebuggingrequest for the actual Node.js process - Session Adoption: Uses
__pendingTargetIdmechanism to adopt child sessions - Command Routing: Routes commands between parent and child sessions as appropriate
// example.js
function calculateSum(a, b) {
console.log(`Calculating sum of ${a} and ${b}`);
const result = a + b; // Set breakpoint here
return result;
}
const sum = calculateSum(5, 3);
console.log(`Result: ${sum}`);// 1. Create session
{
"tool": "create_debug_session",
"params": {
"language": "javascript",
"name": "JS Debug Example"
}
}
// 2. Set breakpoint
{
"tool": "set_breakpoint",
"params": {
"sessionId": "session-id",
"file": "example.js",
"line": 3
}
}
// 3. Start debugging
{
"tool": "start_debugging",
"params": {
"sessionId": "session-id",
"scriptPath": "example.js"
}
}The JavaScript adapter automatically configures:
- Runtime: Uses system Node.js or specified executable
- Console: Captures stdout/stderr
- Skipped code: Node internals and, by default,
node_modules(see below) - Source maps: On by default, for
.jsprograms as well as.ts(see TypeScript Support)
js-debug has no justMyCode key of its own; the launch transform turns the intent into two js-debug keys:
skipFiles (V8 blackboxing — node_modules is on the list while justMyCode is true, Node internals always) and
smartStep, whose stepper keeps stepping while the program is in a skipped frame — in the direction you asked
for, falling back to step-out — instead of reporting the stop. On a server every pause lands in Node internals, and
a request path enters your code by calls, not returns, so with the stepper on neither a pause nor a step_over /
step_out issued from a skipped frame lands (issue #678); a step_into still reaches the next call into your
code. justMyCode: false turns the stepper off and takes node_modules off the list. What that means in practice:
| Launch | skipFiles sent |
smartStep |
A breakpoint inside a dependency | step_over from that breakpoint |
pause_execution on an idle server |
|---|---|---|---|---|---|
default (justMyCode: true) |
<node_internals>/**, **/node_modules/** |
true |
fires; frame 0 is the dependency frame | may never land on a request path (pending: true; the message names the skipped frame, the stepper and the remedy) |
may never land (pending: true; the message names the stepper and the remedy) |
dapLaunchArgs: { justMyCode: false } |
<node_internals>/** |
false |
fires | lands on the next line of the dependency | lands as soon as any JavaScript runs (the next request or timer), in an internal frame if that is where it is (the stack response marks it). With the smart-stepper off, steps also stop in unmapped generated helpers it used to skip |
dapLaunchArgs: { skipFiles: [...] } |
exactly your list | follows justMyCode unless set |
fires | lands unless the frame is on your list and the stepper is on | may never land while the stepper is on and <node_internals>/** is on your list |
adapterLaunchConfig: { smartStep: false } (default list) |
<node_internals>/**, **/node_modules/** |
false |
fires | lands in the next frame V8 does not skip (an internals frame on a request path) | lands, in an internals frame |
A caller-supplied skipFiles replaces the default list (VS Code's launch.json semantics); include
<node_internals>/** yourself if you still want internals skipped. An explicit smartStep always wins over the
derived value. Attach defaults smartStep to false and leaves skipFiles unset (issue #513) — see
attach_to_process in the tool reference.
Why launch keeps the stepper on by default while attach turns it off (issue #687, measured on js-debug 1.112):
js-debug's V8 blackbox patterns for <node_internals>/** cover node:internal/* but not top-level builtins such
as node:url or node:events (the per-builtin patterns are built with a .js suffix the live URLs do not
carry), so with the stepper off a step_into from a dependency frame stops in those internals one line at a
time (<node_internals>/url) instead of in your handler, which the stepper reaches in one press; and step_into
an async callee in TypeScript compiled for a target below ES2017 costs four unmapped __awaiter stops before
the callee's first line. step_over across an await is unchanged either way. Those costs have no hint
mechanism, while the two pending cases in the default row name their remedy — so the default stays, and
justMyCode: false (or smartStep: false) is the switch when a pause or a step from a dependency matters more
than stepping into one.
A step whose stop is a breakpoint or an exception rather than the step itself is reported as
Stepped over; stopped on 'breakpoint' rather than on the step itself (see stopReason) — true both when the next
line carries a breakpoint and when a lost step's next request re-hit the same breakpoint; in the second case the
program is back at the line you stepped from, and the message says so.
You can provide custom DAP launch arguments:
{
"tool": "start_debugging",
"params": {
"sessionId": "session-id",
"scriptPath": "app.js",
"dapLaunchArgs": {
"env": {
"NODE_ENV": "development"
},
"args": ["--port", "3000"],
"cwd": "/path/to/project"
}
}
}The adapter can attach to child processes, but autoAttachChildProcesses defaults to false. To enable automatic child process attachment, pass it explicitly in dapLaunchArgs:
// parent.js
const { spawn } = require('child_process');
const child = spawn('node', ['child.js']);
// Debugger will only attach to child.js if autoAttachChildProcesses is set to true{
"tool": "set_breakpoint",
"params": {
"sessionId": "session-id",
"file": "app.js",
"line": 10,
"condition": "count > 5"
}
}A logMessage turns the breakpoint into a logpoint: execution does not pause — the interpolated message (expressions in {curly braces}) arrives in the session output, readable via get_output.
{
"tool": "set_breakpoint",
"params": {
"sessionId": "session-id",
"file": "app.js",
"line": 15,
"logMessage": "Value is {value}"
}
}-
Breakpoints Not Hitting
- Ensure file paths are correct (use absolute paths when possible)
- Verify the code is actually executing
-
Session Not Starting
- Check Node.js is in PATH or specify
executablePath - Ensure the script file exists
- Check for syntax errors in the JavaScript file
- Check Node.js is in PATH or specify
-
Variables Not Showing
- Wait for the debugger to pause at a breakpoint
- Use correct frame ID from stack trace
- Check scope reference from
get_scopes
Enable detailed logging to troubleshoot issues:
{
"tool": "start_debugging",
"params": {
"sessionId": "session-id",
"scriptPath": "app.js",
"dapLaunchArgs": {
"trace": true
}
}
}Note: trace is a DAP launch argument passed when starting the debug session, not a session-creation option.
The adapter has built-in TypeScript support. When the factory validates the environment, it auto-detects tsx and ts-node in both node_modules/.bin and system PATH. If a TypeScript runner is found, you can debug .ts files directly:
{
"tool": "start_debugging",
"params": {
"sessionId": "session-id",
"scriptPath": "app.ts",
"args": []
}
}Source maps are on by default for every launch, .js programs included (js-debug's own default; issue #684).
Launching a compiled TypeScript app from dist/index.js with dist/**/*.js.map beside it and src/**/*.ts on
disk therefore behaves like debugging the sources: breakpoints set in src/*.ts bind and verify under their own
path, and get_stack_trace, get_local_variables, evaluate_expression, get_source_context and every step
location report src/*.ts lines. The default outFiles is **/*.(m|c|)js excluding node_modules, resolved
against the workspace root js-debug is given — the nearest directory above the program with a package.json,
else the program's directory (adapterLaunchConfig: { __workspaceFolder } overrides it; outFiles: [] opts out
of the pre-launch breakpoint scan) — and
resolveSourceMapLocations excludes node_modules too, so dependency maps are not applied. A program without
maps is unaffected. A map whose sources are not on disk yields frames flagged unresolvedSource (issue #655).
To see generated locations instead, pass adapterLaunchConfig: { sourceMaps: false }; outFiles you pass with it
is forwarded untouched. A breakpoint you set in a generated file still binds and fires with maps on; js-debug then
reports that one frame at its generated location while the frames below it map to their sources.
If neither tsx nor ts-node is installed, the factory emits a warning (not an error), and you can still debug compiled .js files with source maps.
- Browser/Chrome debugging not yet supported (Node.js via
pwa-nodeonly) - Remote attach works over
host/portagainst anode --inspect=0.0.0.0:<port>target, including pods viakubectl port-forward(see attach presets); the target must be started with the inspector enabled, which mcp-debugger cannot do for you - Attach pauses the target unless you pass
stopOnEntry: false. js-debug's pause lands on the next event-loop dispatch, so an idle server answersstate: "running", pending: true(themessagenames the pending pause) and freezes on its next request — attach to a live server withstopOnEntry: false - Some advanced DAP features may not be exposed through MCP tools
- Source-mapped frames you cannot open. A package that ships
.js.mapfiles whosesourcespoint at.tsfiles it did not ship makes js-debug report those frames with a relative label (../src/shared/protocol.ts) and a non-zerosourceReference; mcp-debugger marks themunresolvedSource: trueand says so in thenote(issue #655). On attach the common causes are already handled:resolveSourceMapLocationsdefaults to["**", "!**/node_modules/**"]so dependency maps are not applied (those frames show their real.jspath), andcwddefaults to the server's working directory because js-debug resolves no relative map source without a base path — with it, the debuggee's owndist/**maps resolve to the absolutesrc/**/*.tsnext to them. Knobs, all viaadapterConfig:sourceMaps: false(generated.jspaths everywhere),resolveSourceMapLocations(globs, ornullfor everywhere),cwd,sourceMapPathOverrides.get_stack_tracehidesnode_modulesand async separator frames by default; a debuggee that is itself an installed package undernode_modulesshows its top frame plus an "all frames are internal" note — passincludeInternals: true. A stop inside a dependency — a breakpoint, a step, adebugger;statement, or any stop whose only visible ancestors sit beyond anawait/request boundary — keeps that frame as frame 0 (reported aspausedFrame, and thenotesays so) soget_local_variablesandevaluate_expressionwork where the program stopped (issue #672) - Where a source-mapped breakpoint is reported bound. With maps on (the
default), a launch breakpoint set on
src/x.ts:349is verified by js-debug under that same.tspath and line:list_breakpointsreports itverified: truewith noboundFile/boundLine, andget_stack_traceframes showsrc/*.ts. The bound pair appears only when js-debug answers under a different file. WithadapterLaunchConfig: { sourceMaps: false }the same.tsrequest still binds — js-debug's breakpoint predictor reads the map beside the generated file regardless — but is verified under the generateddist/x.js:277, reported asboundFile/boundLinebeside the untouched request, and the frames showdist/*.js; that is the shape issue #673 measured before maps were on by default (#684, #700). A breakpoint the program stops on is reported verified from that stop even when js-debug never sent a verification for it (observed fornode_modulesfiles) — issue #673 - Debuggee exit codes are captured via an injected preload (js-debug itself
never emits a DAP
exitedevent), soexitCodeis unavailable in two cases: attach mode (the target's environment is not under mcp-debugger's control) and signal-killed debuggees (process.on('exit')never runs). A missingexitCodeis never replaced with a guessed value.
See examples/javascript/ for runnable examples, including:
simple_test.js- Basic variable swap examplepause_test.js- Testing pause functionalitytest_javascript_debug.js- Comprehensive test suite
The directory contains further examples (attach targets, function-breakpoint fixtures, and TypeScript samples such as typescript_test.ts).
The JavaScript adapter uses:
- Vendor: Microsoft's
js-debugfrom VSCode - Vendor artifacts:
vsDebugServer.jsis the canonical vendored artifact produced by the build script.vsDebugServer.cjsis a CommonJS compatibility duplicate created alongside it. The factory's validation checks for.js(the canonical path), while runtime command construction prefers.cjsfor CommonJS child-process compatibility - Protocol: Debug Adapter Protocol (DAP)
- Transport: TCP for DAP communication between the proxy and the js-debug adapter process
- Version: The package requires Node.js 22+ (per the engines field); the factory checks >= 14 as a lower-bound runtime guard
For adapter development details, see the Adapter Development Guide.