Skip to content

Commit 65d2508

Browse files
committed
DAP backend
1 parent 98e3344 commit 65d2508

17 files changed

Lines changed: 2136 additions & 242 deletions

.smithery/index.cjs

Lines changed: 196 additions & 183 deletions
Large diffs are not rendered by default.

docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md

Lines changed: 573 additions & 0 deletions
Large diffs are not rendered by default.

docs/DEBUGGING_ARCHITECTURE.md

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -220,21 +220,20 @@ Annotated example (simplified):
220220
processes. Tests should inject a mock `InteractiveSpawner` into `createLldbCliBackend()` or a custom
221221
`DebuggerManager` backend factory.
222222

223-
## DAP Backend (Stub / Not Implemented)
223+
## DAP Backend (lldb-dap)
224224

225-
- Implementation: `src/utils/debugger/backends/dap-backend.ts`.
225+
- Implementation: `src/utils/debugger/backends/dap-backend.ts`, with protocol support in
226+
`src/utils/debugger/dap/transport.ts`, `src/utils/debugger/dap/types.ts`, and adapter discovery in
227+
`src/utils/debugger/dap/adapter-discovery.ts`.
226228
- Selected via backend selection (explicit `backend`, `XCODEBUILDMCP_DEBUGGER_BACKEND=dap`).
227-
- Current status: not implemented. All `DebuggerBackend` methods throw `DAP_ERROR_MESSAGE`,
228-
including `dispose()`.
229-
230-
Practical effect:
231-
232-
- Setting `XCODEBUILDMCP_DEBUGGER_BACKEND=dap` causes `debug_attach_sim` to fail during
233-
session creation because `backend.attach()` throws.
234-
- `DebuggerManager.createSession` attempts to call `dispose()` on failure, but the stub `dispose()`
235-
also throws (same message). This is expected until a real DAP backend exists.
236-
237-
Use `lldb-cli` (default) for actual debugging.
229+
- Adapter discovery uses `xcrun --find lldb-dap`; missing adapters raise a clear dependency error.
230+
- One `lldb-dap` process is spawned per session; DAP framing and request correlation are handled
231+
by `DapTransport`.
232+
- Session handshake: `initialize``attach``configurationDone`.
233+
- Breakpoints are stateful: adding/removing re-issues `setBreakpoints` or
234+
`setFunctionBreakpoints` with the remaining list. Conditions are passed in the request body.
235+
- Stack/variables typically require a stopped thread; the backend returns guidance if the process
236+
is still running.
238237

239238
## External Tool Invocation
240239

@@ -248,8 +247,8 @@ Use `lldb-cli` (default) for actual debugging.
248247
### LLDB
249248

250249
- Attachment uses `xcrun lldb --no-lldbinit` in the interactive backend.
251-
- Breakpoint conditions are applied by issuing an additional LLDB command:
252-
`breakpoint modify -c "<condition>" <id>`.
250+
- Breakpoint conditions are applied internally by the LLDB CLI backend using
251+
`breakpoint modify -c "<condition>" <id>` after creation.
253252

254253
### xcodebuild (Build/Launch Context)
255254

@@ -272,6 +271,6 @@ Use `lldb-cli` (default) for actual debugging.
272271
- Session defaults: `src/utils/session-store.ts`
273272
- Debug session manager: `src/utils/debugger/debugger-manager.ts`
274273
- Backends: `src/utils/debugger/backends/lldb-cli-backend.ts` (default),
275-
`src/utils/debugger/backends/dap-backend.ts` (stub)
274+
`src/utils/debugger/backends/dap-backend.ts`
276275
- Interactive execution: `src/utils/execution/interactive-process.ts` (used by LLDB CLI backend)
277276
- External commands: `xcrun simctl`, `xcrun lldb`, `xcodebuild`

