UNPKG

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