dap-mcp-server 0.1.11 → 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.
@@ -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 parser?: MessageParser;
47
- private seq = 1;
48
- private pending = new Map<number, PendingRequest>();
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(runtime: string, executable: string, args: string[] = []) {
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
- for (const [, req] of this.pending) {
99
- if (req.timer) clearTimeout(req.timer);
100
- req.reject(
101
- new DAPClientError(
102
- `Adapter exited (code=${code}, signal=${signal}). stderr: ${this.stderrOutput.join("")}`
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
- // Resolve once the process is spawned (use flag to prevent double-resolve)
111
- let resolved = false;
112
- this.process.stdout!.once("readable", () => {
113
- if (!resolved) { resolved = true; resolve(); }
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
- if (!this.process?.stdin?.writable) {
132
- throw new DAPClientError("Adapter stdin is not writable");
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
- const seq = this.seq++;
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
- this.pending.delete(seq);
368
+ conn.pending.delete(seq);
148
369
  reject(new DAPClientError(`Request '${command}' timeout after ${timeout}ms`));
149
370
  }, timeout);
150
371
  }
151
- this.pending.set(seq, pending);
372
+ conn.pending.set(seq, pending);
152
373
  try {
153
- this.process!.stdin!.write(encodeMessage(request), (err) => {
374
+ conn.writeStream.write(encodeMessage(request), (err) => {
154
375
  if (err) {
155
- // Write failed (e.g. EPIPE); clean up and reject
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
- this.pending.delete(seq);
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
- this.pending.delete(seq);
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 = this.pending.get(response.request_seq);
395
+ const pending = conn.pending.get(response.request_seq);
176
396
  if (pending) {
177
397
  if (pending.timer) clearTimeout(pending.timer);
178
- this.pending.delete(response.request_seq);
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
- const errorResponse: DebugProtocol.Response = {
194
- seq: this.seq++,
195
- type: "response",
196
- request_seq: req.seq,
197
- command: req.command,
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
@@ -73,6 +73,23 @@ export interface AdapterConfig {
73
73
  args?: string[];
74
74
  launchDefaults?: Record<string, unknown>;
75
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;
76
93
  }
77
94
 
78
95
  export interface OutputEntry {
package/src/server.ts CHANGED
@@ -223,6 +223,8 @@ export function createServer(): McpServer {
223
223
  args: z.array(z.string()).optional(),
224
224
  launchDefaults: z.record(z.string(), z.unknown()).optional(),
225
225
  installHint: z.string().optional(),
226
+ transport: z.enum(["stdio", "tcp"]).optional(),
227
+ portMatcher: z.string().optional(),
226
228
  })
227
229
  .optional()
228
230
  .describe("Override adapter config inline instead of looking up from registry"),
@@ -276,9 +278,27 @@ export function createServer(): McpServer {
276
278
  if (cwd !== undefined) launchArgs.cwd = cwd;
277
279
  if (env !== undefined) launchArgs.env = env;
278
280
 
281
+ // Adapter-specific arg normalization. delve's `cwd` is the debuggee's
282
+ // working directory; the *build* (go build) runs from delve's own
283
+ // process cwd, controlled by `dlvCwd`. Mirror cwd → dlvCwd so a
284
+ // user-supplied cwd is found by `go build` looking for go.mod.
285
+ if (config.type === "go" && cwd !== undefined && launchArgs.dlvCwd === undefined) {
286
+ launchArgs.dlvCwd = cwd;
287
+ }
288
+
279
289
  // Send launch first (don't await — real adapters like debugpy block
280
290
  // the launch response until configurationDone arrives)
281
291
  const launchPromise = session.launch(launchArgs);
292
+ // Suppress unhandled rejection: the real `await launchPromise` below
293
+ // will still throw if launch fails. Without this, an early launch
294
+ // rejection (e.g. adapter rejects the request) crashes the MCP server.
295
+ launchPromise.catch(() => { /* handled at await */ });
296
+
297
+ // Wait for the adapter to emit the "initialized" event before sending
298
+ // configuration requests. Some adapters (delve) emit it right after the
299
+ // initialize response; others (debugpy) only emit it after receiving
300
+ // launch — that's why we fire launch first and wait for initialized here.
301
+ await session.waitForInitialized(10000);
282
302
 
283
303
  // Set breakpoints grouped by file
284
304
  if (breakpoints && breakpoints.length > 0) {
@@ -365,6 +385,8 @@ export function createServer(): McpServer {
365
385
  args: z.array(z.string()).optional(),
366
386
  launchDefaults: z.record(z.string(), z.unknown()).optional(),
367
387
  installHint: z.string().optional(),
388
+ transport: z.enum(["stdio", "tcp"]).optional(),
389
+ portMatcher: z.string().optional(),
368
390
  })
369
391
  .optional()
370
392
  .describe("Override adapter config inline"),
@@ -402,6 +424,10 @@ export function createServer(): McpServer {
402
424
 
403
425
  // Send attach first (don't await — real adapters block until configurationDone)
404
426
  const attachPromise = session.attach(attachArgs);
427
+ // Suppress unhandled rejection: the real `await attachPromise` below
428
+ // will still throw if attach fails.
429
+ attachPromise.catch(() => { /* handled at await */ });
430
+ await session.waitForInitialized(10000);
405
431
  await session.configurationDone();
406
432
  await attachPromise;
407
433
 
@@ -839,6 +865,10 @@ export function createServer(): McpServer {
839
865
 
840
866
  // Send launch first (don't await — real adapters block until configurationDone)
841
867
  const launchPromise = newSession.launch(launchConfig);
868
+ // Suppress unhandled rejection: the real `await launchPromise` below
869
+ // will still throw if launch fails.
870
+ launchPromise.catch(() => { /* handled at await */ });
871
+ await newSession.waitForInitialized(10000);
842
872
 
843
873
  // Re-apply saved breakpoints
844
874
  for (const [file, bps] of savedBreakpoints) {
@@ -922,6 +952,8 @@ export function createServer(): McpServer {
922
952
  args: z.array(z.string()).optional(),
923
953
  launchDefaults: z.record(z.string(), z.unknown()).optional(),
924
954
  installHint: z.string().optional(),
955
+ transport: z.enum(["stdio", "tcp"]).optional(),
956
+ portMatcher: z.string().optional(),
925
957
  })
926
958
  .optional()
927
959
  .describe("Override adapter config inline"),
@@ -1010,6 +1042,8 @@ export function createServer(): McpServer {
1010
1042
  args: z.array(z.string()).optional(),
1011
1043
  launchDefaults: z.record(z.string(), z.unknown()).optional(),
1012
1044
  installHint: z.string().optional(),
1045
+ transport: z.enum(["stdio", "tcp"]).optional(),
1046
+ portMatcher: z.string().optional(),
1013
1047
  })
1014
1048
  .optional()
1015
1049
  .describe("Override adapter config inline"),