Skip to content

Latest commit

 

History

History
464 lines (388 loc) · 24.6 KB

README.md

File metadata and controls

464 lines (388 loc) · 24.6 KB

JavaScript Debugging Guide

Overview

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

Architecture

The JavaScript adapter uses a sophisticated multi-session architecture:

┌─────────────────┐
│   MCP Client    │
└────────┬────────┘
         │
┌────────▼────────┐
│ Session Manager │
└────────┬────────┘
         │
┌────────▼────────┐
│  ProxyManager   │──► Parent Session
└────────┬────────┘    (Initialization)
         │
┌────────▼────────┐
│ChildSessionMgr │──► Child Session  
└─────────────────┘    (Actual Debug Target)

Key Components

  1. Parent Session: Handles initialization and adapter setup
  2. Child Session: Created via startDebugging request for the actual Node.js process
  3. Session Adoption: Uses __pendingTargetId mechanism to adopt child sessions
  4. Command Routing: Routes commands between parent and child sessions as appropriate

Quick Start

Basic JavaScript Debugging

// 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}`);

Debug Session Example

// 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"
  }
}

Configuration

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 .js programs as well as .ts (see TypeScript Support)

Stepping and pausing inside dependencies (justMyCode, skipFiles)

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.

Custom Configuration

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.

Advanced Features

Child Process Debugging

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

Conditional Breakpoints

{
  "tool": "set_breakpoint",
  "params": {
    "sessionId": "session-id",
    "file": "app.js",
    "line": 10,
    "condition": "count > 5"
  }
}

Log Points

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}"
  }
}

Troubleshooting

Common Issues

  1. Breakpoints Not Hitting

    • Ensure file paths are correct (use absolute paths when possible)
    • Verify the code is actually executing
  2. 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
  3. 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

Debug Logging

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.

TypeScript Support

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.

Attaching to a running Node.js process

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 via kubectl port-forward (see attach presets).
  • By PID (issue #871) — the target is a plain node server.js on 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 (SIGUSR1 on POSIX, process._debugProcess on Windows), which makes Node open its inspector on 127.0.0.1:9229, confirms through the inspector's /json/list that the port now belongs to that PID, and attaches there. A target started with --inspect-port=<n> (or NODE_OPTIONS=--inspect-port=<n>) opens on that port instead: pass it as port alongside processId. The inspector stays open after detach, so a later attach — by PID or by port: 9229 — reuses it without a second signal.
  • By inspector URL (issue #896) — attach_to_process { adapterConfig: { websocketAddress: "ws://127.0.0.1:9229/<uuid>" } }, the webSocketDebuggerUrl that http://host:port/json/list reports. js-debug dials it directly; no port is needed, and a host/port beside it is ignored. continueOnAttach applies as on a port attach, so a target started with --inspect-brk runs 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.

Known Limitations

  • Browser/Chrome debugging not yet supported (Node.js via pwa-node only)
  • Attach by PID activates the inspector on Node's default port unless the target set --inspect-port; two PID attaches to two different plain node processes on one host therefore need the second target started with --inspect-port=<n> and port: <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 (the message names the pending pause) and it freezes on its next request; or paused at once on a sourceless <anonymous> frame between dispatches, with note saying no program frame is active and get_local_variables empty (issue #900) — continue and send a request, or set a breakpoint. Attach to a live server you still need to use with stopOnEntry: false
  • Some advanced DAP features may not be exposed through MCP tools
  • Source-mapped frames you cannot open. A package that ships .js.map files whose sources point at .ts files it did not ship makes js-debug report those frames with a relative label (../src/shared/protocol.ts) and a non-zero sourceReference; mcp-debugger marks them unresolvedSource: true and says so in the note (issue #655). On attach the common causes are already handled: resolveSourceMapLocations defaults to ["**", "!**/node_modules/**"] so dependency maps are not applied (those frames show their real .js path), and cwd defaults to the server's working directory because js-debug resolves no relative map source without a base path — with it, the debuggee's own dist/** maps resolve to the absolute src/**/*.ts next to them. Knobs, all via adapterConfig: sourceMaps: false (generated .js paths everywhere), resolveSourceMapLocations (globs, or null for everywhere), cwd, sourceMapPathOverrides. get_stack_trace hides node_modules and async separator frames by default; a debuggee that is itself an installed package under node_modules shows its top frame plus an "all frames are internal" note — pass includeInternals: true. A stop inside a dependency — a breakpoint, a step, a debugger; statement, or any stop whose only visible ancestors sit beyond an await/request boundary — keeps that frame as frame 0 (reported as pausedFrame, and the note says so) so get_local_variables and evaluate_expression work 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:349 is verified by js-debug under that same .ts path and line: list_breakpoints reports it verified: true with no boundFile/boundLine, and get_stack_trace frames show src/*.ts. The bound pair appears only when js-debug answers under a different file. With adapterLaunchConfig: { sourceMaps: false } the same .ts request still binds — js-debug's breakpoint predictor reads the map beside the generated file regardless — but is verified under the generated dist/x.js:277, reported as boundFile/boundLine beside the untouched request, and the frames show dist/*.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 for node_modules files) — issue #673
  • When the build is older than the source. set_breakpoint echoes the line from the .ts you named, while js-debug binds it through the generated .js's map; with a stale dist/ the two disagree and every later line number is off. The server therefore looks for the compiled file behind a .ts/.tsx/.mts/.cts location — the nearest tsconfig.json that names an outDir (relative to rootDir when set, then with a leading src/ stripped, then relative to the tsconfig directory; a per-directory config that only extends the root is walked past), the conventional dist|build|out|lib under the nearest package.json when no tsconfig names an outDir, then a sibling .js — keeps the first one whose .js.map lists the source, and when that file's mtime is older than the source's (by more than 2 s) adds a warning to the set_breakpoint answer and a note to the start_debugging/step_*/wait_for_stop/pause_execution answers, and to get_stack_trace for 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 under node_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 old dist/. An attach to such a process is not yet exempt (the attach path does not learn the script until the /json/list title is carried through). Limits: tsconfig extends is not followed, and the map must be <generated>.map beside the generated file (sourceMappingURL is not parsed)
  • Debuggee exit codes are captured via an injected preload (js-debug itself never emits a DAP exited event), so exitCode is 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 missing exitCode is never replaced with a guessed value.

Examples

See examples/javascript/ for runnable examples, including:

  • simple_test.js - Basic variable swap example
  • pause_test.js - Testing pause functionality
  • test_javascript_debug.js - Comprehensive test suite

The directory contains further examples (attach targets, function-breakpoint fixtures, and TypeScript samples such as typescript_test.ts).

Implementation Details

The JavaScript adapter uses:

  • Vendor: Microsoft's js-debug from VSCode
  • Vendor artifacts: vsDebugServer.js is the canonical vendored artifact produced by the build script. vsDebugServer.cjs is a CommonJS compatibility duplicate created alongside it. The factory's validation checks for .js (the canonical path), while runtime command construction prefers .cjs for 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.