dap-mcp-server 0.1.5

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.
Files changed (65) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/.forgejo/workflows/publish.yml +63 -0
  3. package/CLAUDE.md +71 -0
  4. package/LICENSE +21 -0
  5. package/README.md +105 -0
  6. package/SKILL.md +62 -0
  7. package/build/adapters/builtin.d.ts +24 -0
  8. package/build/adapters/builtin.js +46 -0
  9. package/build/adapters/builtin.js.map +1 -0
  10. package/build/adapters/detect.d.ts +23 -0
  11. package/build/adapters/detect.js +36 -0
  12. package/build/adapters/detect.js.map +1 -0
  13. package/build/adapters/registry.d.ts +39 -0
  14. package/build/adapters/registry.js +79 -0
  15. package/build/adapters/registry.js.map +1 -0
  16. package/build/dap/dap-client.d.ts +46 -0
  17. package/build/dap/dap-client.js +190 -0
  18. package/build/dap/dap-client.js.map +1 -0
  19. package/build/dap/protocol.d.ts +32 -0
  20. package/build/dap/protocol.js +71 -0
  21. package/build/dap/protocol.js.map +1 -0
  22. package/build/dap/ring-buffer.d.ts +35 -0
  23. package/build/dap/ring-buffer.js +57 -0
  24. package/build/dap/ring-buffer.js.map +1 -0
  25. package/build/dap/types.d.ts +87 -0
  26. package/build/dap/types.js +25 -0
  27. package/build/dap/types.js.map +1 -0
  28. package/build/index.d.ts +24 -0
  29. package/build/index.js +30 -0
  30. package/build/index.js.map +1 -0
  31. package/build/server.d.ts +24 -0
  32. package/build/server.js +735 -0
  33. package/build/server.js.map +1 -0
  34. package/build/session/session-manager.d.ts +32 -0
  35. package/build/session/session-manager.js +60 -0
  36. package/build/session/session-manager.js.map +1 -0
  37. package/build/session/session.d.ts +85 -0
  38. package/build/session/session.js +374 -0
  39. package/build/session/session.js.map +1 -0
  40. package/docs/plans/2026-03-05-dap-wrapper-mcp-design.md +326 -0
  41. package/docs/plans/2026-03-05-dap-wrapper-mcp-implementation.md +2815 -0
  42. package/package.json +43 -0
  43. package/src/adapters/builtin.ts +47 -0
  44. package/src/adapters/detect.test.ts +36 -0
  45. package/src/adapters/detect.ts +36 -0
  46. package/src/adapters/registry.test.ts +73 -0
  47. package/src/adapters/registry.ts +86 -0
  48. package/src/dap/dap-client.test.ts +87 -0
  49. package/src/dap/dap-client.ts +216 -0
  50. package/src/dap/protocol.test.ts +82 -0
  51. package/src/dap/protocol.ts +78 -0
  52. package/src/dap/ring-buffer.test.ts +82 -0
  53. package/src/dap/ring-buffer.ts +64 -0
  54. package/src/dap/types.ts +92 -0
  55. package/src/index.ts +30 -0
  56. package/src/integration/debug-lifecycle.test.ts +107 -0
  57. package/src/server.ts +866 -0
  58. package/src/session/session-manager.test.ts +74 -0
  59. package/src/session/session-manager.ts +67 -0
  60. package/src/session/session.test.ts +84 -0
  61. package/src/session/session.ts +435 -0
  62. package/src/test-fixtures/crash-adapter.js +23 -0
  63. package/src/test-fixtures/mock-adapter.js +142 -0
  64. package/src/test-fixtures/silent-adapter.js +24 -0
  65. package/tsconfig.json +16 -0
