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.
- package/.claude/settings.local.json +31 -0
- package/.forgejo/workflows/publish.yml +63 -0
- package/CLAUDE.md +71 -0
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/SKILL.md +62 -0
- package/build/adapters/builtin.d.ts +24 -0
- package/build/adapters/builtin.js +46 -0
- package/build/adapters/builtin.js.map +1 -0
- package/build/adapters/detect.d.ts +23 -0
- package/build/adapters/detect.js +36 -0
- package/build/adapters/detect.js.map +1 -0
- package/build/adapters/registry.d.ts +39 -0
- package/build/adapters/registry.js +79 -0
- package/build/adapters/registry.js.map +1 -0
- package/build/dap/dap-client.d.ts +46 -0
- package/build/dap/dap-client.js +190 -0
- package/build/dap/dap-client.js.map +1 -0
- package/build/dap/protocol.d.ts +32 -0
- package/build/dap/protocol.js +71 -0
- package/build/dap/protocol.js.map +1 -0
- package/build/dap/ring-buffer.d.ts +35 -0
- package/build/dap/ring-buffer.js +57 -0
- package/build/dap/ring-buffer.js.map +1 -0
- package/build/dap/types.d.ts +87 -0
- package/build/dap/types.js +25 -0
- package/build/dap/types.js.map +1 -0
- package/build/index.d.ts +24 -0
- package/build/index.js +30 -0
- package/build/index.js.map +1 -0
- package/build/server.d.ts +24 -0
- package/build/server.js +735 -0
- package/build/server.js.map +1 -0
- package/build/session/session-manager.d.ts +32 -0
- package/build/session/session-manager.js +60 -0
- package/build/session/session-manager.js.map +1 -0
- package/build/session/session.d.ts +85 -0
- package/build/session/session.js +374 -0
- package/build/session/session.js.map +1 -0
- package/docs/plans/2026-03-05-dap-wrapper-mcp-design.md +326 -0
- package/docs/plans/2026-03-05-dap-wrapper-mcp-implementation.md +2815 -0
- package/package.json +43 -0
- package/src/adapters/builtin.ts +47 -0
- package/src/adapters/detect.test.ts +36 -0
- package/src/adapters/detect.ts +36 -0
- package/src/adapters/registry.test.ts +73 -0
- package/src/adapters/registry.ts +86 -0
- package/src/dap/dap-client.test.ts +87 -0
- package/src/dap/dap-client.ts +216 -0
- package/src/dap/protocol.test.ts +82 -0
- package/src/dap/protocol.ts +78 -0
- package/src/dap/ring-buffer.test.ts +82 -0
- package/src/dap/ring-buffer.ts +64 -0
- package/src/dap/types.ts +92 -0
- package/src/index.ts +30 -0
- package/src/integration/debug-lifecycle.test.ts +107 -0
- package/src/server.ts +866 -0
- package/src/session/session-manager.test.ts +74 -0
- package/src/session/session-manager.ts +67 -0
- package/src/session/session.test.ts +84 -0
- package/src/session/session.ts +435 -0
- package/src/test-fixtures/crash-adapter.js +23 -0
- package/src/test-fixtures/mock-adapter.js +142 -0
- package/src/test-fixtures/silent-adapter.js +24 -0
- 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.
|