dap-mcp-server 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/SKILL.md +175 -28
- package/TODO.md +32 -0
- package/build/adapters/builtin.js +27 -3
- package/build/adapters/builtin.js.map +1 -1
- package/build/dap/dap-client.d.ts +71 -4
- package/build/dap/dap-client.js +324 -53
- package/build/dap/dap-client.js.map +1 -1
- package/build/dap/types.d.ts +29 -7
- package/build/server.js +229 -34
- package/build/server.js.map +1 -1
- package/build/session/inspect.d.ts +64 -0
- package/build/session/inspect.js +116 -0
- package/build/session/inspect.js.map +1 -0
- package/build/session/run.d.ts +52 -0
- package/build/session/run.js +111 -0
- package/build/session/run.js.map +1 -0
- package/build/session/session.d.ts +8 -9
- package/build/session/session.js +67 -9
- package/build/session/session.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/builtin.ts +28 -3
- package/src/adapters/registry.test.ts +3 -2
- package/src/dap/dap-client.test.ts +31 -0
- package/src/dap/dap-client.ts +402 -52
- package/src/dap/types.ts +30 -7
- package/src/integration/debug-lifecycle.test.ts +21 -0
- package/src/server.ts +254 -34
- package/src/session/inspect.test.ts +126 -0
- package/src/session/inspect.ts +160 -0
- package/src/session/run.test.ts +74 -0
- package/src/session/run.ts +144 -0
- package/src/session/session.test.ts +42 -0
- package/src/session/session.ts +68 -21
- package/src/test-fixtures/mock-adapter.js +23 -1
- package/src/test-fixtures/no-stop-adapter.js +77 -0
- package/src/test-fixtures/tcp-mock-adapter.js +87 -0
|
@@ -84,4 +84,35 @@ describe("DAPClient", () => {
|
|
|
84
84
|
client.sendRequest("initialize", { adapterID: "test" }, 500)
|
|
85
85
|
).rejects.toThrow(/timeout/i);
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
it("speaks DAP over TCP when configured", async () => {
|
|
89
|
+
client = new DAPClient(
|
|
90
|
+
"node",
|
|
91
|
+
path.join(__dirname, "../test-fixtures/tcp-mock-adapter.js"),
|
|
92
|
+
[],
|
|
93
|
+
{
|
|
94
|
+
transport: "tcp",
|
|
95
|
+
portMatcher: "DAP server listening at:\\s*\\S+:(\\d+)",
|
|
96
|
+
tcpConnectTimeout: 3000,
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
await client.spawn();
|
|
100
|
+
const response = await client.sendRequest("initialize", {
|
|
101
|
+
adapterID: "test",
|
|
102
|
+
linesStartAt1: true,
|
|
103
|
+
columnsStartAt1: true,
|
|
104
|
+
pathFormat: "path",
|
|
105
|
+
});
|
|
106
|
+
expect(response.success).toBe(true);
|
|
107
|
+
expect(response.command).toBe("initialize");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("rejects TCP transport without a portMatcher", () => {
|
|
111
|
+
expect(
|
|
112
|
+
() =>
|
|
113
|
+
new DAPClient("node", "irrelevant.js", [], {
|
|
114
|
+
transport: "tcp",
|
|
115
|
+
})
|
|
116
|
+
).toThrow(DAPClientError);
|
|
117
|
+
});
|
|
87
118
|
});
|
package/src/dap/dap-client.ts
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
|
|
23
23
|
import { spawn, ChildProcess } from "node:child_process";
|
|
24
24
|
import { EventEmitter } from "node:events";
|
|
25
|
+
import { Socket, createConnection } from "node:net";
|
|
26
|
+
import type { Writable } from "node:stream";
|
|
25
27
|
import { DebugProtocol } from "@vscode/debugprotocol";
|
|
26
28
|
import { encodeMessage, MessageParser } from "./protocol.js";
|
|
27
29
|
|
|
@@ -38,23 +40,95 @@ interface PendingRequest {
|
|
|
38
40
|
timer?: ReturnType<typeof setTimeout>;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/**
|
|
44
|
+
* One DAP transport connection. Multiple connections can exist when a
|
|
45
|
+
* "parent" session (e.g. vscode-js-debug) spawns child sessions via the
|
|
46
|
+
* `startDebugging` reverse request. Each connection has its own request
|
|
47
|
+
* sequence counter and pending-request map; reading is via its parser.
|
|
48
|
+
*/
|
|
49
|
+
interface Conn {
|
|
50
|
+
/** "parent" = the initial connection. "child" = spawned via startDebugging. */
|
|
51
|
+
kind: "parent" | "child";
|
|
52
|
+
/** DAP-message-framed write target. For stdio, the child stdin; for tcp, the socket. */
|
|
53
|
+
writeStream: Writable;
|
|
54
|
+
parser: MessageParser;
|
|
55
|
+
pending: Map<number, PendingRequest>;
|
|
56
|
+
seq: number;
|
|
57
|
+
/** Present only for TCP connections. */
|
|
58
|
+
socket?: Socket;
|
|
59
|
+
/** True once the underlying transport is closed. */
|
|
60
|
+
closed: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DAPClientOptions {
|
|
64
|
+
/** "stdio" (default) or "tcp". */
|
|
65
|
+
transport?: "stdio" | "tcp";
|
|
66
|
+
/**
|
|
67
|
+
* Regex with one capture group that matches the TCP port the adapter is
|
|
68
|
+
* listening on. Required when transport is "tcp". The regex is applied to
|
|
69
|
+
* the cumulative adapter stdout text.
|
|
70
|
+
*/
|
|
71
|
+
portMatcher?: string;
|
|
72
|
+
/** Timeout waiting for the adapter to announce its TCP port, in ms. */
|
|
73
|
+
tcpConnectTimeout?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
41
76
|
export class DAPClient extends EventEmitter {
|
|
42
77
|
private runtime: string;
|
|
43
78
|
private executable: string;
|
|
44
79
|
private args: string[];
|
|
80
|
+
private transport: "stdio" | "tcp";
|
|
81
|
+
private portMatcher?: RegExp;
|
|
82
|
+
private tcpConnectTimeout: number;
|
|
45
83
|
private process?: ChildProcess;
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
private
|
|
84
|
+
private conns: Conn[] = [];
|
|
85
|
+
/** Index into `conns` of the connection that handles user-sent requests. */
|
|
86
|
+
private activeIdx = -1;
|
|
87
|
+
/** TCP target host (set during spawn for tcp mode). */
|
|
88
|
+
private tcpHost?: string;
|
|
89
|
+
/** TCP target port (set during spawn for tcp mode). */
|
|
90
|
+
private tcpPort?: number;
|
|
91
|
+
/**
|
|
92
|
+
* While a startDebugging handler is bootstrapping a new child session
|
|
93
|
+
* (initialize + launch), user-issued `sendRequest` calls await this so they
|
|
94
|
+
* don't race the in-progress handshake.
|
|
95
|
+
*/
|
|
96
|
+
private transitionLock?: Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* Last setBreakpoints arguments per source path. Replayed onto new child
|
|
99
|
+
* sessions when startDebugging causes the active connection to change —
|
|
100
|
+
* adapters like vscode-js-debug spawn a child for the actual debuggee
|
|
101
|
+
* AFTER the parent has accepted setBreakpoints, so the child wouldn't know
|
|
102
|
+
* about them otherwise.
|
|
103
|
+
*/
|
|
104
|
+
private bpHistory: Map<string, Record<string, unknown>> = new Map();
|
|
105
|
+
private excBpHistory?: Record<string, unknown>;
|
|
106
|
+
private configurationDoneSent = false;
|
|
49
107
|
private stderrOutput: string[] = [];
|
|
108
|
+
private stdoutPreamble = "";
|
|
50
109
|
private _exited = false;
|
|
51
110
|
private _exitCode?: number;
|
|
52
111
|
|
|
53
|
-
constructor(
|
|
112
|
+
constructor(
|
|
113
|
+
runtime: string,
|
|
114
|
+
executable: string,
|
|
115
|
+
args: string[] = [],
|
|
116
|
+
options: DAPClientOptions = {}
|
|
117
|
+
) {
|
|
54
118
|
super();
|
|
55
119
|
this.runtime = runtime;
|
|
56
120
|
this.executable = executable;
|
|
57
121
|
this.args = args;
|
|
122
|
+
this.transport = options.transport ?? "stdio";
|
|
123
|
+
if (this.transport === "tcp") {
|
|
124
|
+
if (!options.portMatcher) {
|
|
125
|
+
throw new DAPClientError(
|
|
126
|
+
"portMatcher is required when transport is 'tcp'"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
this.portMatcher = new RegExp(options.portMatcher);
|
|
130
|
+
}
|
|
131
|
+
this.tcpConnectTimeout = options.tcpConnectTimeout ?? 10000;
|
|
58
132
|
}
|
|
59
133
|
|
|
60
134
|
async spawn(): Promise<void> {
|
|
@@ -73,12 +147,6 @@ export class DAPClient extends EventEmitter {
|
|
|
73
147
|
// even if the adapter is still running (cleanup happens via dispose()).
|
|
74
148
|
this.process.unref();
|
|
75
149
|
|
|
76
|
-
this.parser = new MessageParser((msg) => this.handleMessage(msg));
|
|
77
|
-
|
|
78
|
-
this.process.stdout!.on("data", (data: Buffer) => {
|
|
79
|
-
this.parser!.feed(data);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
150
|
this.process.stderr!.on("data", (data: Buffer) => {
|
|
83
151
|
this.stderrOutput.push(data.toString());
|
|
84
152
|
});
|
|
@@ -95,29 +163,167 @@ export class DAPClient extends EventEmitter {
|
|
|
95
163
|
this.process.on("exit", (code, signal) => {
|
|
96
164
|
this._exited = true;
|
|
97
165
|
this._exitCode = code ?? undefined;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
req.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
166
|
+
// Reject pending across all connections.
|
|
167
|
+
for (const conn of this.conns) {
|
|
168
|
+
for (const [, req] of conn.pending) {
|
|
169
|
+
if (req.timer) clearTimeout(req.timer);
|
|
170
|
+
req.reject(
|
|
171
|
+
new DAPClientError(
|
|
172
|
+
`Adapter exited (code=${code}, signal=${signal}). stderr: ${this.stderrOutput.join("")}`
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
conn.pending.clear();
|
|
105
177
|
}
|
|
106
|
-
this.pending.clear();
|
|
107
178
|
this.emit("exit", code, signal);
|
|
108
179
|
});
|
|
109
180
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
181
|
+
if (this.transport === "stdio") {
|
|
182
|
+
// DAP messages flow over the adapter's stdin/stdout. Single connection
|
|
183
|
+
// (no startDebugging support over stdio).
|
|
184
|
+
const conn = this.makeConn("parent", this.process.stdin!, this.process.stdout!);
|
|
185
|
+
this.conns.push(conn);
|
|
186
|
+
this.activeIdx = 0;
|
|
187
|
+
// Resolve once the process is spawned (use flag to prevent double-resolve)
|
|
188
|
+
let resolved = false;
|
|
189
|
+
this.process.stdout!.once("readable", () => {
|
|
190
|
+
if (!resolved) { resolved = true; resolve(); }
|
|
191
|
+
});
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
if (!resolved) { resolved = true; resolve(); }
|
|
194
|
+
}, 100);
|
|
195
|
+
} else {
|
|
196
|
+
// TCP transport: read the adapter's stdout banner to discover the
|
|
197
|
+
// port, then connect via TCP and route DAP messages over the socket.
|
|
198
|
+
let portFound = false;
|
|
199
|
+
const connectTimer = setTimeout(() => {
|
|
200
|
+
if (!portFound) {
|
|
201
|
+
reject(
|
|
202
|
+
new DAPClientError(
|
|
203
|
+
`Timed out waiting for adapter to announce TCP port. ` +
|
|
204
|
+
`Pattern: ${this.portMatcher!.source}. ` +
|
|
205
|
+
`Stdout so far: ${this.stdoutPreamble.slice(0, 500)}`
|
|
206
|
+
)
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}, this.tcpConnectTimeout);
|
|
210
|
+
|
|
211
|
+
this.process.stdout!.on("data", (data: Buffer) => {
|
|
212
|
+
this.stdoutPreamble += data.toString();
|
|
213
|
+
if (portFound) return;
|
|
214
|
+
const m = this.portMatcher!.exec(this.stdoutPreamble);
|
|
215
|
+
if (m) {
|
|
216
|
+
portFound = true;
|
|
217
|
+
clearTimeout(connectTimer);
|
|
218
|
+
const port = Number(m[1]);
|
|
219
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
220
|
+
reject(
|
|
221
|
+
new DAPClientError(
|
|
222
|
+
`Invalid port matched from adapter stdout: '${m[1]}'`
|
|
223
|
+
)
|
|
224
|
+
);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
this.tcpHost = "127.0.0.1";
|
|
228
|
+
this.tcpPort = port;
|
|
229
|
+
this.openTcpConnection("parent")
|
|
230
|
+
.then((conn) => {
|
|
231
|
+
this.conns.push(conn);
|
|
232
|
+
this.activeIdx = 0;
|
|
233
|
+
resolve();
|
|
234
|
+
})
|
|
235
|
+
.catch(reject);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Record session-shaping requests so we can replay them on child sessions.
|
|
244
|
+
* `setBreakpoints` and `setExceptionBreakpoints` need to be re-sent because
|
|
245
|
+
* each DAP session has its own breakpoint state. `configurationDone` is
|
|
246
|
+
* tracked so we know whether the parent has already finished its
|
|
247
|
+
* configuration phase — if so, child sessions need the same signal.
|
|
248
|
+
*/
|
|
249
|
+
private trackOutgoing(command: string, args: Record<string, unknown> | undefined): void {
|
|
250
|
+
if (command === "setBreakpoints" && args) {
|
|
251
|
+
const src = (args.source as { path?: string } | undefined)?.path;
|
|
252
|
+
if (src) {
|
|
253
|
+
this.bpHistory.set(src, args);
|
|
254
|
+
}
|
|
255
|
+
} else if (command === "setExceptionBreakpoints" && args) {
|
|
256
|
+
this.excBpHistory = args;
|
|
257
|
+
} else if (command === "configurationDone") {
|
|
258
|
+
this.configurationDoneSent = true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Build a Conn around a writable/readable stream pair. */
|
|
263
|
+
private makeConn(kind: "parent" | "child", writeStream: Writable, dataStream: NodeJS.ReadableStream): Conn {
|
|
264
|
+
const conn: Conn = {
|
|
265
|
+
kind,
|
|
266
|
+
writeStream,
|
|
267
|
+
parser: new MessageParser((msg) => this.handleMessage(msg, conn)),
|
|
268
|
+
pending: new Map(),
|
|
269
|
+
seq: 1,
|
|
270
|
+
closed: false,
|
|
271
|
+
};
|
|
272
|
+
dataStream.on("data", (data: Buffer) => conn.parser.feed(data));
|
|
273
|
+
return conn;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Open a fresh TCP connection to the adapter's DAP server. */
|
|
277
|
+
private openTcpConnection(kind: "parent" | "child"): Promise<Conn> {
|
|
278
|
+
return new Promise<Conn>((resolve, reject) => {
|
|
279
|
+
if (this.tcpHost === undefined || this.tcpPort === undefined) {
|
|
280
|
+
reject(new DAPClientError("TCP target host/port not set"));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const sock = createConnection({ host: this.tcpHost, port: this.tcpPort }, () => {
|
|
284
|
+
const conn = this.makeConn(kind, sock, sock);
|
|
285
|
+
conn.socket = sock;
|
|
286
|
+
sock.on("close", () => this.onConnClose(conn));
|
|
287
|
+
sock.on("error", (err) => {
|
|
288
|
+
this.stderrOutput.push(`socket error (${kind}): ${err.message}\n`);
|
|
289
|
+
});
|
|
290
|
+
resolve(conn);
|
|
291
|
+
});
|
|
292
|
+
sock.once("error", (err) => {
|
|
293
|
+
reject(
|
|
294
|
+
new DAPClientError(
|
|
295
|
+
`Failed to connect to adapter TCP port ${this.tcpPort}: ${err.message}`
|
|
296
|
+
)
|
|
297
|
+
);
|
|
114
298
|
});
|
|
115
|
-
setTimeout(() => {
|
|
116
|
-
if (!resolved) { resolved = true; resolve(); }
|
|
117
|
-
}, 100);
|
|
118
299
|
});
|
|
119
300
|
}
|
|
120
301
|
|
|
302
|
+
private onConnClose(conn: Conn): void {
|
|
303
|
+
if (conn.closed) return;
|
|
304
|
+
conn.closed = true;
|
|
305
|
+
for (const [, req] of conn.pending) {
|
|
306
|
+
if (req.timer) clearTimeout(req.timer);
|
|
307
|
+
req.reject(
|
|
308
|
+
new DAPClientError(
|
|
309
|
+
`DAP ${conn.kind} connection closed before response. stderr: ${this.stderrOutput.join("")}`
|
|
310
|
+
)
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
conn.pending.clear();
|
|
314
|
+
// If the active connection just closed, fall back to the most recent
|
|
315
|
+
// non-closed one. If none remain, leave activeIdx pointing at the closed
|
|
316
|
+
// conn — sendRequest will fail with "transport is not writable".
|
|
317
|
+
if (this.conns[this.activeIdx] === conn) {
|
|
318
|
+
for (let i = this.conns.length - 1; i >= 0; i--) {
|
|
319
|
+
if (!this.conns[i].closed) {
|
|
320
|
+
this.activeIdx = i;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
121
327
|
async sendRequest(
|
|
122
328
|
command: string,
|
|
123
329
|
args?: Record<string, unknown>,
|
|
@@ -128,54 +334,68 @@ export class DAPClient extends EventEmitter {
|
|
|
128
334
|
`Adapter has exited (code=${this._exitCode}). stderr: ${this.stderrOutput.join("")}`
|
|
129
335
|
);
|
|
130
336
|
}
|
|
131
|
-
|
|
132
|
-
|
|
337
|
+
// Wait for any in-progress session transition (e.g. handleStartDebugging
|
|
338
|
+
// setting up a child session) before sending. Otherwise the request would
|
|
339
|
+
// race the handshake or land on a half-initialized child connection.
|
|
340
|
+
if (this.transitionLock) {
|
|
341
|
+
await this.transitionLock;
|
|
133
342
|
}
|
|
343
|
+
return this.sendRequestOn(this.conns[this.activeIdx], command, args, timeout);
|
|
344
|
+
}
|
|
134
345
|
|
|
135
|
-
|
|
346
|
+
/** Internal: send a request on a specific connection. */
|
|
347
|
+
private sendRequestOn(
|
|
348
|
+
conn: Conn | undefined,
|
|
349
|
+
command: string,
|
|
350
|
+
args: Record<string, unknown> | undefined,
|
|
351
|
+
timeout: number
|
|
352
|
+
): Promise<DebugProtocol.Response> {
|
|
353
|
+
if (!conn || conn.closed || !conn.writeStream.writable) {
|
|
354
|
+
throw new DAPClientError("Adapter transport is not writable");
|
|
355
|
+
}
|
|
356
|
+
this.trackOutgoing(command, args);
|
|
357
|
+
const seq = conn.seq++;
|
|
136
358
|
const request: DebugProtocol.Request = {
|
|
137
359
|
seq,
|
|
138
360
|
type: "request",
|
|
139
361
|
command,
|
|
140
362
|
arguments: args,
|
|
141
363
|
};
|
|
142
|
-
|
|
143
364
|
return new Promise<DebugProtocol.Response>((resolve, reject) => {
|
|
144
365
|
const pending: PendingRequest = { resolve, reject };
|
|
145
366
|
if (timeout > 0) {
|
|
146
367
|
pending.timer = setTimeout(() => {
|
|
147
|
-
|
|
368
|
+
conn.pending.delete(seq);
|
|
148
369
|
reject(new DAPClientError(`Request '${command}' timeout after ${timeout}ms`));
|
|
149
370
|
}, timeout);
|
|
150
371
|
}
|
|
151
|
-
|
|
372
|
+
conn.pending.set(seq, pending);
|
|
152
373
|
try {
|
|
153
|
-
|
|
374
|
+
conn.writeStream.write(encodeMessage(request), (err) => {
|
|
154
375
|
if (err) {
|
|
155
|
-
|
|
156
|
-
const p = this.pending.get(seq);
|
|
376
|
+
const p = conn.pending.get(seq);
|
|
157
377
|
if (p) {
|
|
158
378
|
if (p.timer) clearTimeout(p.timer);
|
|
159
|
-
|
|
379
|
+
conn.pending.delete(seq);
|
|
160
380
|
p.reject(new DAPClientError(`Write to adapter failed: ${err.message}`));
|
|
161
381
|
}
|
|
162
382
|
}
|
|
163
383
|
});
|
|
164
384
|
} catch (err) {
|
|
165
|
-
|
|
385
|
+
conn.pending.delete(seq);
|
|
166
386
|
if (pending.timer) clearTimeout(pending.timer);
|
|
167
387
|
reject(new DAPClientError(`Write to adapter failed: ${(err as Error).message}`));
|
|
168
388
|
}
|
|
169
389
|
});
|
|
170
390
|
}
|
|
171
391
|
|
|
172
|
-
private handleMessage(msg: DebugProtocol.ProtocolMessage): void {
|
|
392
|
+
private handleMessage(msg: DebugProtocol.ProtocolMessage, conn: Conn): void {
|
|
173
393
|
if (msg.type === "response") {
|
|
174
394
|
const response = msg as DebugProtocol.Response;
|
|
175
|
-
const pending =
|
|
395
|
+
const pending = conn.pending.get(response.request_seq);
|
|
176
396
|
if (pending) {
|
|
177
397
|
if (pending.timer) clearTimeout(pending.timer);
|
|
178
|
-
|
|
398
|
+
conn.pending.delete(response.request_seq);
|
|
179
399
|
if (response.success) {
|
|
180
400
|
pending.resolve(response);
|
|
181
401
|
} else {
|
|
@@ -187,22 +407,21 @@ export class DAPClient extends EventEmitter {
|
|
|
187
407
|
}
|
|
188
408
|
}
|
|
189
409
|
} else if (msg.type === "request") {
|
|
190
|
-
// Reverse request from the adapter (e.g. runInTerminal).
|
|
191
|
-
// We reject all reverse requests — the MCP server has no terminal.
|
|
192
410
|
const req = msg as DebugProtocol.Request;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
411
|
+
if (req.command === "startDebugging" && this.transport === "tcp") {
|
|
412
|
+
// The active session is asking us to open a child session for a
|
|
413
|
+
// newly-spawned target (vscode-js-debug does this for every node
|
|
414
|
+
// process it attaches to). Handle natively rather than rejecting.
|
|
415
|
+
this.handleStartDebugging(req, conn).catch((err) => {
|
|
416
|
+
this.stderrOutput.push(`startDebugging handler failed: ${(err as Error).message}\n`);
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
// Reject other reverse requests — the MCP server has no terminal etc.
|
|
421
|
+
this.replyToReverseRequest(conn, req, {
|
|
198
422
|
success: false,
|
|
199
423
|
message: `Reverse request '${req.command}' is not supported by this client`,
|
|
200
|
-
};
|
|
201
|
-
try {
|
|
202
|
-
this.process?.stdin?.write(encodeMessage(errorResponse));
|
|
203
|
-
} catch {
|
|
204
|
-
// ignore — process may have exited
|
|
205
|
-
}
|
|
424
|
+
});
|
|
206
425
|
} else if (msg.type === "event") {
|
|
207
426
|
const event = msg as DebugProtocol.Event;
|
|
208
427
|
this.emit(event.event, event);
|
|
@@ -210,6 +429,132 @@ export class DAPClient extends EventEmitter {
|
|
|
210
429
|
}
|
|
211
430
|
}
|
|
212
431
|
|
|
432
|
+
/** Reply to a reverse request, on the connection that sent it. */
|
|
433
|
+
private replyToReverseRequest(
|
|
434
|
+
conn: Conn,
|
|
435
|
+
req: DebugProtocol.Request,
|
|
436
|
+
result: { success: boolean; message?: string; body?: Record<string, unknown> }
|
|
437
|
+
): void {
|
|
438
|
+
const response: DebugProtocol.Response = {
|
|
439
|
+
seq: conn.seq++,
|
|
440
|
+
type: "response",
|
|
441
|
+
request_seq: req.seq,
|
|
442
|
+
command: req.command,
|
|
443
|
+
success: result.success,
|
|
444
|
+
message: result.message,
|
|
445
|
+
body: result.body,
|
|
446
|
+
};
|
|
447
|
+
try {
|
|
448
|
+
conn.writeStream.write(encodeMessage(response));
|
|
449
|
+
} catch {
|
|
450
|
+
// ignore — transport may have closed
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Handle a `startDebugging` reverse request from an active session.
|
|
456
|
+
*
|
|
457
|
+
* Per the DAP startDebugging protocol, the client should open a new DAP
|
|
458
|
+
* session for the supplied configuration and run the launch/attach
|
|
459
|
+
* handshake against the SAME adapter. For TCP-server adapters like
|
|
460
|
+
* vscode-js-debug, we open a fresh TCP connection to the same port, run
|
|
461
|
+
* initialize + launch (or attach) using the request's configuration, and
|
|
462
|
+
* make that new connection the active one for subsequent user requests.
|
|
463
|
+
*
|
|
464
|
+
* The `transitionLock` prevents user `sendRequest` calls from racing the
|
|
465
|
+
* handshake.
|
|
466
|
+
*/
|
|
467
|
+
private async handleStartDebugging(req: DebugProtocol.Request, fromConn: Conn): Promise<void> {
|
|
468
|
+
let resolveLock: (() => void) | undefined;
|
|
469
|
+
this.transitionLock = new Promise<void>((r) => { resolveLock = r; });
|
|
470
|
+
try {
|
|
471
|
+
const argsObj = (req.arguments ?? {}) as {
|
|
472
|
+
configuration?: Record<string, unknown>;
|
|
473
|
+
request?: "launch" | "attach";
|
|
474
|
+
};
|
|
475
|
+
const childConfig = argsObj.configuration ?? {};
|
|
476
|
+
const childRequest = argsObj.request ?? "launch";
|
|
477
|
+
|
|
478
|
+
const childConn = await this.openTcpConnection("child");
|
|
479
|
+
this.conns.push(childConn);
|
|
480
|
+
const newIdx = this.conns.length - 1;
|
|
481
|
+
// Make it active so the silent handshake calls below go to the child.
|
|
482
|
+
this.activeIdx = newIdx;
|
|
483
|
+
|
|
484
|
+
// Run initialize on the child. The adapterID is taken from the supplied
|
|
485
|
+
// configuration when present (vscode-js-debug uses "pwa-node").
|
|
486
|
+
await this.sendRequestOn(
|
|
487
|
+
childConn,
|
|
488
|
+
"initialize",
|
|
489
|
+
{
|
|
490
|
+
adapterID: (childConfig.type as string | undefined) ?? "child",
|
|
491
|
+
clientID: "dap-mcp-server",
|
|
492
|
+
clientName: "DAP MCP Server",
|
|
493
|
+
linesStartAt1: true,
|
|
494
|
+
columnsStartAt1: true,
|
|
495
|
+
pathFormat: "path",
|
|
496
|
+
},
|
|
497
|
+
10000
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
// Run launch/attach with the provided configuration. The configuration
|
|
501
|
+
// typically carries adapter-specific fields like __pendingTargetId that
|
|
502
|
+
// bind the new session to a specific target. Fire-and-forget — the
|
|
503
|
+
// adapter blocks the response until configurationDone, which we issue
|
|
504
|
+
// ourselves below after replaying breakpoints.
|
|
505
|
+
const childLaunchPromise = this.sendRequestOn(
|
|
506
|
+
childConn,
|
|
507
|
+
childRequest,
|
|
508
|
+
childConfig,
|
|
509
|
+
30000
|
|
510
|
+
);
|
|
511
|
+
childLaunchPromise.catch(() => { /* nothing awaits this directly */ });
|
|
512
|
+
|
|
513
|
+
// Replay configuration state established on the previous active
|
|
514
|
+
// connection so the new child session honors the same breakpoints.
|
|
515
|
+
// Without this, vscode-js-debug-style adapters (where the parent
|
|
516
|
+
// accepts setBreakpoints but the child is what actually runs the
|
|
517
|
+
// debuggee) would never pause at user-set breakpoints.
|
|
518
|
+
for (const args of this.bpHistory.values()) {
|
|
519
|
+
try {
|
|
520
|
+
await this.sendRequestOn(childConn, "setBreakpoints", args, 10000);
|
|
521
|
+
} catch {
|
|
522
|
+
// Best-effort replay — keep going.
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (this.excBpHistory) {
|
|
526
|
+
try {
|
|
527
|
+
await this.sendRequestOn(
|
|
528
|
+
childConn,
|
|
529
|
+
"setExceptionBreakpoints",
|
|
530
|
+
this.excBpHistory,
|
|
531
|
+
10000
|
|
532
|
+
);
|
|
533
|
+
} catch {
|
|
534
|
+
// ignore
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (this.configurationDoneSent) {
|
|
538
|
+
try {
|
|
539
|
+
await this.sendRequestOn(childConn, "configurationDone", undefined, 10000);
|
|
540
|
+
} catch {
|
|
541
|
+
// ignore
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Acknowledge the reverse request so the parent can proceed.
|
|
546
|
+
this.replyToReverseRequest(fromConn, req, { success: true });
|
|
547
|
+
} catch (err) {
|
|
548
|
+
this.replyToReverseRequest(fromConn, req, {
|
|
549
|
+
success: false,
|
|
550
|
+
message: `Failed to start child debug session: ${(err as Error).message}`,
|
|
551
|
+
});
|
|
552
|
+
} finally {
|
|
553
|
+
resolveLock?.();
|
|
554
|
+
this.transitionLock = undefined;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
213
558
|
get exited(): boolean {
|
|
214
559
|
return this._exited;
|
|
215
560
|
}
|
|
@@ -219,6 +564,11 @@ export class DAPClient extends EventEmitter {
|
|
|
219
564
|
}
|
|
220
565
|
|
|
221
566
|
async dispose(): Promise<void> {
|
|
567
|
+
for (const conn of this.conns) {
|
|
568
|
+
if (conn.socket && !conn.socket.destroyed) {
|
|
569
|
+
try { conn.socket.destroy(); } catch { /* ignore */ }
|
|
570
|
+
}
|
|
571
|
+
}
|
|
222
572
|
if (this.process && !this._exited) {
|
|
223
573
|
this.process.stdin?.end();
|
|
224
574
|
this.process.kill("SIGTERM");
|
package/src/dap/types.ts
CHANGED
|
@@ -24,6 +24,16 @@
|
|
|
24
24
|
|
|
25
25
|
export type SessionState = "idle" | "configuring" | "running" | "stopped" | "terminated";
|
|
26
26
|
|
|
27
|
+
export interface Variable {
|
|
28
|
+
name: string;
|
|
29
|
+
value: string;
|
|
30
|
+
type?: string;
|
|
31
|
+
hasChildren: boolean;
|
|
32
|
+
variablesReference: number;
|
|
33
|
+
/** Populated when getVariables is called with depth > 1 and this variable has children. */
|
|
34
|
+
children?: Variable[];
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
export interface StoppedState {
|
|
28
38
|
status: "stopped";
|
|
29
39
|
reason: string;
|
|
@@ -43,13 +53,7 @@ export interface StoppedState {
|
|
|
43
53
|
}>;
|
|
44
54
|
scopes: Array<{
|
|
45
55
|
name: string;
|
|
46
|
-
variables:
|
|
47
|
-
name: string;
|
|
48
|
-
value: string;
|
|
49
|
-
type?: string;
|
|
50
|
-
hasChildren: boolean;
|
|
51
|
-
variablesReference: number;
|
|
52
|
-
}>;
|
|
56
|
+
variables: Variable[];
|
|
53
57
|
}>;
|
|
54
58
|
output?: string[];
|
|
55
59
|
}
|
|
@@ -69,9 +73,28 @@ export interface AdapterConfig {
|
|
|
69
73
|
args?: string[];
|
|
70
74
|
launchDefaults?: Record<string, unknown>;
|
|
71
75
|
installHint?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Transport mode for the adapter:
|
|
78
|
+
* - "stdio" (default): DAP messages flow over the adapter's stdin/stdout.
|
|
79
|
+
* - "tcp": the adapter is a TCP DAP server. After spawning, the client reads
|
|
80
|
+
* the listening address from the adapter's stdout (matched with
|
|
81
|
+
* `portMatcher`) and connects to it via a TCP socket. DAP messages then
|
|
82
|
+
* flow over that socket. Use this for adapters like `dlv dap` or
|
|
83
|
+
* vscode-js-debug's `dapDebugServer.js` that don't speak DAP on stdio.
|
|
84
|
+
*/
|
|
85
|
+
transport?: "stdio" | "tcp";
|
|
86
|
+
/**
|
|
87
|
+
* Regular expression with a single capture group that yields the TCP port the
|
|
88
|
+
* adapter is listening on. Matched against the adapter's stdout output.
|
|
89
|
+
* Required (and only used) when `transport` is "tcp".
|
|
90
|
+
* Example for delve: "DAP server listening at:\\s*\\S+:(\\d+)"
|
|
91
|
+
*/
|
|
92
|
+
portMatcher?: string;
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
export interface OutputEntry {
|
|
96
|
+
/** Monotonic per-session sequence number assigned at push time. Survives ring-buffer eviction. */
|
|
97
|
+
seq: number;
|
|
75
98
|
category: string;
|
|
76
99
|
text: string;
|
|
77
100
|
timestamp: string;
|
|
@@ -101,6 +101,27 @@ describe("Debug Lifecycle Integration", () => {
|
|
|
101
101
|
expect(session.state).toBe("terminated");
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
it("returns nested children when collectState uses variableDepth > 1", async () => {
|
|
105
|
+
session = new DebugSession("integ-deep", { type: "mock", runtime: "node", program: MOCK_ADAPTER });
|
|
106
|
+
await session.initialize();
|
|
107
|
+
const launchPromise = session.launch({ program: "/test/app.py", stopOnEntry: true });
|
|
108
|
+
await session.configurationDone();
|
|
109
|
+
await launchPromise;
|
|
110
|
+
await session.waitForStopped(5000);
|
|
111
|
+
|
|
112
|
+
const state = await session.collectState(1, 10, 2);
|
|
113
|
+
const locals = state.scopes.find(s => s.name === "Locals");
|
|
114
|
+
const data = locals!.variables.find(v => v.name === "data");
|
|
115
|
+
expect(data).toBeDefined();
|
|
116
|
+
expect(data!.hasChildren).toBe(true);
|
|
117
|
+
expect(data!.children).toBeDefined();
|
|
118
|
+
expect(data!.children!.map(c => c.value)).toEqual(["1", "2", "3"]);
|
|
119
|
+
|
|
120
|
+
// Leaf variables should not get a children property.
|
|
121
|
+
const x = locals!.variables.find(v => v.name === "x");
|
|
122
|
+
expect(x!.children).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
104
125
|
it("handles adapter crash gracefully", async () => {
|
|
105
126
|
const crashAdapter = path.join(__dirname, "../test-fixtures/crash-adapter.js");
|
|
106
127
|
session = new DebugSession("integ-crash", { type: "mock", runtime: "node", program: crashAdapter });
|