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": "/path/to/project/app.js",
"dapLaunchArgs": {
"env": {
"NODE_ENV": "development"
},
"args": ["--port", "3000"],
"cwd": "/path/to/project"
}
}
}Both dapLaunchArgs and adapterLaunchConfig accept launch settings. When the same key appears
in both, adapterLaunchConfig wins, including stopOnEntry. Setting it to true keeps the target
paused at entry; restart_debugging replays that intent. An honoured noDebug disables the entry pause.
Where the entry stop lands (issue #858): js-debug implements stopOnEntry with a breakpoint at line 1,
column 1 of the program, and V8 resolves that to the first breakable position in source order. For a file
whose first declaration is a function that the program calls later, that position is inside the function —
so the "entry" stop fires at the function's first call, after the top-level code before it has run (output
included), and fires again at every later call. mcp-debugger cannot move the stop, but it says so: the
stop's lastStop.text (in wait_for_stop, get_stack_trace and list_debug_sessions) explains
where it landed and why. To stop at the first statement, put a statement above the function or set a line
breakpoint on it. A function breakpoint on that first-declared function is not affected: the stop that
binds it is the function's first call, and it is reported as the function breakpoint. Reported upstream as
vscode-js-debug#2430. Node's --inspect-brk is not an alternative: js-debug strips it from runtimeArgs
into stopOnEntry, so a bare --inspect or --inspect-brk in adapterLaunchConfig.runtimeArgs is
forwarded as given and changes nothing about the entry stop.
Use envFile to load dotenv values relative to the effective cwd, then override individual values
with env. A null value removes an inherited or file-defined variable:
{
"sessionId": "session-id",
"scriptPath": "/path/to/project/app.js",
"adapterLaunchConfig": {
"cwd": "/path/to/project",
"envFile": ".env.debug",
"env": { "PORT": "3001", "LEGACY_FLAG": null },
"stopOnEntry": true
}
}Environment precedence is inherited values, then the existing NODE_ENV=development fallback,
then the file, then explicit env entries. File or explicit values for NODE_ENV and NODE_OPTIONS
take effect in the target; mcp-debugger adds its exit-code recorder after these overrides. Windows
environment keys match without regard to case. A missing file warns and continues; another read
error fails the launch. Loading a file never changes the server's own environment.
Check the response's warning for ignored, malformed or unrecognized options. For example,
sourceMapPathOverides remains forwarded but warns with the suggestion sourceMapPathOverrides;
runtimeArgs: "--inspect" warns because it needs an array. The warning names the input object and
key without quoting its value. Notices also reach get_output, appear on dry runs, and survive
preparation failures in data.warning. Each new launch clears prior notices.
The launch configuration self-debugging example demonstrates how to inspect these decisions inside a running mcp-debugger server.
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 as a console entry.
mcp-debugger compiles JavaScript logpoints itself rather than handing the logMessage to js-debug (issues #850, #861, #853): js-debug would print through the debuggee's console.log, which a program that owns its stdout may have replaced, and would turn a logpoint with a condition into a pausing breakpoint. The compiled form delivers the rendered message over js-debug's CDP proxy — strings raw, other values through util.inspect, a throwing expression as <ReferenceError: …> — logs only when the condition holds, and never pauses. A logMessage whose {expression} does not compile is refused by set_breakpoint with V8's syntax error; a condition js-debug rejects shows js-debug's message on the breakpoint in list_breakpoints. A logpoint set before launch makes js-debug stop on entry so the delivery binding is in place before the first hit; that stop is auto-continued and never reported.
{
"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.
Three ways in:
- By port — the target was started with the inspector listening
(
node --inspect=127.0.0.1:9229 server.js):attach_to_process { host, port }. This is the remote form too, against--inspect=0.0.0.0:<port>or a pod viakubectl port-forward(see attach presets). - By PID (issue #871) — the target is a plain
node server.json the same host:attach_to_process { processId }. js-debug's DAP server has no PID step (in VS Code the process picker does it), so mcp-debugger does what the picker does: it signals the process (SIGUSR1on POSIX,process._debugProcesson Windows), which makes Node open its inspector on127.0.0.1:9229, confirms through the inspector's/json/listthat the port now belongs to that PID, and attaches there. A target started with--inspect-port=<n>(orNODE_OPTIONS=--inspect-port=<n>) opens on that port instead: pass it asportalongsideprocessId. The inspector stays open after detach, so a later attach — by PID or byport: 9229— reuses it without a second signal. - By inspector URL (issue #896) —
attach_to_process { adapterConfig: { websocketAddress: "ws://127.0.0.1:9229/<uuid>" } }, thewebSocketDebuggerUrlthathttp://host:port/json/listreports. js-debug dials it directly; noportis needed, and ahost/portbeside it is ignored.continueOnAttachapplies as on a port attach, so a target started with--inspect-brkruns briefly before the post-attach pause lands.
When js-debug refuses the attach request itself (its error response names the inspector it
could not reach), the attach fails at once with that text: Failed to attach: The javascript debug adapter rejected the attach request: …. When it instead drops the DAP socket — what it
does after Could not connect to debug target — the attach fails during verification and
quotes that stderr line (issue #897).
What a PID attach refuses, by name: a non-loopback host (the signal is local); the server's
own PID; a PID that is not a Node.js process (on POSIX SIGUSR1 would terminate it, so the
executable is checked first, and a PID that cannot be identified is not signalled either); a
Node.js process started with --disable-sigusr1 (no handler is installed, so the signal would
terminate it — seen on its command line or in NODE_OPTIONS where those are readable, which
on Linux and macOS means a process of the same user; a hardened target of another user is the
one case the check cannot see); a port already held by another process's inspector or by
something that is not an inspector (pass port if the target listens elsewhere, or free it);
and, in the Docker image, a host PID — the container only reaches its own PID namespace.
Two side effects to expect in the target's own logs: the ownership check opens one short
inspector session, so the target prints Debugger attached. / Debugger ending on … once
before js-debug connects; and an inspector that was opened for an attach that then failed stays
open on its port (Node never closes it by itself) — a later attach by PID or by port reuses it.
- Browser/Chrome debugging not yet supported (Node.js via
pwa-nodeonly) - Attach by PID activates the inspector on Node's default port unless the target set
--inspect-port; two PID attaches to two different plainnodeprocesses on one host therefore need the second target started with--inspect-port=<n>andport: <n>passed - Attach pauses the target unless you pass
stopOnEntry: false. js-debug's pause lands on the next event-loop dispatch, so an idle server answers one of two ways:state: "running", pending: true(themessagenames the pending pause) and it freezes on its next request; orpausedat once on a sourceless<anonymous>frame between dispatches, withnotesaying no program frame is active andget_local_variablesempty (issue #900) — continue and send a request, or set a breakpoint. Attach to a live server you still need to use 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 - When the build is older than the source.
set_breakpointechoes the line from the.tsyou named, while js-debug binds it through the generated.js's map; with a staledist/the two disagree and every later line number is off. The server therefore looks for the compiled file behind a.ts/.tsx/.mts/.ctslocation — the nearesttsconfig.jsonthat names anoutDir(relative torootDirwhen set, then with a leadingsrc/stripped, then relative to the tsconfig directory; a per-directory config that onlyextendsthe root is walked past), the conventionaldist|build|out|libunder the nearestpackage.jsonwhen no tsconfig names an outDir, then a sibling.js— keeps the first one whose.js.maplists the source, and when that file's mtime is older than the source's (by more than 2 s) adds awarningto theset_breakpointanswer and anoteto thestart_debugging/step_*/wait_for_stop/pause_executionanswers, and toget_stack_tracefor its top frame, naming both files and their mtimes (issues #938, #951). It is mtime-only and hedged ("may be stale"): a source saved without a content change reads as newer too, and an incremental build (tsc -b,--incremental,tsc --watch) does not re-emit for an unchanged content hash, so only a full rebuild or a real edit clears that one. A launch whose program is itself a.ts(tsx, ts-node, Node's type stripping) or a runner installed undernode_modules(vitest, jest, mocha) is exempt: the sources run as they are. Before the launch nothing says how the program will run, so a pre-launch breakpoint in such a project can still warn against an olddist/. An attach to such a process is not yet exempt (the attach path does not learn the script until the/json/listtitle is carried through). Limits: tsconfigextendsis not followed, and the map must be<generated>.mapbeside the generated file (sourceMappingURLis not parsed) - 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.