src/mcp/tools/debugging/debug_breakpoint_add.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,6 @@ const debugBreakpointAddSchema = z.preprocess(
3636

3737
export type DebugBreakpointAddParams = z.infer<typeof debugBreakpointAddSchema>;
3838

39-
function formatCondition(condition: string): string {
40-
const escaped = condition.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
41-
return `"${escaped}"`;
42-
}
43-
4439
export async function debug_breakpoint_addLogic(
4540
params: DebugBreakpointAddParams,
4641
ctx: DebuggerToolContext,
@@ -54,12 +49,9 @@ export async function debug_breakpoint_addLogic(
5449
return createErrorResponse('Invalid breakpoint', 'file and line are required.');
5550
}
5651

57-
const result = await ctx.debugger.addBreakpoint(params.debugSessionId, spec);
58-
59-
if (params.condition) {
60-
const conditionCommand = `breakpoint modify -c ${formatCondition(params.condition)} ${result.id}`;
61-
await ctx.debugger.runCommand(params.debugSessionId, conditionCommand);
62-
}
52+
const result = await ctx.debugger.addBreakpoint(params.debugSessionId, spec, {
53+
condition: params.condition,
54+
});
6355

6456
return createTextResponse(`✅ Breakpoint ${result.id} set.\n\n${result.rawOutput.trim()}`);
6557
} catch (error) {

src/mcp/tools/doctor/doctor.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ const doctorSchema = z.object({
2424
// Use z.infer for type safety
2525
type DoctorParams = z.infer<typeof doctorSchema>;
2626

27+
async function checkLldbDapAvailability(executor: CommandExecutor): Promise<boolean> {
28+
try {
29+
const result = await executor(['xcrun', '--find', 'lldb-dap'], 'Check lldb-dap');
30+
return result.success && result.output.trim().length > 0;
31+
} catch {
32+
return false;
33+
}
34+
}
35+
2736
/**
2837
* Run the doctor tool and return the results
2938
*/
@@ -52,6 +61,9 @@ export async function runDoctor(
5261
const xcodemakeEnabled = deps.features.isXcodemakeEnabled();
5362
const xcodemakeAvailable = await deps.features.isXcodemakeAvailable();
5463
const makefileExists = deps.features.doesMakefileExist('./');
64+
const lldbDapAvailable = await checkLldbDapAvailability(deps.commandExecutor);
65+
const selectedDebuggerBackend = process.env.XCODEBUILDMCP_DEBUGGER_BACKEND?.trim();
66+
const dapSelected = selectedDebuggerBackend?.toLowerCase() === 'dap';
5567

5668
const doctorInfo = {
5769
serverVersion: version,
@@ -75,6 +87,12 @@ export async function runDoctor(
7587
running_under_mise: Boolean(process.env.XCODEBUILDMCP_RUNNING_UNDER_MISE),
7688
available: binaryStatus['mise'].available,
7789
},
90+
debugger: {
91+
dap: {
92+
available: lldbDapAvailable,
93+
selected: selectedDebuggerBackend ?? '(default lldb-cli)',
94+
},
95+
},
7896
},
7997
pluginSystem: pluginSystemInfo,
8098
} as const;
@@ -196,6 +214,15 @@ export async function runDoctor(
196214
`- Running under mise: ${doctorInfo.features.mise.running_under_mise ? '✅ Yes' : '❌ No'}`,
197215
`- Mise available: ${doctorInfo.features.mise.available ? '✅ Yes' : '❌ No'}`,
198216

217+
`\n### Debugger Backend (DAP)`,
218+
`- lldb-dap available: ${doctorInfo.features.debugger.dap.available ? '✅ Yes' : '❌ No'}`,
219+
`- Selected backend: ${doctorInfo.features.debugger.dap.selected}`,
220+
...(dapSelected && !lldbDapAvailable
221+
? [
222+
`- Warning: DAP backend selected but lldb-dap not available. Set XCODEBUILDMCP_DEBUGGER_BACKEND=lldb-cli to use the CLI backend.`,
223+
]
224+
: []),
225+
199226
`\n### Available Tools`,
200227
`- Total Plugins: ${'totalPlugins' in doctorInfo.pluginSystem ? doctorInfo.pluginSystem.totalPlugins : 0}`,
201228
`- Plugin Directories: ${'pluginDirectories' in doctorInfo.pluginSystem ? doctorInfo.pluginSystem.pluginDirectories : 0}`,

src/mcp/tools/doctor/lib/doctor.deps.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export interface FeatureDetector {
8787
}
8888

8989
export interface DoctorDependencies {
90+
commandExecutor: CommandExecutor;
9091
binaryChecker: BinaryChecker;
9192
xcode: XcodeInfoProvider;
9293
env: EnvironmentInfoProvider;
@@ -96,6 +97,7 @@ export interface DoctorDependencies {
9697
}
9798

9899
export function createDoctorDependencies(executor: CommandExecutor): DoctorDependencies {
100+
const commandExecutor = executor;
99101
const binaryChecker: BinaryChecker = {
100102
async checkBinaryAvailability(binary: string) {
101103
// If bundled axe is available, reflect that in dependencies even if not on PATH
@@ -276,7 +278,7 @@ export function createDoctorDependencies(executor: CommandExecutor): DoctorDepen
276278
doesMakefileExist,
277279
};
278280

279-
return { binaryChecker, xcode, env, plugins, runtime, features };
281+
return { commandExecutor, binaryChecker, xcode, env, plugins, runtime, features };
280282
}
281283

282284
export type { CommandExecutor };

src/test-utils/mock-executors.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
*/
1717

1818
import { ChildProcess } from 'child_process';
19+
import { EventEmitter } from 'node:events';
20+
import { PassThrough } from 'node:stream';
1921
import { CommandExecutor } from '../utils/CommandExecutor.ts';
2022
import { FileSystemExecutor } from '../utils/FileSystemExecutor.ts';
23+
import type { InteractiveProcess, InteractiveSpawner } from '../utils/execution/index.ts';
2124

2225
/**
2326
* Create a mock executor for testing
@@ -159,6 +162,100 @@ export function createCommandMatchingMockExecutor(
159162
};
160163
}
161164

165+
export type MockInteractiveSession = {
166+
stdout: PassThrough;
167+
stderr: PassThrough;
168+
stdin: PassThrough;
169+
emitExit: (code?: number | null, signal?: NodeJS.Signals | null) => void;
170+
emitError: (error: Error) => void;
171+
};
172+
173+
export type MockInteractiveSpawnerScript = {
174+
onSpawn?: (session: MockInteractiveSession) => void;
175+
onWrite?: (data: string, session: MockInteractiveSession) => void;
176+
onKill?: (signal: NodeJS.Signals | undefined, session: MockInteractiveSession) => void;
177+
onDispose?: (session: MockInteractiveSession) => void;
178+
};
179+
180+
export function createMockInteractiveSpawner(
181+
script: MockInteractiveSpawnerScript = {},
182+
): InteractiveSpawner {
183+
return (): InteractiveProcess => {
184+
const stdout = new PassThrough();
185+
const stderr = new PassThrough();
186+
const stdin = new PassThrough();
187+
const emitter = new EventEmitter();
188+
const mockProcess = emitter as unknown as ChildProcess;
189+
const mutableProcess = mockProcess as unknown as {
190+
stdout: PassThrough | null;
191+
stderr: PassThrough | null;
192+
stdin: PassThrough | null;
193+
killed: boolean;
194+
exitCode: number | null;
195+
signalCode: NodeJS.Signals | null;
196+
spawnargs: string[];
197+
spawnfile: string;
198+
pid: number;
199+
};
200+
201+
mutableProcess.stdout = stdout;
202+
mutableProcess.stderr = stderr;
203+
mutableProcess.stdin = stdin;
204+
mutableProcess.killed = false;
205+
mutableProcess.exitCode = null;
206+
mutableProcess.signalCode = null;
207+
mutableProcess.spawnargs = [];
208+
mutableProcess.spawnfile = 'mock';
209+
mutableProcess.pid = 12345;
210+
mockProcess.kill = ((signal?: NodeJS.Signals): boolean => {
211+
mutableProcess.killed = true;
212+
emitter.emit('exit', 0, signal ?? null);
213+
return true;
214+
}) as ChildProcess['kill'];
215+
216+
const session: MockInteractiveSession = {
217+
stdout,
218+
stderr,
219+
stdin,
220+
emitExit: (code = 0, signal = null) => {
221+
emitter.emit('exit', code, signal);
222+
},
223+
emitError: (error) => {
224+
emitter.emit('error', error);
225+
},
226+
};
227+
228+
script.onSpawn?.(session);
229+
230+
let disposed = false;
231+
232+
return {
233+
process: mockProcess,
234+
write(data: string): void {
235+
if (disposed) {
236+
throw new Error('Mock interactive process disposed');
237+
}
238+
script.onWrite?.(data, session);
239+
},
240+
kill(signal?: NodeJS.Signals): void {
241+
if (disposed) return;
242+
mutableProcess.killed = true;
243+
script.onKill?.(signal, session);
244+
emitter.emit('exit', 0, signal ?? null);
245+
},
246+
dispose(): void {
247+
if (disposed) return;
248+
disposed = true;
249+
script.onDispose?.(session);
250+
stdout.end();
251+
stderr.end();
252+
stdin.end();
253+
emitter.removeAllListeners();
254+
},
255+
};
256+
};
257+
}
258+
162259
/**
163260
* Create a mock file system executor for testing
164261
*/
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type { BreakpointInfo, BreakpointSpec } from '../types.ts';
4+
import type { DebuggerBackend } from '../backends/DebuggerBackend.ts';
5+
import { DebuggerManager } from '../debugger-manager.ts';
6+
7+
function createBackend(overrides: Partial<DebuggerBackend> = {}): DebuggerBackend {
8+
const base: DebuggerBackend = {
9+
kind: 'dap',
10+
attach: async () => {},
11+
detach: async () => {},
12+
runCommand: async () => '',
13+
addBreakpoint: async (spec: BreakpointSpec): Promise<BreakpointInfo> => ({
14+
id: 1,
15+
spec,
16+
rawOutput: '',
17+
}),
18+
removeBreakpoint: async () => '',
19+
getStack: async () => '',
20+
getVariables: async () => '',
21+
dispose: async () => {},
22+
};
23+
24+
return { ...base, ...overrides };
25+
}
26+
27+
describe('DebuggerManager DAP selection', () => {
28+
it('selects dap backend when env is set', async () => {
29+
const prevEnv = process.env.XCODEBUILDMCP_DEBUGGER_BACKEND;
30+
process.env.XCODEBUILDMCP_DEBUGGER_BACKEND = 'dap';
31+
32+
let selected: string | null = null;
33+
const backend = createBackend({ kind: 'dap' });
34+
const manager = new DebuggerManager({
35+
backendFactory: async (kind) => {
36+
selected = kind;
37+
return backend;
38+
},
39+
});
40+
41+
await manager.createSession({ simulatorId: 'sim-1', pid: 1000 });
42+
43+
expect(selected).toBe('dap');
44+
45+
if (prevEnv === undefined) {
46+
delete process.env.XCODEBUILDMCP_DEBUGGER_BACKEND;
47+
} else {
48+
process.env.XCODEBUILDMCP_DEBUGGER_BACKEND = prevEnv;
49+
}
50+
});
51+
52+
it('disposes backend when attach fails without masking error', async () => {
53+
const error = new Error('attach failed');
54+
let disposeCalled = false;
55+
56+
const backend = createBackend({
57+
attach: async () => {
58+
throw error;
59+
},
60+
dispose: async () => {
61+
disposeCalled = true;
62+
throw new Error('dispose failed');
63+
},
64+
});
65+
66+
const manager = new DebuggerManager({
67+
backendFactory: async () => backend,
68+
});
69+
70+
await expect(
71+
manager.createSession({ simulatorId: 'sim-1', pid: 2000, backend: 'dap' }),
72+
).rejects.toThrow('attach failed');
73+
expect(disposeCalled).toBe(true);
74+
});
75+
});

src/utils/debugger/backends/DebuggerBackend.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export interface DebuggerBackend {
88

99
runCommand(command: string, opts?: { timeoutMs?: number }): Promise<string>;
1010

11-
addBreakpoint(spec: BreakpointSpec): Promise<BreakpointInfo>;
11+
addBreakpoint(spec: BreakpointSpec, opts?: { condition?: string }): Promise<BreakpointInfo>;
1212
removeBreakpoint(id: number): Promise<string>;
1313

1414
getStack(opts?: { threadIndex?: number; maxFrames?: number }): Promise<string>;

0 commit comments

Comments
 (0)