@@ -0,0 +1,2815 @@
1
+ # DAP Wrapper MCP Server — Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Build an MCP server that wraps the Debug Adapter Protocol, enabling AI agents to use real debuggers via coarse-grained MCP tools.
6
+
7
+ **Architecture:** MCP server (stdio transport) manages debug sessions. Each session spawns a DAP-compliant debug adapter as a child process (stdin/stdout). A custom DAP client handles Content-Length framing and request/response correlation. The session layer adds state tracking, output buffering, and composite state collection.
8
+
9
+ **Tech Stack:** TypeScript, Node.js, `@modelcontextprotocol/sdk` + `zod` (MCP server), `@vscode/debugprotocol` (DAP types), `vitest` (testing)
10
+
11
+ ---
12
+
13
+ ## Task 1: Project Scaffolding
14
+
15
+ **Files:**
16
+ - Create: `package.json`
17
+ - Create: `tsconfig.json`
18
+ - Create: `src/index.ts` (empty entry point)
19
+
20
+ **Step 1: Create package.json**
21
+
22
+ ```json
23
+ {
24
+ "name": "dap-mcp-server",
25
+ "version": "0.1.0",
26
+ "description": "MCP server wrapping the Debug Adapter Protocol for AI-driven debugging",
27
+ "type": "module",
28
+ "main": "build/index.js",
29
+ "bin": {
30
+ "dap-mcp-server": "build/index.js"
31
+ },
32
+ "scripts": {
33
+ "build": "tsc",
34
+ "dev": "tsc --watch",
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "start": "node build/index.js"
38
+ },
39
+ "license": "MIT"
40
+ }
41
+ ```
42
+
43
+ **Step 2: Create tsconfig.json**
44
+
45
+ ```json
46
+ {
47
+ "compilerOptions": {
48
+ "target": "ES2022",
49
+ "module": "Node16",
50
+ "moduleResolution": "Node16",
51
+ "outDir": "./build",
52
+ "rootDir": "./src",
53
+ "strict": true,
54
+ "esModuleInterop": true,
55
+ "skipLibCheck": true,
56
+ "declaration": true,
57
+ "sourceMap": true
58
+ },
59
+ "include": ["src/**/*"],
60
+ "exclude": ["node_modules", "build", "**/*.test.ts"]
61
+ }
62
+ ```
63
+
64
+ **Step 3: Create .gitignore**
65
+
66
+ ```
67
+ node_modules/
68
+ build/
69
+ .dap-mcp/
70
+ ```
71
+
72
+ **Step 4: Install dependencies**
73
+
74
+ Run:
75
+ ```bash
76
+ npm install @modelcontextprotocol/sdk zod @vscode/debugprotocol
77
+ npm install -D typescript @types/node vitest
78
+ ```
79
+
80
+ **Step 5: Create empty entry point**
81
+
82
+ Create `src/index.ts`:
83
+ ```typescript
84
+ #!/usr/bin/env node
85
+ console.error("dap-mcp-server starting...");
86
+ ```
87
+
88
+ **Step 6: Verify build**
89
+
90
+ Run: `npx tsc --noEmit`
91
+ Expected: no errors
92
+
93
+ **Step 7: Commit**
94
+
95
+ ```bash
96
+ git add package.json tsconfig.json .gitignore src/index.ts package-lock.json
97
+ git commit -m "feat: project scaffolding with dependencies"
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Task 2: DAP Protocol Types
103
+
104
+ **Files:**
105
+ - Create: `src/dap/types.ts`
106
+
107
+ **Step 1: Create shared types used across the project**
108
+
109
+ These are our internal types — not duplicating `@vscode/debugprotocol` but defining the shapes our MCP tools return.
110
+
111
+ ```typescript
112
+ // src/dap/types.ts
113
+
114
+ export type SessionState = "idle" | "configuring" | "running" | "stopped" | "terminated";
115
+
116
+ export interface StoppedState {
117
+ status: "stopped";
118
+ reason: string;
119
+ location: {
120
+ file: string;
121
+ line: number;
122
+ column?: number;
123
+ functionName?: string;
124
+ };
125
+ threads: Array<{ id: number; name: string; stopped: boolean }>;
126
+ stackTrace: Array<{
127
+ id: number;
128
+ name: string;
129
+ file: string;
130
+ line: number;
131
+ column?: number;
132
+ }>;
133
+ scopes: Array<{
134
+ name: string;
135
+ variables: Array<{
136
+ name: string;
137
+ value: string;
138
+ type?: string;
139
+ hasChildren: boolean;
140
+ variablesReference: number;
141
+ }>;
142
+ }>;
143
+ output?: string[];
144
+ }
145
+
146
+ export interface RunningState {
147
+ status: "running";
148
+ output?: string[];
149
+ elapsedMs?: number;
150
+ }
151
+
152
+ export type DebugState = StoppedState | RunningState;
153
+
154
+ export interface AdapterConfig {
155
+ type: string;
156
+ runtime?: string;
157
+ program: string;
158
+ args?: string[];
159
+ launchDefaults?: Record<string, unknown>;
160
+ installHint?: string;
161
+ }
162
+
163
+ export interface OutputEntry {
164
+ category: string;
165
+ text: string;
166
+ timestamp: string;
167
+ }
168
+
169
+ export interface BreakpointConfig {
170
+ line: number;
171
+ condition?: string;
172
+ hitCondition?: string;
173
+ logMessage?: string;
174
+ }
175
+
176
+ export interface BreakpointResult {
177
+ id: number;
178
+ verified: boolean;
179
+ line: number;
180
+ message?: string;
181
+ }
182
+ ```
183
+
184
+ **Step 2: Commit**
185
+
186
+ ```bash
187
+ git add src/dap/types.ts
188
+ git commit -m "feat: add shared DAP/MCP type definitions"
189
+ ```
190
+
191
+ ---
192
+
193
+ ## Task 3: Output Ring Buffer
194
+
195
+ **Files:**
196
+ - Create: `src/dap/ring-buffer.ts`
197
+ - Create: `src/dap/ring-buffer.test.ts`
198
+
199
+ **Step 1: Write the failing tests**
200
+
201
+ ```typescript
202
+ // src/dap/ring-buffer.test.ts
203
+ import { describe, it, expect } from "vitest";
204
+ import { RingBuffer } from "./ring-buffer.js";
205
+
206
+ describe("RingBuffer", () => {
207
+ it("stores and retrieves entries", () => {
208
+ const buf = new RingBuffer<string>(100);
209
+ buf.push("a");
210
+ buf.push("b");
211
+ expect(buf.getAll()).toEqual(["a", "b"]);
212
+ expect(buf.truncated).toBe(false);
213
+ });
214
+
215
+ it("drops oldest entries when full", () => {
216
+ const buf = new RingBuffer<string>(3);
217
+ buf.push("a");
218
+ buf.push("b");
219
+ buf.push("c");
220
+ buf.push("d");
221
+ expect(buf.getAll()).toEqual(["b", "c", "d"]);
222
+ expect(buf.truncated).toBe(true);
223
+ });
224
+
225
+ it("clears entries", () => {
226
+ const buf = new RingBuffer<string>(10);
227
+ buf.push("a");
228
+ buf.push("b");
229
+ buf.clear();
230
+ expect(buf.getAll()).toEqual([]);
231
+ expect(buf.truncated).toBe(false);
232
+ });
233
+
234
+ it("filters entries", () => {
235
+ const buf = new RingBuffer<{ cat: string; text: string }>(10);
236
+ buf.push({ cat: "stdout", text: "hello" });
237
+ buf.push({ cat: "stderr", text: "error" });
238
+ buf.push({ cat: "stdout", text: "world" });
239
+ const filtered = buf.getFiltered((e) => e.cat === "stdout");
240
+ expect(filtered).toEqual([
241
+ { cat: "stdout", text: "hello" },
242
+ { cat: "stdout", text: "world" },
243
+ ]);
244
+ });
245
+
246
+ it("reports size correctly", () => {
247
+ const buf = new RingBuffer<string>(5);
248
+ expect(buf.size).toBe(0);
249
+ buf.push("a");
250
+ buf.push("b");
251
+ expect(buf.size).toBe(2);
252
+ });
253
+ });
254
+ ```
255
+
256
+ **Step 2: Run tests to verify they fail**
257
+
258
+ Run: `npx vitest run src/dap/ring-buffer.test.ts`
259
+ Expected: FAIL — module not found
260
+
261
+ **Step 3: Implement RingBuffer**
262
+
263
+ ```typescript
264
+ // src/dap/ring-buffer.ts
265
+ export class RingBuffer<T> {
266
+ private buffer: T[] = [];
267
+ private maxSize: number;
268
+ private _truncated = false;
269
+
270
+ constructor(maxSize: number) {
271
+ this.maxSize = maxSize;
272
+ }
273
+
274
+ push(item: T): void {
275
+ if (this.buffer.length >= this.maxSize) {
276
+ this.buffer.shift();
277
+ this._truncated = true;
278
+ }
279
+ this.buffer.push(item);
280
+ }
281
+
282
+ getAll(): T[] {
283
+ return [...this.buffer];
284
+ }
285
+
286
+ getFiltered(predicate: (item: T) => boolean): T[] {
287
+ return this.buffer.filter(predicate);
288
+ }
289
+
290
+ clear(): void {
291
+ this.buffer = [];
292
+ this._truncated = false;
293
+ }
294
+
295
+ get size(): number {
296
+ return this.buffer.length;
297
+ }
298
+
299
+ get truncated(): boolean {
300
+ return this._truncated;
301
+ }
302
+ }
303
+ ```
304
+
305
+ **Step 4: Run tests to verify they pass**
306
+
307
+ Run: `npx vitest run src/dap/ring-buffer.test.ts`
308
+ Expected: all 5 tests PASS
309
+
310
+ **Step 5: Commit**
311
+
312
+ ```bash
313
+ git add src/dap/ring-buffer.ts src/dap/ring-buffer.test.ts
314
+ git commit -m "feat: add output ring buffer with max size and truncation tracking"
315
+ ```
316
+
317
+ ---
318
+
319
+ ## Task 4: DAP Client — Protocol Framing
320
+
321
+ **Files:**
322
+ - Create: `src/dap/protocol.ts`
323
+ - Create: `src/dap/protocol.test.ts`
324
+
325
+ The DAP wire protocol uses `Content-Length: <N>\r\n\r\n<JSON body>` framing over streams. We need an encoder and a stream parser.
326
+
327
+ **Step 1: Write the failing tests**
328
+
329
+ ```typescript
330
+ // src/dap/protocol.test.ts
331
+ import { describe, it, expect } from "vitest";
332
+ import { encodeMessage, MessageParser } from "./protocol.js";
333
+ import { DebugProtocol } from "@vscode/debugprotocol";
334
+
335
+ describe("encodeMessage", () => {
336
+ it("encodes a DAP message with Content-Length header", () => {
337
+ const msg: DebugProtocol.Request = {
338
+ seq: 1,
339
+ type: "request",
340
+ command: "initialize",
341
+ arguments: { adapterID: "test" },
342
+ };
343
+ const encoded = encodeMessage(msg);
344
+ const str = encoded.toString("utf-8");
345
+ const json = JSON.stringify(msg);
346
+ expect(str).toBe(`Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`);
347
+ });
348
+ });
349
+
350
+ describe("MessageParser", () => {
351
+ it("parses a single complete message", () => {
352
+ const messages: DebugProtocol.ProtocolMessage[] = [];
353
+ const parser = new MessageParser((msg) => messages.push(msg));
354
+
355
+ const body = JSON.stringify({ seq: 1, type: "response", command: "initialize", success: true, request_seq: 1 });
356
+ const raw = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
357
+ parser.feed(Buffer.from(raw));
358
+
359
+ expect(messages).toHaveLength(1);
360
+ expect(messages[0].seq).toBe(1);
361
+ });
362
+
363
+ it("handles messages split across multiple chunks", () => {
364
+ const messages: DebugProtocol.ProtocolMessage[] = [];
365
+ const parser = new MessageParser((msg) => messages.push(msg));
366
+
367
+ const body = JSON.stringify({ seq: 1, type: "event", event: "initialized" });
368
+ const raw = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
369
+ const mid = Math.floor(raw.length / 2);
370
+
371
+ parser.feed(Buffer.from(raw.slice(0, mid)));
372
+ expect(messages).toHaveLength(0);
373
+ parser.feed(Buffer.from(raw.slice(mid)));
374
+ expect(messages).toHaveLength(1);
375
+ });
376
+
377
+ it("handles multiple messages in one chunk", () => {
378
+ const messages: DebugProtocol.ProtocolMessage[] = [];
379
+ const parser = new MessageParser((msg) => messages.push(msg));
380
+
381
+ const body1 = JSON.stringify({ seq: 1, type: "event", event: "initialized" });
382
+ const body2 = JSON.stringify({ seq: 2, type: "event", event: "stopped" });
383
+ const raw = `Content-Length: ${Buffer.byteLength(body1)}\r\n\r\n${body1}Content-Length: ${Buffer.byteLength(body2)}\r\n\r\n${body2}`;
384
+ parser.feed(Buffer.from(raw));
385
+
386
+ expect(messages).toHaveLength(2);
387
+ expect(messages[0].seq).toBe(1);
388
+ expect(messages[1].seq).toBe(2);
389
+ });
390
+ });
391
+ ```
392
+
393
+ **Step 2: Run tests to verify they fail**
394
+
395
+ Run: `npx vitest run src/dap/protocol.test.ts`
396
+ Expected: FAIL — module not found
397
+
398
+ **Step 3: Implement protocol encoder and parser**
399
+
400
+ ```typescript
401
+ // src/dap/protocol.ts
402
+ import { DebugProtocol } from "@vscode/debugprotocol";
403
+
404
+ const TWO_CRLF = "\r\n\r\n";
405
+ const CONTENT_LENGTH = "Content-Length: ";
406
+
407
+ export function encodeMessage(msg: DebugProtocol.ProtocolMessage): Buffer {
408
+ const json = JSON.stringify(msg);
409
+ const header = `${CONTENT_LENGTH}${Buffer.byteLength(json)}${TWO_CRLF}`;
410
+ return Buffer.from(header + json, "utf-8");
411
+ }
412
+
413
+ export class MessageParser {
414
+ private buffer = Buffer.alloc(0);
415
+ private contentLength = -1;
416
+ private onMessage: (msg: DebugProtocol.ProtocolMessage) => void;
417
+
418
+ constructor(onMessage: (msg: DebugProtocol.ProtocolMessage) => void) {
419
+ this.onMessage = onMessage;
420
+ }
421
+
422
+ feed(data: Buffer): void {
423
+ this.buffer = Buffer.concat([this.buffer, data]);
424
+ this.parse();
425
+ }
426
+
427
+ private parse(): void {
428
+ while (true) {
429
+ if (this.contentLength === -1) {
430
+ const headerEnd = this.buffer.indexOf(TWO_CRLF);
431
+ if (headerEnd === -1) return;
432
+
433
+ const header = this.buffer.subarray(0, headerEnd).toString("utf-8");
434
+ const match = header.match(/Content-Length:\s*(\d+)/i);
435
+ if (!match) {
436
+ // Skip malformed header — advance past it
437
+ this.buffer = this.buffer.subarray(headerEnd + TWO_CRLF.length);
438
+ continue;
439
+ }
440
+ this.contentLength = parseInt(match[1], 10);
441
+ this.buffer = this.buffer.subarray(headerEnd + TWO_CRLF.length);
442
+ }
443
+
444
+ if (this.buffer.length < this.contentLength) return;
445
+
446
+ const body = this.buffer.subarray(0, this.contentLength).toString("utf-8");
447
+ this.buffer = this.buffer.subarray(this.contentLength);
448
+ this.contentLength = -1;
449
+
450
+ try {
451
+ const msg = JSON.parse(body) as DebugProtocol.ProtocolMessage;
452
+ this.onMessage(msg);
453
+ } catch {
454
+ // Skip malformed JSON
455
+ }
456
+ }
457
+ }
458
+ }
459
+ ```
460
+
461
+ **Step 4: Run tests to verify they pass**
462
+
463
+ Run: `npx vitest run src/dap/protocol.test.ts`
464
+ Expected: all tests PASS
465
+
466
+ **Step 5: Commit**
467
+
468
+ ```bash
469
+ git add src/dap/protocol.ts src/dap/protocol.test.ts
470
+ git commit -m "feat: add DAP Content-Length protocol framing encoder and parser"
471
+ ```
472
+
473
+ ---
474
+
475
+ ## Task 5: DAP Client — Process Spawning and Request/Response
476
+
477
+ **Files:**
478
+ - Create: `src/dap/dap-client.ts`
479
+ - Create: `src/dap/dap-client.test.ts`
480
+
481
+ The DAP client spawns a debug adapter as a child process, sends requests, and receives responses and events. It uses `EventEmitter` for events and `Promise` for request/response correlation.
482
+
483
+ **Step 1: Write the failing tests**
484
+
485
+ Use a mock adapter (a simple Node.js script that echoes back DAP responses) to test the client.
486
+
487
+ ```typescript
488
+ // src/dap/dap-client.test.ts
489
+ import { describe, it, expect, afterEach } from "vitest";
490
+ import { DAPClient, DAPClientError } from "./dap-client.js";
491
+ import path from "node:path";
492
+ import { fileURLToPath } from "node:url";
493
+
494
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
495
+
496
+ describe("DAPClient", () => {
497
+ let client: DAPClient | undefined;
498
+
499
+ afterEach(async () => {
500
+ if (client) {
501
+ try { await client.dispose(); } catch { /* ignore */ }
502
+ client = undefined;
503
+ }
504
+ });
505
+
506
+ it("spawns adapter and sends initialize request", async () => {
507
+ client = new DAPClient("node", path.join(__dirname, "../test-fixtures/mock-adapter.js"));
508
+ await client.spawn();
509
+ const response = await client.sendRequest("initialize", {
510
+ adapterID: "test",
511
+ linesStartAt1: true,
512
+ columnsStartAt1: true,
513
+ pathFormat: "path",
514
+ });
515
+ expect(response.success).toBe(true);
516
+ expect(response.command).toBe("initialize");
517
+ });
518
+
519
+ it("receives events from adapter", async () => {
520
+ client = new DAPClient("node", path.join(__dirname, "../test-fixtures/mock-adapter.js"));
521
+ await client.spawn();
522
+
523
+ const eventPromise = new Promise<any>((resolve) => {
524
+ client!.on("initialized", resolve);
525
+ });
526
+
527
+ await client.sendRequest("initialize", {
528
+ adapterID: "test",
529
+ linesStartAt1: true,
530
+ columnsStartAt1: true,
531
+ pathFormat: "path",
532
+ });
533
+
534
+ const event = await eventPromise;
535
+ expect(event.event).toBe("initialized");
536
+ });
537
+
538
+ it("rejects on adapter exit during request", async () => {
539
+ client = new DAPClient("node", path.join(__dirname, "../test-fixtures/crash-adapter.js"));
540
+ await client.spawn();
541
+ await expect(
542
+ client.sendRequest("initialize", { adapterID: "test" })
543
+ ).rejects.toThrow();
544
+ });
545
+
546
+ it("times out if adapter does not respond", async () => {
547
+ client = new DAPClient("node", path.join(__dirname, "../test-fixtures/silent-adapter.js"));
548
+ await client.spawn();
549
+ await expect(
550
+ client.sendRequest("initialize", { adapterID: "test" }, 500)
551
+ ).rejects.toThrow(/timeout/i);
552
+ });
553
+ });
554
+ ```
555
+
556
+ **Step 2: Create test fixture — mock adapter**
557
+
558
+ Create `src/test-fixtures/mock-adapter.js` — a minimal DAP adapter that handles `initialize`:
559
+
560
+ ```javascript
561
+ // src/test-fixtures/mock-adapter.js
562
+ // Minimal DAP mock adapter for testing
563
+ const TWO_CRLF = "\r\n\r\n";
564
+ let buffer = Buffer.alloc(0);
565
+ let contentLength = -1;
566
+
567
+ process.stdin.on("data", (chunk) => {
568
+ buffer = Buffer.concat([buffer, chunk]);
569
+ while (true) {
570
+ if (contentLength === -1) {
571
+ const idx = buffer.indexOf(TWO_CRLF);
572
+ if (idx === -1) return;
573
+ const header = buffer.subarray(0, idx).toString();
574
+ const match = header.match(/Content-Length:\s*(\d+)/i);
575
+ if (!match) return;
576
+ contentLength = parseInt(match[1], 10);
577
+ buffer = buffer.subarray(idx + TWO_CRLF.length);
578
+ }
579
+ if (buffer.length < contentLength) return;
580
+ const body = buffer.subarray(0, contentLength).toString();
581
+ buffer = buffer.subarray(contentLength);
582
+ contentLength = -1;
583
+ handleMessage(JSON.parse(body));
584
+ }
585
+ });
586
+
587
+ function send(msg) {
588
+ const json = JSON.stringify(msg);
589
+ const header = `Content-Length: ${Buffer.byteLength(json)}${TWO_CRLF}`;
590
+ process.stdout.write(header + json);
591
+ }
592
+
593
+ let seq = 1;
594
+ function handleMessage(msg) {
595
+ if (msg.type === "request") {
596
+ if (msg.command === "initialize") {
597
+ send({
598
+ seq: seq++, type: "response", request_seq: msg.seq,
599
+ command: "initialize", success: true,
600
+ body: { supportsConfigurationDoneRequest: true }
601
+ });
602
+ send({ seq: seq++, type: "event", event: "initialized", body: {} });
603
+ } else if (msg.command === "disconnect") {
604
+ send({
605
+ seq: seq++, type: "response", request_seq: msg.seq,
606
+ command: "disconnect", success: true
607
+ });
608
+ process.exit(0);
609
+ } else {
610
+ send({
611
+ seq: seq++, type: "response", request_seq: msg.seq,
612
+ command: msg.command, success: true, body: {}
613
+ });
614
+ }
615
+ }
616
+ }
617
+ ```
618
+
619
+ Create `src/test-fixtures/crash-adapter.js`:
620
+
621
+ ```javascript
622
+ // src/test-fixtures/crash-adapter.js
623
+ // Adapter that crashes immediately
624
+ process.exit(1);
625
+ ```
626
+
627
+ Create `src/test-fixtures/silent-adapter.js`:
628
+
629
+ ```javascript
630
+ // src/test-fixtures/silent-adapter.js
631
+ // Adapter that accepts input but never responds
632
+ process.stdin.on("data", () => {});
633
+ process.stdin.resume();
634
+ ```
635
+
636
+ **Step 3: Run tests to verify they fail**
637
+
638
+ Run: `npx vitest run src/dap/dap-client.test.ts`
639
+ Expected: FAIL — module not found
640
+
641
+ **Step 4: Implement DAPClient**
642
+
643
+ ```typescript
644
+ // src/dap/dap-client.ts
645
+ import { spawn, ChildProcess } from "node:child_process";
646
+ import { EventEmitter } from "node:events";
647
+ import { DebugProtocol } from "@vscode/debugprotocol";
648
+ import { encodeMessage, MessageParser } from "./protocol.js";
649
+
650
+ export class DAPClientError extends Error {
651
+ constructor(message: string) {
652
+ super(message);
653
+ this.name = "DAPClientError";
654
+ }
655
+ }
656
+
657
+ interface PendingRequest {
658
+ resolve: (response: DebugProtocol.Response) => void;
659
+ reject: (error: Error) => void;
660
+ timer?: ReturnType<typeof setTimeout>;
661
+ }
662
+
663
+ export class DAPClient extends EventEmitter {
664
+ private runtime: string;
665
+ private executable: string;
666
+ private args: string[];
667
+ private process?: ChildProcess;
668
+ private parser?: MessageParser;
669
+ private seq = 1;
670
+ private pending = new Map<number, PendingRequest>();
671
+ private stderrOutput: string[] = [];
672
+ private _exited = false;
673
+ private _exitCode?: number;
674
+
675
+ constructor(runtime: string, executable: string, args: string[] = []) {
676
+ super();
677
+ this.runtime = runtime;
678
+ this.executable = executable;
679
+ this.args = args;
680
+ }
681
+
682
+ async spawn(): Promise<void> {
683
+ return new Promise<void>((resolve, reject) => {
684
+ const runtimeArgs = this.runtime
685
+ ? [this.executable, ...this.args]
686
+ : this.args;
687
+ const cmd = this.runtime || this.executable;
688
+
689
+ this.process = spawn(cmd, runtimeArgs, {
690
+ stdio: ["pipe", "pipe", "pipe"],
691
+ });
692
+
693
+ this.parser = new MessageParser((msg) => this.handleMessage(msg));
694
+
695
+ this.process.stdout!.on("data", (data: Buffer) => {
696
+ this.parser!.feed(data);
697
+ });
698
+
699
+ this.process.stderr!.on("data", (data: Buffer) => {
700
+ this.stderrOutput.push(data.toString());
701
+ });
702
+
703
+ this.process.on("error", (err) => {
704
+ reject(new DAPClientError(`Failed to spawn adapter: ${err.message}`));
705
+ });
706
+
707
+ this.process.on("exit", (code, signal) => {
708
+ this._exited = true;
709
+ this._exitCode = code ?? undefined;
710
+ // Reject all pending requests
711
+ for (const [, req] of this.pending) {
712
+ if (req.timer) clearTimeout(req.timer);
713
+ req.reject(
714
+ new DAPClientError(
715
+ `Adapter exited (code=${code}, signal=${signal}). stderr: ${this.stderrOutput.join("")}`
716
+ )
717
+ );
718
+ }
719
+ this.pending.clear();
720
+ this.emit("exit", code, signal);
721
+ });
722
+
723
+ // Resolve once the process is spawned (stdout is piped)
724
+ this.process.stdout!.once("readable", () => resolve());
725
+ // Also resolve on a short timeout in case readable fires before we listen
726
+ setTimeout(() => resolve(), 100);
727
+ });
728
+ }
729
+
730
+ async sendRequest(
731
+ command: string,
732
+ args?: Record<string, unknown>,
733
+ timeout = 10000
734
+ ): Promise<DebugProtocol.Response> {
735
+ if (this._exited) {
736
+ throw new DAPClientError(
737
+ `Adapter has exited (code=${this._exitCode}). stderr: ${this.stderrOutput.join("")}`
738
+ );
739
+ }
740
+ if (!this.process?.stdin?.writable) {
741
+ throw new DAPClientError("Adapter stdin is not writable");
742
+ }
743
+
744
+ const seq = this.seq++;
745
+ const request: DebugProtocol.Request = {
746
+ seq,
747
+ type: "request",
748
+ command,
749
+ arguments: args,
750
+ };
751
+
752
+ return new Promise<DebugProtocol.Response>((resolve, reject) => {
753
+ const pending: PendingRequest = { resolve, reject };
754
+ if (timeout > 0) {
755
+ pending.timer = setTimeout(() => {
756
+ this.pending.delete(seq);
757
+ reject(new DAPClientError(`Request '${command}' timed out after ${timeout}ms`));
758
+ }, timeout);
759
+ }
760
+ this.pending.set(seq, pending);
761
+ this.process!.stdin!.write(encodeMessage(request));
762
+ });
763
+ }
764
+
765
+ private handleMessage(msg: DebugProtocol.ProtocolMessage): void {
766
+ if (msg.type === "response") {
767
+ const response = msg as DebugProtocol.Response;
768
+ const pending = this.pending.get(response.request_seq);
769
+ if (pending) {
770
+ if (pending.timer) clearTimeout(pending.timer);
771
+ this.pending.delete(response.request_seq);
772
+ if (response.success) {
773
+ pending.resolve(response);
774
+ } else {
775
+ pending.reject(
776
+ new DAPClientError(
777
+ `DAP error on '${response.command}': ${response.message || "unknown error"}`
778
+ )
779
+ );
780
+ }
781
+ }
782
+ } else if (msg.type === "event") {
783
+ const event = msg as DebugProtocol.Event;
784
+ this.emit(event.event, event);
785
+ this.emit("event", event);
786
+ }
787
+ }
788
+
789
+ get exited(): boolean {
790
+ return this._exited;
791
+ }
792
+
793
+ get stderr(): string {
794
+ return this.stderrOutput.join("");
795
+ }
796
+
797
+ async dispose(): Promise<void> {
798
+ if (this.process && !this._exited) {
799
+ this.process.stdin?.end();
800
+ this.process.kill("SIGTERM");
801
+ await new Promise<void>((resolve) => {
802
+ const timer = setTimeout(() => {
803
+ this.process?.kill("SIGKILL");
804
+ resolve();
805
+ }, 3000);
806
+ this.process!.on("exit", () => {
807
+ clearTimeout(timer);
808
+ resolve();
809
+ });
810
+ });
811
+ }
812
+ this.removeAllListeners();
813
+ }
814
+ }
815
+ ```
816
+
817
+ **Step 5: Run tests to verify they pass**
818
+
819
+ Run: `npx vitest run src/dap/dap-client.test.ts`
820
+ Expected: all 4 tests PASS
821
+
822
+ **Step 6: Commit**
823
+
824
+ ```bash
825
+ git add src/dap/dap-client.ts src/dap/dap-client.test.ts src/test-fixtures/
826
+ git commit -m "feat: add DAP client with process spawning, request/response, and event handling"
827
+ ```
828
+
829
+ ---
830
+
831
+ ## Task 6: Adapter Registry
832
+
833
+ **Files:**
834
+ - Create: `src/adapters/registry.ts`
835
+ - Create: `src/adapters/builtin.ts`
836
+ - Create: `src/adapters/registry.test.ts`
837
+
838
+ **Step 1: Write the failing tests**
839
+
840
+ ```typescript
841
+ // src/adapters/registry.test.ts
842
+ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
843
+ import { AdapterRegistry } from "./registry.js";
844
+ import { AdapterConfig } from "../dap/types.js";
845
+ import fs from "node:fs/promises";
846
+ import os from "node:os";
847
+ import path from "node:path";
848
+
849
+ describe("AdapterRegistry", () => {
850
+ let tmpDir: string;
851
+ let registry: AdapterRegistry;
852
+
853
+ beforeEach(async () => {
854
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "dap-mcp-test-"));
855
+ registry = new AdapterRegistry(tmpDir, tmpDir);
856
+ });
857
+
858
+ afterEach(async () => {
859
+ await fs.rm(tmpDir, { recursive: true, force: true });
860
+ });
861
+
862
+ it("returns built-in adapters", () => {
863
+ const adapters = registry.listAll();
864
+ expect(adapters.some((a) => a.type === "python")).toBe(true);
865
+ expect(adapters.some((a) => a.type === "node")).toBe(true);
866
+ });
867
+
868
+ it("registers a custom adapter", () => {
869
+ registry.register({
870
+ type: "ruby",
871
+ program: "/usr/bin/rdbg",
872
+ args: ["--open", "--dap"],
873
+ });
874
+ const ruby = registry.get("ruby");
875
+ expect(ruby).toBeDefined();
876
+ expect(ruby!.program).toBe("/usr/bin/rdbg");
877
+ });
878
+
879
+ it("project-local overrides built-in", () => {
880
+ registry.register({
881
+ type: "python",
882
+ program: "/custom/debugpy",
883
+ });
884
+ const python = registry.get("python");
885
+ expect(python!.program).toBe("/custom/debugpy");
886
+ });
887
+
888
+ it("saves and loads config from disk", async () => {
889
+ registry.register({
890
+ type: "ruby",
891
+ program: "/usr/bin/rdbg",
892
+ });
893
+ await registry.save("project");
894
+
895
+ const registry2 = new AdapterRegistry(tmpDir, tmpDir);
896
+ await registry2.load();
897
+ const ruby = registry2.get("ruby");
898
+ expect(ruby).toBeDefined();
899
+ expect(ruby!.program).toBe("/usr/bin/rdbg");
900
+ });
901
+ });
902
+ ```
903
+
904
+ **Step 2: Run tests to verify they fail**
905
+
906
+ Run: `npx vitest run src/adapters/registry.test.ts`
907
+ Expected: FAIL — module not found
908
+
909
+ **Step 3: Implement built-in adapter defaults**
910
+
911
+ ```typescript
912
+ // src/adapters/builtin.ts
913
+ import { AdapterConfig } from "../dap/types.js";
914
+
915
+ export const BUILTIN_ADAPTERS: AdapterConfig[] = [
916
+ {
917
+ type: "python",
918
+ runtime: "python3",
919
+ program: "-m",
920
+ args: ["debugpy.adapter"],
921
+ installHint: "pip install debugpy",
922
+ },
923
+ {
924
+ type: "node",
925
+ runtime: "node",
926
+ program: "", // Will be resolved at runtime; js-debug needs special handling
927
+ args: [],
928
+ installHint: "npm install -g @vscode/js-debug",
929
+ },
930
+ {
931
+ type: "go",
932
+ program: "dlv",
933
+ args: ["dap"],
934
+ installHint: "go install github.com/go-delve/delve/cmd/dlv@latest",
935
+ },
936
+ {
937
+ type: "cppdbg",
938
+ program: "lldb-dap",
939
+ args: [],
940
+ installHint: "apt install lldb (Debian/Ubuntu) or brew install llvm (macOS)",
941
+ },
942
+ ];
943
+ ```
944
+
945
+ **Step 4: Implement AdapterRegistry**
946
+
947
+ ```typescript
948
+ // src/adapters/registry.ts
949
+ import { AdapterConfig } from "../dap/types.js";
950
+ import { BUILTIN_ADAPTERS } from "./builtin.js";
951
+ import fs from "node:fs/promises";
952
+ import path from "node:path";
953
+
954
+ export class AdapterRegistry {
955
+ private builtins: Map<string, AdapterConfig>;
956
+ private global: Map<string, AdapterConfig> = new Map();
957
+ private project: Map<string, AdapterConfig> = new Map();
958
+ private globalDir: string;
959
+ private projectDir: string;
960
+
961
+ constructor(globalDir: string, projectDir: string) {
962
+ this.globalDir = globalDir;
963
+ this.projectDir = projectDir;
964
+ this.builtins = new Map(BUILTIN_ADAPTERS.map((a) => [a.type, a]));
965
+ }
966
+
967
+ get(type: string): AdapterConfig | undefined {
968
+ // Priority: project > global > builtin
969
+ return this.project.get(type) ?? this.global.get(type) ?? this.builtins.get(type);
970
+ }
971
+
972
+ register(config: AdapterConfig, scope: "global" | "project" = "project"): void {
973
+ const map = scope === "global" ? this.global : this.project;
974
+ map.set(config.type, config);
975
+ }
976
+
977
+ listAll(): Array<AdapterConfig & { source: "builtin" | "global" | "project" }> {
978
+ const result = new Map<string, AdapterConfig & { source: "builtin" | "global" | "project" }>();
979
+
980
+ for (const [type, config] of this.builtins) {
981
+ result.set(type, { ...config, source: "builtin" });
982
+ }
983
+ for (const [type, config] of this.global) {
984
+ result.set(type, { ...config, source: "global" });
985
+ }
986
+ for (const [type, config] of this.project) {
987
+ result.set(type, { ...config, source: "project" });
988
+ }
989
+
990
+ return Array.from(result.values());
991
+ }
992
+
993
+ async load(): Promise<void> {
994
+ this.global = await this.loadFile(path.join(this.globalDir, "adapters.json"));
995
+ this.project = await this.loadFile(path.join(this.projectDir, "adapters.json"));
996
+ }
997
+
998
+ async save(scope: "global" | "project"): Promise<void> {
999
+ const dir = scope === "global" ? this.globalDir : this.projectDir;
1000
+ const map = scope === "global" ? this.global : this.project;
1001
+ await fs.mkdir(dir, { recursive: true });
1002
+ const data = Object.fromEntries(map);
1003
+ await fs.writeFile(path.join(dir, "adapters.json"), JSON.stringify(data, null, 2));
1004
+ }
1005
+
1006
+ private async loadFile(filePath: string): Promise<Map<string, AdapterConfig>> {
1007
+ try {
1008
+ const data = await fs.readFile(filePath, "utf-8");
1009
+ const parsed = JSON.parse(data) as Record<string, AdapterConfig>;
1010
+ return new Map(Object.entries(parsed));
1011
+ } catch {
1012
+ return new Map();
1013
+ }
1014
+ }
1015
+ }
1016
+ ```
1017
+
1018
+ **Step 5: Run tests to verify they pass**
1019
+
1020
+ Run: `npx vitest run src/adapters/registry.test.ts`
1021
+ Expected: all 4 tests PASS
1022
+
1023
+ **Step 6: Commit**
1024
+
1025
+ ```bash
1026
+ git add src/adapters/registry.ts src/adapters/builtin.ts src/adapters/registry.test.ts
1027
+ git commit -m "feat: add adapter registry with built-in defaults and config persistence"
1028
+ ```
1029
+
1030
+ ---
1031
+
1032
+ ## Task 7: Debug Session
1033
+
1034
+ **Files:**
1035
+ - Create: `src/session/session.ts`
1036
+ - Create: `src/session/session.test.ts`
1037
+
1038
+ The Session wraps a DAPClient and adds: state tracking, breakpoint storage, output buffering, and composite state collection.
1039
+
1040
+ **Step 1: Write the failing tests**
1041
+
1042
+ ```typescript
1043
+ // src/session/session.test.ts
1044
+ import { describe, it, expect, afterEach } from "vitest";
1045
+ import { DebugSession } from "./session.js";
1046
+ import path from "node:path";
1047
+ import { fileURLToPath } from "node:url";
1048
+
1049
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1050
+ const MOCK_ADAPTER = path.join(__dirname, "../test-fixtures/mock-adapter.js");
1051
+
1052
+ describe("DebugSession", () => {
1053
+ let session: DebugSession | undefined;
1054
+
1055
+ afterEach(async () => {
1056
+ if (session) {
1057
+ try { await session.disconnect(); } catch { /* ignore */ }
1058
+ session = undefined;
1059
+ }
1060
+ });
1061
+
1062
+ it("initializes and tracks state", async () => {
1063
+ session = new DebugSession("test-1", {
1064
+ type: "mock",
1065
+ runtime: "node",
1066
+ program: MOCK_ADAPTER,
1067
+ });
1068
+ expect(session.state).toBe("idle");
1069
+ await session.initialize();
1070
+ expect(session.state).toBe("configuring");
1071
+ });
1072
+
1073
+ it("has a unique session ID", () => {
1074
+ session = new DebugSession("my-session", {
1075
+ type: "mock",
1076
+ runtime: "node",
1077
+ program: MOCK_ADAPTER,
1078
+ });
1079
+ expect(session.id).toBe("my-session");
1080
+ });
1081
+
1082
+ it("buffers output events", async () => {
1083
+ session = new DebugSession("test-2", {
1084
+ type: "mock",
1085
+ runtime: "node",
1086
+ program: MOCK_ADAPTER,
1087
+ });
1088
+ // Simulate output
1089
+ session.addOutput("stdout", "hello world");
1090
+ const output = session.getOutput();
1091
+ expect(output.output).toHaveLength(1);
1092
+ expect(output.output[0].text).toBe("hello world");
1093
+ expect(output.output[0].category).toBe("stdout");
1094
+ });
1095
+
1096
+ it("detects adapter crash", async () => {
1097
+ const crashAdapter = path.join(__dirname, "../test-fixtures/crash-adapter.js");
1098
+ session = new DebugSession("test-crash", {
1099
+ type: "mock",
1100
+ runtime: "node",
1101
+ program: crashAdapter,
1102
+ });
1103
+ await expect(session.initialize()).rejects.toThrow();
1104
+ expect(session.state).toBe("terminated");
1105
+ });
1106
+ });
1107
+ ```
1108
+
1109
+ **Step 2: Run tests to verify they fail**
1110
+
1111
+ Run: `npx vitest run src/session/session.test.ts`
1112
+ Expected: FAIL — module not found
1113
+
1114
+ **Step 3: Implement DebugSession**
1115
+
1116
+ ```typescript
1117
+ // src/session/session.ts
1118
+ import { DAPClient, DAPClientError } from "../dap/dap-client.js";
1119
+ import { RingBuffer } from "../dap/ring-buffer.js";
1120
+ import {
1121
+ SessionState,
1122
+ StoppedState,
1123
+ RunningState,
1124
+ DebugState,
1125
+ AdapterConfig,
1126
+ OutputEntry,
1127
+ BreakpointConfig,
1128
+ BreakpointResult,
1129
+ } from "../dap/types.js";
1130
+ import { DebugProtocol } from "@vscode/debugprotocol";
1131
+ import path from "node:path";
1132
+
1133
+ const MAX_OUTPUT_LINES = 10000;
1134
+ const DEFAULT_TIMEOUT = 30000;
1135
+
1136
+ export class DebugSession {
1137
+ readonly id: string;
1138
+ private client: DAPClient;
1139
+ private adapterConfig: AdapterConfig;
1140
+ private _state: SessionState = "idle";
1141
+ private outputBuffer = new RingBuffer<OutputEntry>(MAX_OUTPUT_LINES);
1142
+ private breakpointsMap = new Map<string, BreakpointConfig[]>();
1143
+ private capabilities: DebugProtocol.Capabilities = {};
1144
+ private stoppedThreadId?: number;
1145
+ private stoppedReason?: string;
1146
+ private launchConfig?: Record<string, unknown>;
1147
+ private launchMode: "launch" | "attach" = "launch";
1148
+
1149
+ // Promise-based event waiting
1150
+ private stoppedResolve?: (event: DebugProtocol.StoppedEvent) => void;
1151
+
1152
+ constructor(id: string, config: AdapterConfig) {
1153
+ this.id = id;
1154
+ this.adapterConfig = config;
1155
+
1156
+ const runtime = config.runtime || "";
1157
+ const program = config.program;
1158
+ const args = config.args || [];
1159
+
1160
+ this.client = new DAPClient(runtime, program, args);
1161
+
1162
+ this.client.on("event", (event: DebugProtocol.Event) => {
1163
+ this.handleEvent(event);
1164
+ });
1165
+
1166
+ this.client.on("exit", (_code: number, _signal: string) => {
1167
+ if (this._state !== "terminated") {
1168
+ this._state = "terminated";
1169
+ }
1170
+ });
1171
+ }
1172
+
1173
+ get state(): SessionState {
1174
+ return this._state;
1175
+ }
1176
+
1177
+ async initialize(): Promise<DebugProtocol.Capabilities> {
1178
+ try {
1179
+ await this.client.spawn();
1180
+ } catch (err) {
1181
+ this._state = "terminated";
1182
+ throw err;
1183
+ }
1184
+
1185
+ try {
1186
+ const response = await this.client.sendRequest("initialize", {
1187
+ adapterID: this.adapterConfig.type,
1188
+ clientID: "dap-mcp-server",
1189
+ clientName: "DAP MCP Server",
1190
+ linesStartAt1: true,
1191
+ columnsStartAt1: true,
1192
+ pathFormat: "path",
1193
+ });
1194
+ this.capabilities = response.body || {};
1195
+ this._state = "configuring";
1196
+ return this.capabilities;
1197
+ } catch (err) {
1198
+ this._state = "terminated";
1199
+ throw err;
1200
+ }
1201
+ }
1202
+
1203
+ async setBreakpoints(
1204
+ filePath: string,
1205
+ breakpoints: BreakpointConfig[]
1206
+ ): Promise<BreakpointResult[]> {
1207
+ const absPath = path.resolve(filePath);
1208
+ this.breakpointsMap.set(absPath, breakpoints);
1209
+
1210
+ const response = await this.client.sendRequest("setBreakpoints", {
1211
+ source: { path: absPath },
1212
+ breakpoints: breakpoints.map((bp) => ({
1213
+ line: bp.line,
1214
+ condition: bp.condition,
1215
+ hitCondition: bp.hitCondition,
1216
+ logMessage: bp.logMessage,
1217
+ })),
1218
+ });
1219
+
1220
+ return (response.body?.breakpoints || []).map((bp: any) => ({
1221
+ id: bp.id ?? 0,
1222
+ verified: bp.verified ?? false,
1223
+ line: bp.line ?? 0,
1224
+ message: bp.message,
1225
+ }));
1226
+ }
1227
+
1228
+ async setExceptionBreakpoints(filters: string[]): Promise<void> {
1229
+ await this.client.sendRequest("setExceptionBreakpoints", { filters });
1230
+ }
1231
+
1232
+ async configurationDone(): Promise<void> {
1233
+ if (this.capabilities.supportsConfigurationDoneRequest) {
1234
+ await this.client.sendRequest("configurationDone");
1235
+ }
1236
+ }
1237
+
1238
+ async launch(config: Record<string, unknown>): Promise<void> {
1239
+ this.launchConfig = config;
1240
+ this.launchMode = "launch";
1241
+ await this.client.sendRequest("launch", config);
1242
+ this._state = "running";
1243
+ }
1244
+
1245
+ async attach(config: Record<string, unknown>): Promise<void> {
1246
+ this.launchConfig = config;
1247
+ this.launchMode = "attach";
1248
+ await this.client.sendRequest("attach", config);
1249
+ this._state = "running";
1250
+ }
1251
+
1252
+ async continueExecution(threadId?: number): Promise<void> {
1253
+ const tid = threadId ?? this.stoppedThreadId ?? 1;
1254
+ await this.client.sendRequest("continue", { threadId: tid });
1255
+ this._state = "running";
1256
+ }
1257
+
1258
+ async next(threadId?: number): Promise<void> {
1259
+ const tid = threadId ?? this.stoppedThreadId ?? 1;
1260
+ await this.client.sendRequest("next", { threadId: tid });
1261
+ this._state = "running";
1262
+ }
1263
+
1264
+ async stepIn(threadId?: number): Promise<void> {
1265
+ const tid = threadId ?? this.stoppedThreadId ?? 1;
1266
+ await this.client.sendRequest("stepIn", { threadId: tid });
1267
+ this._state = "running";
1268
+ }
1269
+
1270
+ async stepOut(threadId?: number): Promise<void> {
1271
+ const tid = threadId ?? this.stoppedThreadId ?? 1;
1272
+ await this.client.sendRequest("stepOut", { threadId: tid });
1273
+ this._state = "running";
1274
+ }
1275
+
1276
+ async pause(threadId?: number): Promise<void> {
1277
+ const tid = threadId ?? this.stoppedThreadId ?? 1;
1278
+ await this.client.sendRequest("pause", { threadId: tid });
1279
+ }
1280
+
1281
+ async waitForStopped(timeout = DEFAULT_TIMEOUT): Promise<DebugProtocol.StoppedEvent> {
1282
+ if (this._state === "stopped") {
1283
+ // Already stopped — build a synthetic event
1284
+ return {
1285
+ seq: 0,
1286
+ type: "event",
1287
+ event: "stopped",
1288
+ body: {
1289
+ reason: this.stoppedReason || "unknown",
1290
+ threadId: this.stoppedThreadId,
1291
+ },
1292
+ } as DebugProtocol.StoppedEvent;
1293
+ }
1294
+
1295
+ return new Promise<DebugProtocol.StoppedEvent>((resolve, reject) => {
1296
+ const timer = setTimeout(() => {
1297
+ this.stoppedResolve = undefined;
1298
+ reject(new Error("timeout"));
1299
+ }, timeout);
1300
+
1301
+ this.stoppedResolve = (event) => {
1302
+ clearTimeout(timer);
1303
+ this.stoppedResolve = undefined;
1304
+ resolve(event);
1305
+ };
1306
+ });
1307
+ }
1308
+
1309
+ async getThreads(): Promise<DebugProtocol.Thread[]> {
1310
+ const response = await this.client.sendRequest("threads");
1311
+ return response.body?.threads || [];
1312
+ }
1313
+
1314
+ async getStackTrace(
1315
+ threadId: number,
1316
+ depth = 10
1317
+ ): Promise<DebugProtocol.StackFrame[]> {
1318
+ const response = await this.client.sendRequest("stackTrace", {
1319
+ threadId,
1320
+ startFrame: 0,
1321
+ levels: depth,
1322
+ });
1323
+ return response.body?.stackFrames || [];
1324
+ }
1325
+
1326
+ async getScopes(frameId: number): Promise<DebugProtocol.Scope[]> {
1327
+ const response = await this.client.sendRequest("scopes", { frameId });
1328
+ return response.body?.scopes || [];
1329
+ }
1330
+
1331
+ async getVariables(
1332
+ variablesReference: number,
1333
+ depth = 1
1334
+ ): Promise<Array<{
1335
+ name: string;
1336
+ value: string;
1337
+ type?: string;
1338
+ hasChildren: boolean;
1339
+ variablesReference: number;
1340
+ }>> {
1341
+ const response = await this.client.sendRequest("variables", {
1342
+ variablesReference,
1343
+ });
1344
+ const vars = (response.body?.variables || []).map((v: any) => ({
1345
+ name: v.name,
1346
+ value: v.value,
1347
+ type: v.type,
1348
+ hasChildren: (v.variablesReference ?? 0) > 0,
1349
+ variablesReference: v.variablesReference ?? 0,
1350
+ }));
1351
+
1352
+ // Recursively expand children if depth > 1
1353
+ if (depth > 1) {
1354
+ for (const v of vars) {
1355
+ if (v.hasChildren) {
1356
+ const children = await this.getVariables(v.variablesReference, depth - 1);
1357
+ // Attach children as a stringified nested value
1358
+ v.value = `${v.value} { ${children.map((c) => `${c.name}: ${c.value}`).join(", ")} }`;
1359
+ }
1360
+ }
1361
+ }
1362
+
1363
+ return vars;
1364
+ }
1365
+
1366
+ async evaluate(
1367
+ expression: string,
1368
+ frameId?: number,
1369
+ context: "repl" | "watch" | "hover" = "repl"
1370
+ ): Promise<{ result: string; type?: string; hasChildren: boolean; variablesReference: number }> {
1371
+ const args: Record<string, unknown> = { expression, context };
1372
+ if (frameId !== undefined) args.frameId = frameId;
1373
+
1374
+ const response = await this.client.sendRequest("evaluate", args);
1375
+ return {
1376
+ result: response.body?.result ?? "",
1377
+ type: response.body?.type,
1378
+ hasChildren: (response.body?.variablesReference ?? 0) > 0,
1379
+ variablesReference: response.body?.variablesReference ?? 0,
1380
+ };
1381
+ }
1382
+
1383
+ async collectState(
1384
+ threadId?: number,
1385
+ depth = 10,
1386
+ variableDepth = 1
1387
+ ): Promise<StoppedState> {
1388
+ const tid = threadId ?? this.stoppedThreadId ?? 1;
1389
+
1390
+ const threads = await this.getThreads();
1391
+ const frames = await this.getStackTrace(tid, depth);
1392
+
1393
+ const topFrame = frames[0];
1394
+ const location = topFrame
1395
+ ? {
1396
+ file: topFrame.source?.path || topFrame.source?.name || "<unknown>",
1397
+ line: topFrame.line,
1398
+ column: topFrame.column,
1399
+ functionName: topFrame.name,
1400
+ }
1401
+ : { file: "<unknown>", line: 0 };
1402
+
1403
+ // Collect scopes and variables for the top frame
1404
+ const scopes: StoppedState["scopes"] = [];
1405
+ if (topFrame) {
1406
+ const rawScopes = await this.getScopes(topFrame.id);
1407
+ for (const scope of rawScopes) {
1408
+ const variables = await this.getVariables(scope.variablesReference, variableDepth);
1409
+ scopes.push({ name: scope.name, variables });
1410
+ }
1411
+ }
1412
+
1413
+ // Drain any pending output
1414
+ const output = this.outputBuffer.getAll().map((e) => e.text);
1415
+
1416
+ return {
1417
+ status: "stopped",
1418
+ reason: this.stoppedReason || "unknown",
1419
+ location,
1420
+ threads: threads.map((t) => ({
1421
+ id: t.id,
1422
+ name: t.name,
1423
+ stopped: t.id === tid,
1424
+ })),
1425
+ stackTrace: frames.map((f) => ({
1426
+ id: f.id,
1427
+ name: f.name,
1428
+ file: f.source?.path || f.source?.name || "<unknown>",
1429
+ line: f.line,
1430
+ column: f.column,
1431
+ })),
1432
+ scopes,
1433
+ output: output.length > 0 ? output : undefined,
1434
+ };
1435
+ }
1436
+
1437
+ addOutput(category: string, text: string): void {
1438
+ this.outputBuffer.push({
1439
+ category,
1440
+ text,
1441
+ timestamp: new Date().toISOString(),
1442
+ });
1443
+ }
1444
+
1445
+ getOutput(
1446
+ category?: string,
1447
+ clear = false
1448
+ ): { output: OutputEntry[]; truncated: boolean } {
1449
+ const entries = category
1450
+ ? this.outputBuffer.getFiltered((e) => e.category === category)
1451
+ : this.outputBuffer.getAll();
1452
+ const truncated = this.outputBuffer.truncated;
1453
+ if (clear) this.outputBuffer.clear();
1454
+ return { output: entries, truncated };
1455
+ }
1456
+
1457
+ async disconnect(terminateDebuggee?: boolean): Promise<void> {
1458
+ const terminate = terminateDebuggee ?? (this.launchMode === "launch");
1459
+ try {
1460
+ await this.client.sendRequest("disconnect", { terminateDebuggee: terminate }, 5000);
1461
+ } catch {
1462
+ // Ignore — adapter may have already exited
1463
+ }
1464
+ this._state = "terminated";
1465
+ await this.client.dispose();
1466
+ }
1467
+
1468
+ getLaunchConfig(): Record<string, unknown> | undefined {
1469
+ return this.launchConfig;
1470
+ }
1471
+
1472
+ getLaunchMode(): "launch" | "attach" {
1473
+ return this.launchMode;
1474
+ }
1475
+
1476
+ getAdapterConfig(): AdapterConfig {
1477
+ return this.adapterConfig;
1478
+ }
1479
+
1480
+ private handleEvent(event: DebugProtocol.Event): void {
1481
+ switch (event.event) {
1482
+ case "stopped":
1483
+ this._state = "stopped";
1484
+ this.stoppedThreadId = event.body?.threadId;
1485
+ this.stoppedReason = event.body?.reason;
1486
+ if (this.stoppedResolve) {
1487
+ this.stoppedResolve(event as DebugProtocol.StoppedEvent);
1488
+ }
1489
+ break;
1490
+ case "continued":
1491
+ this._state = "running";
1492
+ break;
1493
+ case "output":
1494
+ if (event.body?.output) {
1495
+ this.addOutput(
1496
+ event.body.category || "console",
1497
+ event.body.output
1498
+ );
1499
+ }
1500
+ break;
1501
+ case "terminated":
1502
+ case "exited":
1503
+ this._state = "terminated";
1504
+ break;
1505
+ }
1506
+ }
1507
+ }
1508
+ ```
1509
+
1510
+ **Step 4: Run tests to verify they pass**
1511
+
1512
+ Run: `npx vitest run src/session/session.test.ts`
1513
+ Expected: all 4 tests PASS
1514
+
1515
+ **Step 5: Commit**
1516
+
1517
+ ```bash
1518
+ git add src/session/session.ts src/session/session.test.ts
1519
+ git commit -m "feat: add DebugSession with state tracking, output buffering, and composite state collection"
1520
+ ```
1521
+
1522
+ ---
1523
+
1524
+ ## Task 8: Session Manager
1525
+
1526
+ **Files:**
1527
+ - Create: `src/session/session-manager.ts`
1528
+ - Create: `src/session/session-manager.test.ts`
1529
+
1530
+ **Step 1: Write the failing tests**
1531
+
1532
+ ```typescript
1533
+ // src/session/session-manager.test.ts
1534
+ import { describe, it, expect } from "vitest";
1535
+ import { SessionManager } from "./session-manager.js";
1536
+
1537
+ describe("SessionManager", () => {
1538
+ it("generates unique session IDs", () => {
1539
+ const manager = new SessionManager();
1540
+ const id1 = manager.generateId();
1541
+ const id2 = manager.generateId();
1542
+ expect(id1).not.toBe(id2);
1543
+ });
1544
+
1545
+ it("stores and retrieves sessions by ID", () => {
1546
+ const manager = new SessionManager();
1547
+ const mockSession = { id: "test-1" } as any;
1548
+ manager.add(mockSession);
1549
+ expect(manager.get("test-1")).toBe(mockSession);
1550
+ });
1551
+
1552
+ it("removes sessions", () => {
1553
+ const manager = new SessionManager();
1554
+ const mockSession = { id: "test-1" } as any;
1555
+ manager.add(mockSession);
1556
+ manager.remove("test-1");
1557
+ expect(manager.get("test-1")).toBeUndefined();
1558
+ });
1559
+
1560
+ it("resolves single session when no ID provided", () => {
1561
+ const manager = new SessionManager();
1562
+ const mockSession = { id: "test-1" } as any;
1563
+ manager.add(mockSession);
1564
+ expect(manager.resolve(undefined)).toBe(mockSession);
1565
+ });
1566
+
1567
+ it("throws when resolving without ID and multiple sessions exist", () => {
1568
+ const manager = new SessionManager();
1569
+ manager.add({ id: "s1" } as any);
1570
+ manager.add({ id: "s2" } as any);
1571
+ expect(() => manager.resolve(undefined)).toThrow(/multiple/i);
1572
+ });
1573
+
1574
+ it("throws when resolving non-existent session", () => {
1575
+ const manager = new SessionManager();
1576
+ expect(() => manager.resolve("nope")).toThrow(/not found/i);
1577
+ });
1578
+
1579
+ it("lists all sessions", () => {
1580
+ const manager = new SessionManager();
1581
+ manager.add({ id: "s1" } as any);
1582
+ manager.add({ id: "s2" } as any);
1583
+ expect(manager.listAll()).toHaveLength(2);
1584
+ });
1585
+ });
1586
+ ```
1587
+
1588
+ **Step 2: Run tests to verify they fail**
1589
+
1590
+ Run: `npx vitest run src/session/session-manager.test.ts`
1591
+ Expected: FAIL
1592
+
1593
+ **Step 3: Implement SessionManager**
1594
+
1595
+ ```typescript
1596
+ // src/session/session-manager.ts
1597
+ import { DebugSession } from "./session.js";
1598
+ import { randomUUID } from "node:crypto";
1599
+
1600
+ export class SessionManager {
1601
+ private sessions = new Map<string, DebugSession>();
1602
+
1603
+ generateId(): string {
1604
+ return randomUUID().slice(0, 8);
1605
+ }
1606
+
1607
+ add(session: DebugSession): void {
1608
+ this.sessions.set(session.id, session);
1609
+ }
1610
+
1611
+ get(id: string): DebugSession | undefined {
1612
+ return this.sessions.get(id);
1613
+ }
1614
+
1615
+ remove(id: string): void {
1616
+ this.sessions.delete(id);
1617
+ }
1618
+
1619
+ resolve(sessionId: string | undefined): DebugSession {
1620
+ if (sessionId) {
1621
+ const session = this.sessions.get(sessionId);
1622
+ if (!session) {
1623
+ const available = Array.from(this.sessions.keys()).join(", ");
1624
+ throw new Error(
1625
+ `Session '${sessionId}' not found. Available sessions: ${available || "none"}`
1626
+ );
1627
+ }
1628
+ return session;
1629
+ }
1630
+
1631
+ if (this.sessions.size === 0) {
1632
+ throw new Error("No active debug sessions. Use debug_launch or debug_attach first.");
1633
+ }
1634
+ if (this.sessions.size === 1) {
1635
+ return this.sessions.values().next().value!;
1636
+ }
1637
+
1638
+ const available = Array.from(this.sessions.keys()).join(", ");
1639
+ throw new Error(
1640
+ `Multiple active sessions: ${available}. Specify sessionId.`
1641
+ );
1642
+ }
1643
+
1644
+ listAll(): DebugSession[] {
1645
+ return Array.from(this.sessions.values());
1646
+ }
1647
+ }
1648
+ ```
1649
+
1650
+ **Step 4: Run tests to verify they pass**
1651
+
1652
+ Run: `npx vitest run src/session/session-manager.test.ts`
1653
+ Expected: all 7 tests PASS
1654
+
1655
+ **Step 5: Commit**
1656
+
1657
+ ```bash
1658
+ git add src/session/session-manager.ts src/session/session-manager.test.ts
1659
+ git commit -m "feat: add session manager with auto-resolve for single sessions"
1660
+ ```
1661
+
1662
+ ---
1663
+
1664
+ ## Task 9: MCP Server — Core Setup and Adapter Tools
1665
+
1666
+ **Files:**
1667
+ - Create: `src/server.ts`
1668
+ - Modify: `src/index.ts`
1669
+
1670
+ This task sets up the MCP server with the two adapter management tools (`list_adapters`, `configure_adapter`) and the `dap://sessions` resource.
1671
+
1672
+ **Step 1: Implement the MCP server core**
1673
+
1674
+ ```typescript
1675
+ // src/server.ts
1676
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
1677
+ import { z } from "zod";
1678
+ import { SessionManager } from "./session/session-manager.js";
1679
+ import { AdapterRegistry } from "./adapters/registry.js";
1680
+ import { DebugSession } from "./session/session.js";
1681
+ import { AdapterConfig, DebugState } from "./dap/types.js";
1682
+ import os from "node:os";
1683
+ import path from "node:path";
1684
+ import { execFile } from "node:child_process";
1685
+ import { promisify } from "node:util";
1686
+
1687
+ const execFileAsync = promisify(execFile);
1688
+
1689
+ export function createServer(): McpServer {
1690
+ const globalConfigDir = path.join(os.homedir(), ".dap-mcp");
1691
+ const projectConfigDir = path.join(process.cwd(), ".dap-mcp");
1692
+
1693
+ const registry = new AdapterRegistry(globalConfigDir, projectConfigDir);
1694
+ const sessions = new SessionManager();
1695
+
1696
+ // Load config asynchronously on first use
1697
+ let configLoaded = false;
1698
+ async function ensureConfig() {
1699
+ if (!configLoaded) {
1700
+ await registry.load();
1701
+ configLoaded = true;
1702
+ }
1703
+ }
1704
+
1705
+ const server = new McpServer({
1706
+ name: "dap-mcp-server",
1707
+ version: "0.1.0",
1708
+ });
1709
+
1710
+ // === Tool: list_adapters ===
1711
+ server.tool(
1712
+ "list_adapters",
1713
+ "List available debug adapter configurations with availability status",
1714
+ {},
1715
+ async () => {
1716
+ await ensureConfig();
1717
+ const adapters = registry.listAll();
1718
+
1719
+ const results = await Promise.all(
1720
+ adapters.map(async (adapter) => {
1721
+ let available = false;
1722
+ try {
1723
+ if (adapter.runtime) {
1724
+ await execFileAsync("which", [adapter.runtime]);
1725
+ available = true;
1726
+ } else if (adapter.program) {
1727
+ await execFileAsync("which", [adapter.program]);
1728
+ available = true;
1729
+ }
1730
+ } catch {
1731
+ available = false;
1732
+ }
1733
+ return {
1734
+ type: adapter.type,
1735
+ source: adapter.source,
1736
+ available,
1737
+ program: adapter.runtime
1738
+ ? `${adapter.runtime} ${adapter.program}`
1739
+ : adapter.program,
1740
+ installHint: adapter.installHint,
1741
+ };
1742
+ })
1743
+ );
1744
+
1745
+ return {
1746
+ content: [{ type: "text" as const, text: JSON.stringify(results, null, 2) }],
1747
+ };
1748
+ }
1749
+ );
1750
+
1751
+ // === Tool: configure_adapter ===
1752
+ server.tool(
1753
+ "configure_adapter",
1754
+ "Register or update a debug adapter configuration",
1755
+ {
1756
+ type: z.string().describe("Adapter type identifier (e.g., 'ruby', 'python')"),
1757
+ runtime: z.string().optional().describe("Runtime command (e.g., 'node', 'python3')"),
1758
+ program: z.string().describe("Path to adapter binary or module"),
1759
+ args: z.array(z.string()).optional().describe("Arguments to pass when spawning the adapter"),
1760
+ launchDefaults: z.record(z.unknown()).optional().describe("Default launch configuration"),
1761
+ scope: z.enum(["global", "project"]).optional().describe("Where to save the config (default: project)"),
1762
+ },
1763
+ async ({ type, runtime, program, args, launchDefaults, scope }) => {
1764
+ await ensureConfig();
1765
+ const config: AdapterConfig = { type, program, runtime, args, launchDefaults };
1766
+ const targetScope = scope || "project";
1767
+ registry.register(config, targetScope);
1768
+ await registry.save(targetScope);
1769
+
1770
+ return {
1771
+ content: [{ type: "text" as const, text: `Adapter '${type}' configured (${targetScope}).` }],
1772
+ };
1773
+ }
1774
+ );
1775
+
1776
+ // === Tool: debug_launch ===
1777
+ server.tool(
1778
+ "debug_launch",
1779
+ "Start a new debug session by launching a program. Sets breakpoints and stops on entry by default.",
1780
+ {
1781
+ adapter: z.string().describe("Adapter type: 'python', 'node', 'go', 'cppdbg', or custom"),
1782
+ program: z.string().describe("Path to the program to debug"),
1783
+ args: z.array(z.string()).optional().describe("Program arguments"),
1784
+ cwd: z.string().optional().describe("Working directory"),
1785
+ env: z.record(z.string()).optional().describe("Environment variables"),
1786
+ stopOnEntry: z.boolean().optional().describe("Stop on entry (default: true)"),
1787
+ breakpoints: z
1788
+ .array(
1789
+ z.object({
1790
+ file: z.string(),
1791
+ lines: z.array(
1792
+ z.object({
1793
+ line: z.number(),
1794
+ condition: z.string().optional(),
1795
+ hitCondition: z.string().optional(),
1796
+ logMessage: z.string().optional(),
1797
+ })
1798
+ ),
1799
+ })
1800
+ )
1801
+ .optional()
1802
+ .describe("Initial breakpoints"),
1803
+ exceptionBreakpoints: z.array(z.string()).optional().describe("Exception breakpoint filters"),
1804
+ adapterConfig: z.record(z.unknown()).optional().describe("Additional adapter-specific launch config"),
1805
+ },
1806
+ async ({ adapter, program, args, cwd, env, stopOnEntry, breakpoints, exceptionBreakpoints, adapterConfig }) => {
1807
+ try {
1808
+ await ensureConfig();
1809
+ const adapterCfg = registry.get(adapter);
1810
+ if (!adapterCfg) {
1811
+ return {
1812
+ content: [{ type: "text" as const, text: `Unknown adapter '${adapter}'. Run list_adapters to see available adapters.` }],
1813
+ isError: true,
1814
+ };
1815
+ }
1816
+
1817
+ const sessionId = sessions.generateId();
1818
+ const session = new DebugSession(sessionId, adapterCfg);
1819
+ sessions.add(session);
1820
+
1821
+ // Initialize
1822
+ await session.initialize();
1823
+
1824
+ // Wait for 'initialized' event (brief wait)
1825
+ await new Promise((r) => setTimeout(r, 200));
1826
+
1827
+ // Set breakpoints
1828
+ if (breakpoints) {
1829
+ for (const bp of breakpoints) {
1830
+ await session.setBreakpoints(
1831
+ bp.file,
1832
+ bp.lines.map((l) => ({
1833
+ line: l.line,
1834
+ condition: l.condition,
1835
+ hitCondition: l.hitCondition,
1836
+ logMessage: l.logMessage,
1837
+ }))
1838
+ );
1839
+ }
1840
+ }
1841
+
1842
+ // Set exception breakpoints
1843
+ if (exceptionBreakpoints) {
1844
+ await session.setExceptionBreakpoints(exceptionBreakpoints);
1845
+ }
1846
+
1847
+ // Configuration done
1848
+ await session.configurationDone();
1849
+
1850
+ // Launch
1851
+ const launchArgs: Record<string, unknown> = {
1852
+ program: path.resolve(program),
1853
+ args: args || [],
1854
+ cwd: cwd || process.cwd(),
1855
+ stopOnEntry: stopOnEntry !== false,
1856
+ ...adapterCfg.launchDefaults,
1857
+ ...adapterConfig,
1858
+ };
1859
+ if (env) launchArgs.env = env;
1860
+
1861
+ await session.launch(launchArgs);
1862
+
1863
+ // Wait for stopped event if stopOnEntry
1864
+ if (stopOnEntry !== false) {
1865
+ try {
1866
+ await session.waitForStopped(10000);
1867
+ const state = await session.collectState();
1868
+ return {
1869
+ content: [{ type: "text" as const, text: JSON.stringify({ sessionId, ...state }, null, 2) }],
1870
+ };
1871
+ } catch {
1872
+ return {
1873
+ content: [{ type: "text" as const, text: JSON.stringify({ sessionId, status: "running", output: [] }, null, 2) }],
1874
+ };
1875
+ }
1876
+ }
1877
+
1878
+ return {
1879
+ content: [{ type: "text" as const, text: JSON.stringify({ sessionId, status: "running" }, null, 2) }],
1880
+ };
1881
+ } catch (err: any) {
1882
+ return {
1883
+ content: [{ type: "text" as const, text: `Launch failed: ${err.message}` }],
1884
+ isError: true,
1885
+ };
1886
+ }
1887
+ }
1888
+ );
1889
+
1890
+ // === Tool: debug_attach ===
1891
+ server.tool(
1892
+ "debug_attach",
1893
+ "Attach to a running process for debugging",
1894
+ {
1895
+ adapter: z.string().describe("Adapter type"),
1896
+ processId: z.number().optional().describe("Process ID to attach to"),
1897
+ host: z.string().optional().describe("Host for remote attach"),
1898
+ port: z.number().optional().describe("Port for remote attach"),
1899
+ adapterConfig: z.record(z.unknown()).optional().describe("Additional adapter-specific config"),
1900
+ },
1901
+ async ({ adapter, processId, host, port, adapterConfig }) => {
1902
+ try {
1903
+ await ensureConfig();
1904
+ const adapterCfg = registry.get(adapter);
1905
+ if (!adapterCfg) {
1906
+ return {
1907
+ content: [{ type: "text" as const, text: `Unknown adapter '${adapter}'.` }],
1908
+ isError: true,
1909
+ };
1910
+ }
1911
+
1912
+ const sessionId = sessions.generateId();
1913
+ const session = new DebugSession(sessionId, adapterCfg);
1914
+ sessions.add(session);
1915
+
1916
+ await session.initialize();
1917
+ await new Promise((r) => setTimeout(r, 200));
1918
+ await session.configurationDone();
1919
+
1920
+ const attachArgs: Record<string, unknown> = { ...adapterConfig };
1921
+ if (processId !== undefined) attachArgs.processId = processId;
1922
+ if (host) attachArgs.host = host;
1923
+ if (port) attachArgs.port = port;
1924
+
1925
+ await session.attach(attachArgs);
1926
+
1927
+ return {
1928
+ content: [{ type: "text" as const, text: JSON.stringify({ sessionId, status: "running" }, null, 2) }],
1929
+ };
1930
+ } catch (err: any) {
1931
+ return {
1932
+ content: [{ type: "text" as const, text: `Attach failed: ${err.message}` }],
1933
+ isError: true,
1934
+ };
1935
+ }
1936
+ }
1937
+ );
1938
+
1939
+ // === Tool: set_breakpoints ===
1940
+ server.tool(
1941
+ "set_breakpoints",
1942
+ "Set or replace breakpoints in a source file. Replaces all breakpoints in the specified file (DAP semantics).",
1943
+ {
1944
+ sessionId: z.string().optional().describe("Session ID (optional if single session)"),
1945
+ file: z.string().describe("Source file path"),
1946
+ breakpoints: z.array(
1947
+ z.object({
1948
+ line: z.number(),
1949
+ condition: z.string().optional(),
1950
+ hitCondition: z.string().optional(),
1951
+ logMessage: z.string().optional(),
1952
+ })
1953
+ ).describe("Breakpoints to set"),
1954
+ },
1955
+ async ({ sessionId, file, breakpoints }) => {
1956
+ try {
1957
+ const session = sessions.resolve(sessionId);
1958
+ const results = await session.setBreakpoints(file, breakpoints);
1959
+ return {
1960
+ content: [{ type: "text" as const, text: JSON.stringify({ breakpoints: results }, null, 2) }],
1961
+ };
1962
+ } catch (err: any) {
1963
+ return {
1964
+ content: [{ type: "text" as const, text: err.message }],
1965
+ isError: true,
1966
+ };
1967
+ }
1968
+ }
1969
+ );
1970
+
1971
+ // === Tool: set_exception_breakpoints ===
1972
+ server.tool(
1973
+ "set_exception_breakpoints",
1974
+ "Configure which exceptions cause the debugger to break",
1975
+ {
1976
+ sessionId: z.string().optional().describe("Session ID (optional if single session)"),
1977
+ filters: z.array(z.string()).describe("Exception breakpoint filters (adapter-specific, e.g., ['uncaught', 'raised'])"),
1978
+ },
1979
+ async ({ sessionId, filters }) => {
1980
+ try {
1981
+ const session = sessions.resolve(sessionId);
1982
+ await session.setExceptionBreakpoints(filters);
1983
+ return {
1984
+ content: [{ type: "text" as const, text: JSON.stringify({ filters }, null, 2) }],
1985
+ };
1986
+ } catch (err: any) {
1987
+ return {
1988
+ content: [{ type: "text" as const, text: err.message }],
1989
+ isError: true,
1990
+ };
1991
+ }
1992
+ }
1993
+ );
1994
+
1995
+ // === Tool: get_state ===
1996
+ server.tool(
1997
+ "get_state",
1998
+ "Get current debuggee state. When stopped: threads, stack trace, scopes, and variables. When running: status and buffered output.",
1999
+ {
2000
+ sessionId: z.string().optional().describe("Session ID (optional if single session)"),
2001
+ threadId: z.number().optional().describe("Thread to inspect (default: stopped thread)"),
2002
+ depth: z.number().optional().describe("Stack frames to return (default: 10)"),
2003
+ variableDepth: z.number().optional().describe("Nested variable expansion depth (default: 1)"),
2004
+ timeout: z.number().optional().describe("Ms to wait if running (default: 0, return immediately)"),
2005
+ },
2006
+ async ({ sessionId, threadId, depth, variableDepth, timeout }) => {
2007
+ try {
2008
+ const session = sessions.resolve(sessionId);
2009
+
2010
+ if (session.state === "running" && timeout && timeout > 0) {
2011
+ try {
2012
+ await session.waitForStopped(timeout);
2013
+ } catch {
2014
+ const { output } = session.getOutput();
2015
+ return {
2016
+ content: [{
2017
+ type: "text" as const,
2018
+ text: JSON.stringify({ status: "running", output: output.map((o) => o.text) }, null, 2),
2019
+ }],
2020
+ };
2021
+ }
2022
+ }
2023
+
2024
+ if (session.state === "stopped") {
2025
+ const state = await session.collectState(threadId, depth ?? 10, variableDepth ?? 1);
2026
+ return {
2027
+ content: [{ type: "text" as const, text: JSON.stringify(state, null, 2) }],
2028
+ };
2029
+ }
2030
+
2031
+ if (session.state === "terminated") {
2032
+ const { output } = session.getOutput();
2033
+ return {
2034
+ content: [{
2035
+ type: "text" as const,
2036
+ text: JSON.stringify({ status: "terminated", output: output.map((o) => o.text) }, null, 2),
2037
+ }],
2038
+ };
2039
+ }
2040
+
2041
+ // Running
2042
+ const { output } = session.getOutput();
2043
+ return {
2044
+ content: [{
2045
+ type: "text" as const,
2046
+ text: JSON.stringify({ status: "running", output: output.map((o) => o.text) }, null, 2),
2047
+ }],
2048
+ };
2049
+ } catch (err: any) {
2050
+ return {
2051
+ content: [{ type: "text" as const, text: err.message }],
2052
+ isError: true,
2053
+ };
2054
+ }
2055
+ }
2056
+ );
2057
+
2058
+ // === Tool: evaluate ===
2059
+ server.tool(
2060
+ "evaluate",
2061
+ "Evaluate an expression in the debuggee's context (only works when stopped)",
2062
+ {
2063
+ sessionId: z.string().optional().describe("Session ID (optional if single session)"),
2064
+ expression: z.string().describe("Expression to evaluate"),
2065
+ frameId: z.number().optional().describe("Stack frame ID (default: top frame)"),
2066
+ context: z.enum(["repl", "watch", "hover"]).optional().describe("Evaluation context (default: repl)"),
2067
+ },
2068
+ async ({ sessionId, expression, frameId, context }) => {
2069
+ try {
2070
+ const session = sessions.resolve(sessionId);
2071
+ if (session.state !== "stopped") {
2072
+ return {
2073
+ content: [{ type: "text" as const, text: "Cannot evaluate: debuggee is not stopped." }],
2074
+ isError: true,
2075
+ };
2076
+ }
2077
+ const result = await session.evaluate(expression, frameId, context ?? "repl");
2078
+ return {
2079
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
2080
+ };
2081
+ } catch (err: any) {
2082
+ return {
2083
+ content: [{ type: "text" as const, text: `Evaluation error: ${err.message}` }],
2084
+ isError: true,
2085
+ };
2086
+ }
2087
+ }
2088
+ );
2089
+
2090
+ // === Tool: step ===
2091
+ server.tool(
2092
+ "step",
2093
+ "Control execution. Blocks until the debuggee stops again or timeout. Returns the new stopped state.",
2094
+ {
2095
+ sessionId: z.string().optional().describe("Session ID (optional if single session)"),
2096
+ action: z.enum(["continue", "next", "stepIn", "stepOut", "pause"]).describe("Execution action"),
2097
+ threadId: z.number().optional().describe("Thread ID (default: focused thread)"),
2098
+ timeout: z.number().optional().describe("Ms to wait for stop (default: 30000)"),
2099
+ },
2100
+ async ({ sessionId, action, threadId, timeout }) => {
2101
+ try {
2102
+ const session = sessions.resolve(sessionId);
2103
+ const timeoutMs = timeout ?? 30000;
2104
+
2105
+ // Execute the action
2106
+ switch (action) {
2107
+ case "continue":
2108
+ await session.continueExecution(threadId);
2109
+ break;
2110
+ case "next":
2111
+ await session.next(threadId);
2112
+ break;
2113
+ case "stepIn":
2114
+ await session.stepIn(threadId);
2115
+ break;
2116
+ case "stepOut":
2117
+ await session.stepOut(threadId);
2118
+ break;
2119
+ case "pause":
2120
+ await session.pause(threadId);
2121
+ break;
2122
+ }
2123
+
2124
+ // Wait for stopped
2125
+ try {
2126
+ await session.waitForStopped(timeoutMs);
2127
+ const state = await session.collectState(threadId);
2128
+ return {
2129
+ content: [{ type: "text" as const, text: JSON.stringify(state, null, 2) }],
2130
+ };
2131
+ } catch {
2132
+ if (session.state === "terminated") {
2133
+ const { output } = session.getOutput();
2134
+ return {
2135
+ content: [{
2136
+ type: "text" as const,
2137
+ text: JSON.stringify({ status: "terminated", output: output.map((o) => o.text) }, null, 2),
2138
+ }],
2139
+ };
2140
+ }
2141
+ return {
2142
+ content: [{
2143
+ type: "text" as const,
2144
+ text: JSON.stringify({ status: "running", elapsedMs: timeoutMs }, null, 2),
2145
+ }],
2146
+ };
2147
+ }
2148
+ } catch (err: any) {
2149
+ return {
2150
+ content: [{ type: "text" as const, text: err.message }],
2151
+ isError: true,
2152
+ };
2153
+ }
2154
+ }
2155
+ );
2156
+
2157
+ // === Tool: get_output ===
2158
+ server.tool(
2159
+ "get_output",
2160
+ "Get buffered program output (stdout, stderr, console messages)",
2161
+ {
2162
+ sessionId: z.string().optional().describe("Session ID (optional if single session)"),
2163
+ category: z.enum(["stdout", "stderr", "console"]).optional().describe("Filter by category (default: all)"),
2164
+ clear: z.boolean().optional().describe("Clear the buffer after reading (default: false)"),
2165
+ },
2166
+ async ({ sessionId, category, clear }) => {
2167
+ try {
2168
+ const session = sessions.resolve(sessionId);
2169
+ const result = session.getOutput(category, clear ?? false);
2170
+ return {
2171
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
2172
+ };
2173
+ } catch (err: any) {
2174
+ return {
2175
+ content: [{ type: "text" as const, text: err.message }],
2176
+ isError: true,
2177
+ };
2178
+ }
2179
+ }
2180
+ );
2181
+
2182
+ // === Tool: debug_disconnect ===
2183
+ server.tool(
2184
+ "debug_disconnect",
2185
+ "End a debug session. Terminates the debuggee for launched sessions by default.",
2186
+ {
2187
+ sessionId: z.string().optional().describe("Session ID (optional if single session)"),
2188
+ terminateDebuggee: z.boolean().optional().describe("Kill the debuggee process (default: true for launch, false for attach)"),
2189
+ },
2190
+ async ({ sessionId, terminateDebuggee }) => {
2191
+ try {
2192
+ const session = sessions.resolve(sessionId);
2193
+ await session.disconnect(terminateDebuggee);
2194
+ sessions.remove(session.id);
2195
+ return {
2196
+ content: [{ type: "text" as const, text: `Session '${session.id}' disconnected.` }],
2197
+ };
2198
+ } catch (err: any) {
2199
+ return {
2200
+ content: [{ type: "text" as const, text: err.message }],
2201
+ isError: true,
2202
+ };
2203
+ }
2204
+ }
2205
+ );
2206
+
2207
+ // === Tool: debug_restart ===
2208
+ server.tool(
2209
+ "debug_restart",
2210
+ "Restart the current debug session with the same configuration",
2211
+ {
2212
+ sessionId: z.string().optional().describe("Session ID (optional if single session)"),
2213
+ },
2214
+ async ({ sessionId }) => {
2215
+ try {
2216
+ const session = sessions.resolve(sessionId);
2217
+ const launchConfig = session.getLaunchConfig();
2218
+ const launchMode = session.getLaunchMode();
2219
+ const adapterConfig = session.getAdapterConfig();
2220
+
2221
+ if (!launchConfig) {
2222
+ return {
2223
+ content: [{ type: "text" as const, text: "Cannot restart: no launch configuration stored." }],
2224
+ isError: true,
2225
+ };
2226
+ }
2227
+
2228
+ // Disconnect old session
2229
+ await session.disconnect();
2230
+ sessions.remove(session.id);
2231
+
2232
+ // Create new session with same config
2233
+ const newId = sessions.generateId();
2234
+ const newSession = new DebugSession(newId, adapterConfig);
2235
+ sessions.add(newSession);
2236
+
2237
+ await newSession.initialize();
2238
+ await new Promise((r) => setTimeout(r, 200));
2239
+ await newSession.configurationDone();
2240
+
2241
+ if (launchMode === "launch") {
2242
+ await newSession.launch(launchConfig);
2243
+ } else {
2244
+ await newSession.attach(launchConfig);
2245
+ }
2246
+
2247
+ try {
2248
+ await newSession.waitForStopped(10000);
2249
+ const state = await newSession.collectState();
2250
+ return {
2251
+ content: [{ type: "text" as const, text: JSON.stringify({ sessionId: newId, ...state }, null, 2) }],
2252
+ };
2253
+ } catch {
2254
+ return {
2255
+ content: [{ type: "text" as const, text: JSON.stringify({ sessionId: newId, status: "running" }, null, 2) }],
2256
+ };
2257
+ }
2258
+ } catch (err: any) {
2259
+ return {
2260
+ content: [{ type: "text" as const, text: `Restart failed: ${err.message}` }],
2261
+ isError: true,
2262
+ };
2263
+ }
2264
+ }
2265
+ );
2266
+
2267
+ // === Resource: dap://sessions ===
2268
+ server.resource(
2269
+ "debug-sessions",
2270
+ "dap://sessions",
2271
+ {
2272
+ description: "List of active debug sessions with their current status",
2273
+ mimeType: "application/json",
2274
+ },
2275
+ async (uri) => {
2276
+ const allSessions = sessions.listAll();
2277
+ const summary = allSessions.map((s) => ({
2278
+ id: s.id,
2279
+ state: s.state,
2280
+ adapter: s.getAdapterConfig().type,
2281
+ mode: s.getLaunchMode(),
2282
+ }));
2283
+ return {
2284
+ contents: [{ uri: uri.href, text: JSON.stringify(summary, null, 2) }],
2285
+ };
2286
+ }
2287
+ );
2288
+
2289
+ return server;
2290
+ }
2291
+ ```
2292
+
2293
+ **Step 2: Update entry point**
2294
+
2295
+ ```typescript
2296
+ // src/index.ts
2297
+ #!/usr/bin/env node
2298
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2299
+ import { createServer } from "./server.js";
2300
+
2301
+ const server = createServer();
2302
+ const transport = new StdioServerTransport();
2303
+ await server.connect(transport);
2304
+ console.error("dap-mcp-server running on stdio");
2305
+ ```
2306
+
2307
+ **Step 3: Build and verify**
2308
+
2309
+ Run: `npx tsc --noEmit`
2310
+ Expected: no errors
2311
+
2312
+ **Step 4: Run all tests**
2313
+
2314
+ Run: `npx vitest run`
2315
+ Expected: all tests PASS
2316
+
2317
+ **Step 5: Commit**
2318
+
2319
+ ```bash
2320
+ git add src/server.ts src/index.ts
2321
+ git commit -m "feat: add MCP server with all 12 tools and sessions resource"
2322
+ ```
2323
+
2324
+ ---
2325
+
2326
+ ## Task 10: Integration Test with Mock Adapter
2327
+
2328
+ **Files:**
2329
+ - Modify: `src/test-fixtures/mock-adapter.js` (extend to handle full lifecycle)
2330
+ - Create: `src/integration/debug-lifecycle.test.ts`
2331
+
2332
+ **Step 1: Extend the mock adapter to support a complete debug lifecycle**
2333
+
2334
+ Update `src/test-fixtures/mock-adapter.js` to handle: `initialize`, `setBreakpoints`, `configurationDone`, `launch`, `threads`, `stackTrace`, `scopes`, `variables`, `continue`, `next`, `evaluate`, `disconnect`.
2335
+
2336
+ The mock simulates a simple program with a single thread, 3 stack frames, and some variables.
2337
+
2338
+ ```javascript
2339
+ // src/test-fixtures/mock-adapter.js
2340
+ // Full-lifecycle DAP mock adapter for testing
2341
+ const TWO_CRLF = "\r\n\r\n";
2342
+ let buffer = Buffer.alloc(0);
2343
+ let contentLength = -1;
2344
+
2345
+ process.stdin.on("data", (chunk) => {
2346
+ buffer = Buffer.concat([buffer, chunk]);
2347
+ while (true) {
2348
+ if (contentLength === -1) {
2349
+ const idx = buffer.indexOf(TWO_CRLF);
2350
+ if (idx === -1) return;
2351
+ const header = buffer.subarray(0, idx).toString();
2352
+ const match = header.match(/Content-Length:\s*(\d+)/i);
2353
+ if (!match) return;
2354
+ contentLength = parseInt(match[1], 10);
2355
+ buffer = buffer.subarray(idx + TWO_CRLF.length);
2356
+ }
2357
+ if (buffer.length < contentLength) return;
2358
+ const body = buffer.subarray(0, contentLength).toString();
2359
+ buffer = buffer.subarray(contentLength);
2360
+ contentLength = -1;
2361
+ handleMessage(JSON.parse(body));
2362
+ }
2363
+ });
2364
+
2365
+ function send(msg) {
2366
+ const json = JSON.stringify(msg);
2367
+ const header = `Content-Length: ${Buffer.byteLength(json)}${TWO_CRLF}`;
2368
+ process.stdout.write(header + json);
2369
+ }
2370
+
2371
+ let seq = 1;
2372
+ let currentLine = 1;
2373
+ const breakpoints = new Map();
2374
+
2375
+ function handleMessage(msg) {
2376
+ if (msg.type !== "request") return;
2377
+
2378
+ switch (msg.command) {
2379
+ case "initialize":
2380
+ send({
2381
+ seq: seq++, type: "response", request_seq: msg.seq,
2382
+ command: "initialize", success: true,
2383
+ body: { supportsConfigurationDoneRequest: true, supportsEvaluateForHovers: true }
2384
+ });
2385
+ send({ seq: seq++, type: "event", event: "initialized", body: {} });
2386
+ break;
2387
+
2388
+ case "setBreakpoints": {
2389
+ const file = msg.arguments.source.path;
2390
+ const bps = (msg.arguments.breakpoints || []).map((bp, i) => ({
2391
+ id: i + 1, verified: true, line: bp.line
2392
+ }));
2393
+ breakpoints.set(file, bps.map(bp => bp.line));
2394
+ send({
2395
+ seq: seq++, type: "response", request_seq: msg.seq,
2396
+ command: "setBreakpoints", success: true,
2397
+ body: { breakpoints: bps }
2398
+ });
2399
+ break;
2400
+ }
2401
+
2402
+ case "setExceptionBreakpoints":
2403
+ send({
2404
+ seq: seq++, type: "response", request_seq: msg.seq,
2405
+ command: "setExceptionBreakpoints", success: true, body: {}
2406
+ });
2407
+ break;
2408
+
2409
+ case "configurationDone":
2410
+ send({
2411
+ seq: seq++, type: "response", request_seq: msg.seq,
2412
+ command: "configurationDone", success: true, body: {}
2413
+ });
2414
+ break;
2415
+
2416
+ case "launch":
2417
+ currentLine = 1;
2418
+ send({
2419
+ seq: seq++, type: "response", request_seq: msg.seq,
2420
+ command: "launch", success: true, body: {}
2421
+ });
2422
+ // Emit output
2423
+ send({
2424
+ seq: seq++, type: "event", event: "output",
2425
+ body: { category: "stdout", output: "Program started\n" }
2426
+ });
2427
+ // Stop on entry if requested
2428
+ if (msg.arguments.stopOnEntry) {
2429
+ setTimeout(() => {
2430
+ send({
2431
+ seq: seq++, type: "event", event: "stopped",
2432
+ body: { reason: "entry", threadId: 1, allThreadsStopped: true }
2433
+ });
2434
+ }, 50);
2435
+ }
2436
+ break;
2437
+
2438
+ case "threads":
2439
+ send({
2440
+ seq: seq++, type: "response", request_seq: msg.seq,
2441
+ command: "threads", success: true,
2442
+ body: { threads: [{ id: 1, name: "MainThread" }] }
2443
+ });
2444
+ break;
2445
+
2446
+ case "stackTrace":
2447
+ send({
2448
+ seq: seq++, type: "response", request_seq: msg.seq,
2449
+ command: "stackTrace", success: true,
2450
+ body: {
2451
+ stackFrames: [
2452
+ { id: 1, name: "main", source: { path: "/test/app.py" }, line: currentLine, column: 1 },
2453
+ { id: 2, name: "helper", source: { path: "/test/utils.py" }, line: 10, column: 1 },
2454
+ ],
2455
+ totalFrames: 2
2456
+ }
2457
+ });
2458
+ break;
2459
+
2460
+ case "scopes":
2461
+ send({
2462
+ seq: seq++, type: "response", request_seq: msg.seq,
2463
+ command: "scopes", success: true,
2464
+ body: {
2465
+ scopes: [
2466
+ { name: "Locals", variablesReference: 100, expensive: false },
2467
+ { name: "Globals", variablesReference: 200, expensive: true },
2468
+ ]
2469
+ }
2470
+ });
2471
+ break;
2472
+
2473
+ case "variables":
2474
+ if (msg.arguments.variablesReference === 100) {
2475
+ send({
2476
+ seq: seq++, type: "response", request_seq: msg.seq,
2477
+ command: "variables", success: true,
2478
+ body: {
2479
+ variables: [
2480
+ { name: "x", value: "42", type: "int", variablesReference: 0 },
2481
+ { name: "name", value: "'hello'", type: "str", variablesReference: 0 },
2482
+ { name: "data", value: "[1, 2, 3]", type: "list", variablesReference: 300 },
2483
+ ]
2484
+ }
2485
+ });
2486
+ } else {
2487
+ send({
2488
+ seq: seq++, type: "response", request_seq: msg.seq,
2489
+ command: "variables", success: true,
2490
+ body: { variables: [] }
2491
+ });
2492
+ }
2493
+ break;
2494
+
2495
+ case "evaluate":
2496
+ send({
2497
+ seq: seq++, type: "response", request_seq: msg.seq,
2498
+ command: "evaluate", success: true,
2499
+ body: {
2500
+ result: "42",
2501
+ type: "int",
2502
+ variablesReference: 0
2503
+ }
2504
+ });
2505
+ break;
2506
+
2507
+ case "continue":
2508
+ send({
2509
+ seq: seq++, type: "response", request_seq: msg.seq,
2510
+ command: "continue", success: true, body: { allThreadsContinued: true }
2511
+ });
2512
+ // Simulate running then stopping at a breakpoint
2513
+ currentLine += 5;
2514
+ setTimeout(() => {
2515
+ send({
2516
+ seq: seq++, type: "event", event: "stopped",
2517
+ body: { reason: "breakpoint", threadId: 1, allThreadsStopped: true }
2518
+ });
2519
+ }, 50);
2520
+ break;
2521
+
2522
+ case "next":
2523
+ send({
2524
+ seq: seq++, type: "response", request_seq: msg.seq,
2525
+ command: "next", success: true, body: {}
2526
+ });
2527
+ currentLine++;
2528
+ setTimeout(() => {
2529
+ send({
2530
+ seq: seq++, type: "event", event: "stopped",
2531
+ body: { reason: "step", threadId: 1, allThreadsStopped: true }
2532
+ });
2533
+ }, 50);
2534
+ break;
2535
+
2536
+ case "stepIn":
2537
+ send({
2538
+ seq: seq++, type: "response", request_seq: msg.seq,
2539
+ command: "stepIn", success: true, body: {}
2540
+ });
2541
+ currentLine++;
2542
+ setTimeout(() => {
2543
+ send({
2544
+ seq: seq++, type: "event", event: "stopped",
2545
+ body: { reason: "step", threadId: 1, allThreadsStopped: true }
2546
+ });
2547
+ }, 50);
2548
+ break;
2549
+
2550
+ case "stepOut":
2551
+ send({
2552
+ seq: seq++, type: "response", request_seq: msg.seq,
2553
+ command: "stepOut", success: true, body: {}
2554
+ });
2555
+ setTimeout(() => {
2556
+ send({
2557
+ seq: seq++, type: "event", event: "stopped",
2558
+ body: { reason: "step", threadId: 1, allThreadsStopped: true }
2559
+ });
2560
+ }, 50);
2561
+ break;
2562
+
2563
+ case "disconnect":
2564
+ send({
2565
+ seq: seq++, type: "response", request_seq: msg.seq,
2566
+ command: "disconnect", success: true, body: {}
2567
+ });
2568
+ setTimeout(() => process.exit(0), 50);
2569
+ break;
2570
+
2571
+ default:
2572
+ send({
2573
+ seq: seq++, type: "response", request_seq: msg.seq,
2574
+ command: msg.command, success: true, body: {}
2575
+ });
2576
+ }
2577
+ }
2578
+ ```
2579
+
2580
+ **Step 2: Write the integration test**
2581
+
2582
+ ```typescript
2583
+ // src/integration/debug-lifecycle.test.ts
2584
+ import { describe, it, expect, afterEach } from "vitest";
2585
+ import { DebugSession } from "../session/session.js";
2586
+ import path from "node:path";
2587
+ import { fileURLToPath } from "node:url";
2588
+
2589
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
2590
+ const MOCK_ADAPTER = path.join(__dirname, "../test-fixtures/mock-adapter.js");
2591
+
2592
+ describe("Debug Lifecycle Integration", () => {
2593
+ let session: DebugSession | undefined;
2594
+
2595
+ afterEach(async () => {
2596
+ if (session && session.state !== "terminated") {
2597
+ try { await session.disconnect(); } catch { /* ignore */ }
2598
+ }
2599
+ session = undefined;
2600
+ });
2601
+
2602
+ it("full debug lifecycle: launch, stop, inspect, step, evaluate, disconnect", async () => {
2603
+ session = new DebugSession("integ-1", {
2604
+ type: "mock",
2605
+ runtime: "node",
2606
+ program: MOCK_ADAPTER,
2607
+ });
2608
+
2609
+ // Initialize
2610
+ const caps = await session.initialize();
2611
+ expect(caps.supportsConfigurationDoneRequest).toBe(true);
2612
+ expect(session.state).toBe("configuring");
2613
+
2614
+ // Set breakpoints
2615
+ const bps = await session.setBreakpoints("/test/app.py", [{ line: 5 }, { line: 10 }]);
2616
+ expect(bps).toHaveLength(2);
2617
+ expect(bps[0].verified).toBe(true);
2618
+
2619
+ // Configuration done + launch
2620
+ await session.configurationDone();
2621
+ await session.launch({ program: "/test/app.py", stopOnEntry: true });
2622
+
2623
+ // Wait for stopped on entry
2624
+ const stoppedEvent = await session.waitForStopped(5000);
2625
+ expect(stoppedEvent.body.reason).toBe("entry");
2626
+ expect(session.state).toBe("stopped");
2627
+
2628
+ // Collect composite state
2629
+ const state = await session.collectState(1, 10, 1);
2630
+ expect(state.status).toBe("stopped");
2631
+ expect(state.reason).toBe("entry");
2632
+ expect(state.threads).toHaveLength(1);
2633
+ expect(state.threads[0].name).toBe("MainThread");
2634
+ expect(state.stackTrace.length).toBeGreaterThan(0);
2635
+ expect(state.scopes.length).toBeGreaterThan(0);
2636
+
2637
+ // Check variables
2638
+ const locals = state.scopes.find(s => s.name === "Locals");
2639
+ expect(locals).toBeDefined();
2640
+ expect(locals!.variables.some(v => v.name === "x" && v.value === "42")).toBe(true);
2641
+ expect(locals!.variables.some(v => v.name === "name")).toBe(true);
2642
+
2643
+ // Step next
2644
+ await session.next(1);
2645
+ const stoppedAfterStep = await session.waitForStopped(5000);
2646
+ expect(stoppedAfterStep.body.reason).toBe("step");
2647
+
2648
+ // Evaluate expression
2649
+ const evalResult = await session.evaluate("x + 1", 1);
2650
+ expect(evalResult.result).toBe("42");
2651
+
2652
+ // Continue
2653
+ await session.continueExecution(1);
2654
+ const stoppedAtBp = await session.waitForStopped(5000);
2655
+ expect(stoppedAtBp.body.reason).toBe("breakpoint");
2656
+
2657
+ // Check output
2658
+ const output = session.getOutput();
2659
+ expect(output.output.some(o => o.text.includes("Program started"))).toBe(true);
2660
+
2661
+ // Disconnect
2662
+ await session.disconnect();
2663
+ expect(session.state).toBe("terminated");
2664
+ });
2665
+
2666
+ it("handles adapter crash gracefully", async () => {
2667
+ const crashAdapter = path.join(__dirname, "../test-fixtures/crash-adapter.js");
2668
+ session = new DebugSession("integ-crash", {
2669
+ type: "mock",
2670
+ runtime: "node",
2671
+ program: crashAdapter,
2672
+ });
2673
+
2674
+ await expect(session.initialize()).rejects.toThrow();
2675
+ expect(session.state).toBe("terminated");
2676
+ });
2677
+ });
2678
+ ```
2679
+
2680
+ **Step 3: Run integration tests**
2681
+
2682
+ Run: `npx vitest run src/integration/`
2683
+ Expected: all tests PASS
2684
+
2685
+ **Step 4: Run all tests**
2686
+
2687
+ Run: `npx vitest run`
2688
+ Expected: all tests PASS
2689
+
2690
+ **Step 5: Commit**
2691
+
2692
+ ```bash
2693
+ git add src/test-fixtures/mock-adapter.js src/integration/
2694
+ git commit -m "test: add integration tests for full debug lifecycle with mock adapter"
2695
+ ```
2696
+
2697
+ ---
2698
+
2699
+ ## Task 11: Adapter Availability Check
2700
+
2701
+ **Files:**
2702
+ - Create: `src/adapters/detect.ts`
2703
+ - Create: `src/adapters/detect.test.ts`
2704
+
2705
+ **Step 1: Write the failing test**
2706
+
2707
+ ```typescript
2708
+ // src/adapters/detect.test.ts
2709
+ import { describe, it, expect } from "vitest";
2710
+ import { checkAdapterAvailable } from "./detect.js";
2711
+
2712
+ describe("checkAdapterAvailable", () => {
2713
+ it("detects 'node' as available", async () => {
2714
+ const result = await checkAdapterAvailable("node");
2715
+ expect(result).toBe(true);
2716
+ });
2717
+
2718
+ it("detects non-existent binary as unavailable", async () => {
2719
+ const result = await checkAdapterAvailable("definitely-not-a-real-binary-xyz");
2720
+ expect(result).toBe(false);
2721
+ });
2722
+ });
2723
+ ```
2724
+
2725
+ **Step 2: Run test to verify it fails**
2726
+
2727
+ Run: `npx vitest run src/adapters/detect.test.ts`
2728
+ Expected: FAIL
2729
+
2730
+ **Step 3: Implement**
2731
+
2732
+ ```typescript
2733
+ // src/adapters/detect.ts
2734
+ import { execFile } from "node:child_process";
2735
+ import { promisify } from "node:util";
2736
+
2737
+ const execFileAsync = promisify(execFile);
2738
+
2739
+ export async function checkAdapterAvailable(command: string): Promise<boolean> {
2740
+ try {
2741
+ await execFileAsync("which", [command]);
2742
+ return true;
2743
+ } catch {
2744
+ return false;
2745
+ }
2746
+ }
2747
+ ```
2748
+
2749
+ **Step 4: Run tests to verify they pass**
2750
+
2751
+ Run: `npx vitest run src/adapters/detect.test.ts`
2752
+ Expected: PASS
2753
+
2754
+ **Step 5: Commit**
2755
+
2756
+ ```bash
2757
+ git add src/adapters/detect.ts src/adapters/detect.test.ts
2758
+ git commit -m "feat: add adapter binary availability detection"
2759
+ ```
2760
+
2761
+ ---
2762
+
2763
+ ## Task 12: Final Build, Full Test Suite, and Package
2764
+
2765
+ **Files:**
2766
+ - Modify: `package.json` (add bin shebang support)
2767
+ - Verify all tests pass
2768
+ - Full build
2769
+
2770
+ **Step 1: Ensure shebang in index.ts**
2771
+
2772
+ Verify `src/index.ts` starts with `#!/usr/bin/env node`.
2773
+
2774
+ **Step 2: Build**
2775
+
2776
+ Run: `npm run build`
2777
+ Expected: no errors, output in `build/`
2778
+
2779
+ **Step 3: Run full test suite**
2780
+
2781
+ Run: `npm test`
2782
+ Expected: all tests PASS
2783
+
2784
+ **Step 4: Test the server manually with MCP Inspector (optional)**
2785
+
2786
+ Run: `npx @modelcontextprotocol/inspector node build/index.js`
2787
+ Expected: Inspector UI opens, tools are listed
2788
+
2789
+ **Step 5: Commit**
2790
+
2791
+ ```bash
2792
+ git add -A
2793
+ git commit -m "feat: complete dap-mcp-server v0.1.0"
2794
+ ```
2795
+
2796
+ ---
2797
+
2798
+ ## Summary
2799
+
2800
+ | Task | Component | Tests |
2801
+ |------|-----------|-------|
2802
+ | 1 | Project scaffolding | — |
2803
+ | 2 | DAP types | — |
2804
+ | 3 | Ring buffer | 5 unit tests |
2805
+ | 4 | Protocol framing | 3 unit tests |
2806
+ | 5 | DAP client | 4 unit tests |
2807
+ | 6 | Adapter registry | 4 unit tests |
2808
+ | 7 | Debug session | 4 unit tests |
2809
+ | 8 | Session manager | 7 unit tests |
2810
+ | 9 | MCP server + all 12 tools | — (covered by integration) |
2811
+ | 10 | Integration tests | 2 integration tests |
2812
+ | 11 | Adapter detection | 2 unit tests |
2813
+ | 12 | Final build & verification | — |
2814
+
2815
+ Total: ~31 tests across 12 tasks.