nodejs-debug-mcp
Version:
Model Context Protocol server exposing a debug-script tool for Node.js inspector sessions.
334 lines (333 loc) • 11.6 kB
JavaScript
import { fileURLToPath } from 'node:url';
export const PROCESS_EXIT_ERROR = 'Process exited before breakpoint was hit';
export function createContent(message) {
if (!message) {
return [];
}
return [{ type: 'text', text: message }];
}
export class BreakpointEvaluationSession {
constructor(args, child, client, options) {
this.state = 'waiting-for-runtime';
this.evaluations = [];
this.scriptIdToUrl = new Map();
this.timeoutId = null;
this.resolvePromise = null;
this.listenersAttached = false;
this.removePausedListener = null;
this.handleProcessTermination = () => {
this.finishOnProcessTermination();
};
this.handleScriptParsed = (event) => {
if (!event || typeof event !== 'object') {
return;
}
const { scriptId, url } = event;
if (typeof scriptId === 'string' && typeof url === 'string' && url.trim()) {
this.scriptIdToUrl.set(scriptId, url);
}
};
this.handleExecutionContextDestroyed = () => {
if (this.isSettled()) {
return;
}
if (this.state === 'waiting-for-runtime') {
this.settleWithProcessExitError();
return;
}
this.finishOnProcessTermination();
};
this.handleRuntimeReady = () => {
if (this.isSettled()) {
return;
}
if (this.state === 'waiting-for-runtime') {
this.state = 'runtime-ready';
}
if (this.childExited()) {
this.settleWithProcessExitError();
}
};
this.handleRuntimeError = (error) => {
if (this.isSettled()) {
return;
}
const message = error instanceof Error ? error.message : String(error);
this.settleWithError(message);
};
this.handlePaused = async (event) => {
if (this.isSettled()) {
return;
}
if (this.options.breakpointId && !event.hitBreakpoints?.includes(this.options.breakpointId)) {
await this.Debugger.resume();
return;
}
const topFrame = event.callFrames[0];
const frameUrl = topFrame?.url;
const location = topFrame?.location;
if (frameUrl && frameUrl !== this.options.targetUrl) {
await this.Debugger.resume();
return;
}
if (location && location.lineNumber !== this.options.targetLineNumber) {
await this.Debugger.resume();
return;
}
const callFrameId = topFrame?.callFrameId;
if (!callFrameId) {
this.settleWithProcessExitError();
return;
}
const stack = this.args.includeStack ? this.createStack(event.callFrames) : undefined;
try {
const evaluation = await evaluateExpression(this.Debugger, callFrameId, this.args.expression);
const result = stack ? { ...evaluation, stack } : evaluation;
this.recordEvaluation(result);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.settleWithError(message);
return;
}
if (this.isSettled()) {
return;
}
try {
await this.Debugger.resume();
}
catch (resumeError) {
if (this.hasEvaluations()) {
this.finishOnProcessTermination();
return;
}
const message = resumeError instanceof Error ? resumeError.message : String(resumeError);
this.settleWithError(message);
}
};
this.child = child;
this.args = args;
this.options = options;
this.client = client;
this.Debugger = client.Debugger;
this.Runtime = client.Runtime;
if (options.targetScriptId) {
this.scriptIdToUrl.set(options.targetScriptId, options.targetUrl);
}
}
start() {
return new Promise((resolve) => {
this.resolvePromise = resolve;
this.attachListeners();
this.startTimeout();
if (this.childExited()) {
this.settleWithProcessExitError();
return;
}
void this.Runtime.runIfWaitingForDebugger()
.then(this.handleRuntimeReady)
.catch(this.handleRuntimeError);
});
}
attachListeners() {
if (this.listenersAttached) {
return;
}
this.removePausedListener = this.Debugger.on('paused', this.handlePaused);
this.child.on('exit', this.handleProcessTermination);
this.child.on('close', this.handleProcessTermination);
this.client.on('disconnect', this.handleProcessTermination);
this.client.on('Runtime.executionContextDestroyed', this.handleExecutionContextDestroyed);
this.client.on('Debugger.scriptParsed', this.handleScriptParsed);
this.listenersAttached = true;
}
detachListeners() {
if (!this.listenersAttached) {
return;
}
this.child.off('exit', this.handleProcessTermination);
this.child.off('close', this.handleProcessTermination);
this.client.removeListener('disconnect', this.handleProcessTermination);
this.client.removeListener('Runtime.executionContextDestroyed', this.handleExecutionContextDestroyed);
this.client.removeListener('Debugger.scriptParsed', this.handleScriptParsed);
if (this.removePausedListener) {
this.removePausedListener();
this.removePausedListener = null;
}
this.listenersAttached = false;
}
startTimeout() {
this.clearTimeout();
this.timeoutId = setTimeout(() => {
this.settleWithError(`Timeout waiting for breakpoint after ${this.args.timeout}ms`);
}, this.args.timeout);
}
clearTimeout() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
}
childExited() {
return this.child.exitCode !== null || this.child.signalCode !== null;
}
isSettled() {
return this.state === 'completed' || this.state === 'errored';
}
recordEvaluation(evaluation) {
if (!this.isSettled()) {
this.evaluations.push(evaluation);
}
}
hasEvaluations() {
return this.evaluations.length > 0;
}
finishOnProcessTermination() {
if (this.isSettled()) {
return;
}
if (!this.hasEvaluations()) {
this.settleWithProcessExitError();
return;
}
this.settle({
content: createContent(),
structuredContent: { results: this.evaluations },
}, 'completed');
}
settleWithProcessExitError() {
this.settle({
content: createContent(PROCESS_EXIT_ERROR),
structuredContent: { error: PROCESS_EXIT_ERROR },
isError: true,
}, 'errored');
}
settleWithError(message) {
this.settle({
content: createContent(message),
structuredContent: { error: message },
isError: true,
}, 'errored');
}
settle(result, nextState) {
if (this.isSettled()) {
return;
}
this.state = nextState;
this.clearTimeout();
this.detachListeners();
const resolve = this.resolvePromise;
this.resolvePromise = null;
if (resolve) {
resolve(result);
}
}
createStack(callFrames) {
return callFrames.map((callFrame) => {
const frame = {};
frame.function = callFrame.functionName ?? '';
const url = this.resolveCallFrameUrl(callFrame);
if (url) {
frame.file = this.normalizeFilePath(url);
}
if (callFrame.location) {
const { lineNumber, columnNumber } = callFrame.location;
frame.line = lineNumber + 1;
frame.column = columnNumber + 1;
}
return frame;
});
}
resolveCallFrameUrl(callFrame) {
if (callFrame.url && callFrame.url.trim()) {
return callFrame.url;
}
const scriptId = callFrame.location?.scriptId;
if (scriptId && scriptId === this.options.targetScriptId) {
return this.options.targetUrl;
}
if (scriptId) {
const mapped = this.scriptIdToUrl.get(scriptId);
if (mapped) {
return mapped;
}
}
return undefined;
}
normalizeFilePath(url) {
if (url.startsWith('file://')) {
try {
return fileURLToPath(url);
}
catch {
return url;
}
}
return url;
}
}
async function evaluateExpression(Debugger, callFrameId, expression) {
const rawResult = await Debugger.evaluateOnCallFrame({
callFrameId,
expression,
returnByValue: true,
silent: true,
});
let type = rawResult.result.type ?? 'undefined';
let value = rawResult.result.value;
const hasObjectId = rawResult.result.objectId !== undefined;
if (!rawResult.exceptionDetails) {
if (rawResult.result.subtype === 'null') {
type = 'null';
value ?? (value = null);
}
else if (rawResult.result.subtype === 'array') {
type = 'array';
}
if (value === undefined && rawResult.result.description !== undefined && !hasObjectId) {
value = rawResult.result.description;
}
}
const needsSerialization = rawResult.exceptionDetails !== undefined ||
hasObjectId ||
value === undefined ||
type === 'object';
if (needsSerialization) {
const wrappedExpression = `(function () { try { return JSON.stringify(${expression}); } catch (error) { return undefined; } })()`;
const stringifyResult = await Debugger.evaluateOnCallFrame({
callFrameId,
expression: wrappedExpression,
returnByValue: true,
silent: true,
});
if (!stringifyResult.exceptionDetails && stringifyResult.result.value !== undefined) {
const serialized = stringifyResult.result.value;
if (typeof serialized === 'string') {
try {
value = JSON.parse(serialized);
}
catch {
value = serialized;
}
}
else {
value = serialized;
}
}
}
if (rawResult.exceptionDetails) {
type = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
}
else if (value !== undefined) {
if (Array.isArray(value)) {
type = 'array';
}
else if (value === null) {
type = 'null';
}
else if (typeof value !== 'object') {
type = typeof value;
}
}
return { type, value };
}