Implement a real lldb-dap Debug Adapter Protocol backend that plugs into the existing debugger architecture without changing MCP tool names/schemas. The DAP backend remains opt-in only via XCODEBUILDMCP_DEBUGGER_BACKEND=dap (current selection logic in src/utils/debugger/debugger-manager.ts).
Key integration points already in place:
- Backend contract:
src/utils/debugger/backends/DebuggerBackend.ts - Backend selection & session lifecycle:
src/utils/debugger/debugger-manager.ts - MCP tool surface area:
src/mcp/tools/debugging/*(attach, breakpoints, stack, variables, command, detach) - Subprocess patterns:
src/utils/execution/interactive-process.ts(interactive, piped stdio, test-safe default spawner) - DI/test safety: defaults throw under Vitest (
getDefaultCommandExecutor,getDefaultInteractiveSpawner) - Docs baseline:
docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md,docs/DEBUGGING_ARCHITECTURE.md
Implemented modules and behavior (as of this document):
- DAP protocol and transport:
src/utils/debugger/dap/types.ts,src/utils/debugger/dap/transport.ts - Adapter discovery:
src/utils/debugger/dap/adapter-discovery.ts - Backend implementation:
src/utils/debugger/backends/dap-backend.ts - Conditional breakpoints: backend-level support via
DebuggerBackend.addBreakpoint(..., { condition }) - Tool updates:
src/mcp/tools/debugging/debug_breakpoint_add.tspasses conditions to backend - Health check:
doctornow reportslldb-dapavailability - Tests: DAP transport framing, backend mapping, and debugger manager selection tests
| MCP tool | DebuggerManager call | DAP requests |
|---|---|---|
debug_attach_sim |
createSession → attach |
initialize → attach → configurationDone |
debug_lldb_command |
runCommand |
evaluate (context: repl) |
debug_stack |
getStack |
threads → stackTrace |
debug_variables |
getVariables |
threads → stackTrace → scopes → variables |
debug_breakpoint_add |
addBreakpoint |
setBreakpoints / setFunctionBreakpoints |
debug_breakpoint_remove |
removeBreakpoint |
setBreakpoints / setFunctionBreakpoints |
debug_detach |
detach |
disconnect |
- Breakpoints are stateful: DAP removal re-applies
setBreakpoints/setFunctionBreakpointswith the remaining list. - Conditions are passed as part of the breakpoint request in both backends:
- DAP:
breakpoints[].conditionorfunctionBreakpoints[].condition - LLDB CLI:
breakpoint modify -c "<condition>" <id>
- DAP:
Decision: Each DebuggerManager.createSession() creates a new backend instance, which owns a single lldb-dap subprocess for the lifetime of that session.
- Aligns with current LLDB CLI backend (one long-lived interactive
lldbper session). - Keeps multi-session support (
DebuggerManager.sessions: Map) straightforward.
Decision: Build a dedicated DAP transport that:
- implements
Content-Lengthframing - correlates requests/responses by
seq - emits DAP events
This keeps DapBackend focused on mapping MCP tool operations → DAP requests.
Decision: Extend internal debugger API to support conditional breakpoints without relying on “LLDB command follow-ups” (which are CLI-specific).
This avoids depending on DAP evaluate for breakpoint modification and keeps semantics consistent across backends.
Define minimal DAP types used by the backend (not a full spec).
Example types (illustrative, not exhaustive):
export type DapRequest<C = unknown> = {
seq: number;
type: 'request';
command: string;
arguments?: C;
};
export type DapResponse<B = unknown> = {
seq: number;
type: 'response';
request_seq: number;
success: boolean;
command: string;
message?: string;
body?: B;
};
export type DapEvent<B = unknown> = {
seq: number;
type: 'event';
event: string;
body?: B;
};Also define bodies used in mapping:
InitializeResponseBody(capabilities)ThreadsResponseBodyStackTraceResponseBodyScopesResponseBodyVariablesResponseBodySetBreakpointsResponseBodyEvaluateResponseBody- event bodies:
StoppedEventBody,OutputEventBody,TerminatedEventBody
Side effects / impact: none outside debugger subsystem; ensures type safety inside DAP modules.
Implement DAP over stdio.
Dependencies / imports
node:events(EventEmitter) or a small typed emitter patternsrc/utils/execution/index.tsforInteractiveSpawnerandInteractiveProcesstypessrc/utils/logging/index.tsforlogsrc/utils/CommandExecutor.tstype (for adapter discovery helper if kept here)
Core responsibilities
- Spawn adapter process (or accept an already spawned
InteractiveProcess) - Parse stdout stream into discrete DAP messages using
Content-Lengthframing - Maintain:
nextSeq: numberpending: Map<number, { resolve, reject, timeout }>keyed by requestseq
- Expose:
sendRequest(command, args, opts?) => Promise<body>- event subscription:
onEvent(handler)oron('event', ...) - lifecycle:
dispose()(must not throw)
Key function signatures
export type DapTransportOptions = {
spawner: InteractiveSpawner;
adapterCommand: string[]; // e.g. ['xcrun', 'lldb-dap'] or [resolvedPath]
env?: Record<string, string>;
cwd?: string;
logPrefix?: string;
};
export class DapTransport {
constructor(opts: DapTransportOptions);
sendRequest<A, B>(
command: string,
args?: A,
opts?: { timeoutMs?: number },
): Promise<B>;
onEvent(handler: (evt: DapEvent) => void): () => void;
dispose(): void; // best-effort, never throw
}Framing logic
- Maintain an internal
Buffer/string accumulator for stdout. - Repeatedly:
- find
\r\n\r\n - parse headers for
Content-Length - wait until body bytes are available
JSON.parsebody into{ type: 'response' | 'event' | 'request' }
- find
Process failure handling
- On adapter
exit/error, reject all pending requests with a clear error (and include exit detail). - Log stderr output at
debuglevel; do not feed stderr into framing.
Concurrency
- Transport supports multiple in-flight requests concurrently (DAP allows it).
- Backend may still serialize higher-level operations if stateful.
Side effects
- Add a long-lived child process per session.
- Requires careful memory management in the framing buffer (ensure you slice consumed bytes).
Purpose: centralize resolution and produce actionable errors when DAP is explicitly selected but unavailable.
Uses
CommandExecutorto runxcrun --find lldb-daplogfor diagnostics- throw a
DependencyError(fromsrc/utils/errors.ts) or plainErrorwith a consistent message
Example signature:
import type { CommandExecutor } from '../../execution/index.ts';
export async function resolveLldbDapCommand(opts: {
executor: CommandExecutor;
}): Promise<string[]>;
// returns e.g. ['xcrun', 'lldb-dap'] OR [absolutePath]Design choice
- Returning
['xcrun','lldb-dap']is simplest (no dependency on parsing). - Returning
[absolutePath]provides a stronger “tool exists” guarantee.
Impact
- Enables a clean error message early in session creation.
- Keeps
DapBackendsimpler.
Implemented as a real backend that:
- discovers adapter (
resolveLldbDapCommand) - creates
DapTransport - performs DAP handshake (
initialize) - attaches by PID (
attach) - maps backend interface methods to DAP requests
Dependencies
DapTransportresolveLldbDapCommandgetDefaultCommandExecutorandgetDefaultInteractiveSpawner(production defaults)log- existing backend interface/types
Constructor / factory
Update createDapBackend() to accept injectable deps, mirroring the CLI backend’s injection style.
export async function createDapBackend(opts?: {
executor?: CommandExecutor;
spawner?: InteractiveSpawner;
requestTimeoutMs?: number;
}): Promise<DebuggerBackend>;This is critical for tests because defaults throw under Vitest.
Session state to maintain inside DapBackend
transport: DapTransport | nullattached: booleanlastStoppedThreadId: number | nullcachedThreads: { id: number; name?: string }[] | null(optional)- breakpoint registry:
breakpointsById: Map<number, BreakpointSpec & { condition?: string }>- for DAP “remove breakpoint”, you must re-issue
setBreakpoints/setFunctionBreakpointswith the updated list, so also keep:fileLineBreakpointsByFile: Map<string, Array<{ line: number; condition?: string; id?: number }>>functionBreakpoints: Array<{ name: string; condition?: string; id?: number }>
- optional cached stack frames from the last
stackTracecall (for variables lookup)
Backend lifecycle mapping
-
attach():- spawn
lldb-dap initializeattachwith pid (+ waitFor mapping)configurationDoneif required by lldb-dap behavior (plan for it even if no-op)- mark attached
- spawn
-
detach()- send
disconnectwithterminateDebuggee: false(do not kill app) - dispose transport / kill process
- send
-
dispose()- best-effort cleanup; must not throw (important because
DebuggerManager.createSessioncalls dispose to clean up on attach failure)
- best-effort cleanup; must not throw (important because
Method mappings (MCP tools → DebuggerManager → DapBackend)
runCommand(command: string, opts?)
- Map to DAP
evaluatewithcontext: 'repl' - Return string output from
EvaluateResponse.body.resultand/orbody.output - If adapter doesn’t support command-style repl evaluation, return a clear error message suggesting
lldb-clibackend.
getStack(opts?: { threadIndex?: number; maxFrames?: number })
- DAP sequence:
threads- select thread:
- if a
stoppedevent has athreadId, prefer that whenthreadIndexis undefined - else map
threadIndexto array index (document this)
- if a
stackTrace({ threadId, startFrame: 0, levels: maxFrames })
- Format output as readable text (LLDB-like) to keep tool behavior familiar:
frame #<i>: <name> at <path>:<line>
- If stackTrace fails due to running state, return a helpful error:
- “Process is running; pause or hit a breakpoint to fetch stack.”
getVariables(opts?: { frameIndex?: number })
- DAP sequence:
- resolve thread as above
stackTraceto get frames- choose frame by
frameIndex(default 0) scopes({ frameId })- for each scope:
variables({ variablesReference })
- Format output as text with sections per scope:
Locals:\n x = 1\n y = ...
addBreakpoint(spec: BreakpointSpec, opts?: { condition?: string })
- For
file-line:- update
fileLineBreakpointsByFile[file] - call
setBreakpoints({ source: { path: file }, breakpoints: [{ line, condition }] }) - parse returned
breakpoints[]to find matching line and captureid
- update
- For
function:- update
functionBreakpoints - call
setFunctionBreakpoints({ breakpoints: [{ name, condition }] })
- update
- Return
BreakpointInfo:idmust be a number (from DAP breakpoint id; if missing, generate a synthetic id and store mapping, but prefer real id)rawOutputcan be a pretty JSON snippet or a short text summary
removeBreakpoint(id: number)
- Look up spec in
breakpointsById - Remove it from the corresponding registry
- Re-issue
setBreakpointsorsetFunctionBreakpointswith the remaining breakpoints - Return text confirmation
Important: DAP vs existing condition flow
- Today
debug_breakpoint_addsets condition by issuing an LLDB command after creation. - With the above, condition becomes part of breakpoint creation and removal logic, backend-agnostic.
Update signature:
addBreakpoint(spec: BreakpointSpec, opts?: { condition?: string }): Promise<BreakpointInfo>;Update method:
async addBreakpoint(
id: string | undefined,
spec: BreakpointSpec,
opts?: { condition?: string },
): Promise<BreakpointInfo>Pass opts through to backend.addBreakpoint.
Impact
- Requires updating both backends + the tool call site.
- Improves cross-backend compatibility and avoids “DAP evaluate must support breakpoint modify”.
Implement condition via LLDB command internally after breakpoint creation (current behavior, just moved):
- after parsing breakpoint id:
- if
opts?.condition, runbreakpoint modify -c "<escaped>" <id>
- if
This keeps condition support identical for LLDB CLI users.
Change logic to pass condition into ctx.debugger.addBreakpoint(...) and remove the follow-up breakpoint modify ... command.
Before
- call
addBreakpoint() - if condition, call
runCommand("breakpoint modify ...")
After
- call
addBreakpoint(sessionId, spec, { condition }) - no extra
runCommandrequired
Impact / side effects
- Output remains the same shape, but the “rawOutput” content for DAP may differ (acceptable).
- Improves backend portability.
Keep selection rules but improve failure clarity:
- If backend kind is
dap, and adapter discovery fails, throw an error like:DAP backend selected but lldb-dap not found. Ensure Xcode is installed and xcrun can locate lldb-dap, or set XCODEBUILDMCP_DEBUGGER_BACKEND=lldb-cli.
Also ensure that dispose failures do not mask attach failures:
- in
createSessioncatch, wrapdispose()in its own try/catch (even if backend should not throw).
Add a DAP capability line:
lldb-dap available: yes/no- if env selects dap, include a prominent warning/error section when missing
Implementation approach:
- reuse
CommandExecutorand callxcrun --find lldb-dap - do not fail doctor entirely if missing; just report
Side effects
- Improves discoverability and reduces “mystery failures” when users opt into dap.
- Fully concurrent in-flight DAP requests supported via:
seqgenerationpendingmap keyed byseq
- Each request can set its own timeout (
timeoutMs).
Use a serialized queue only where state mutation occurs, e.g.:
- updating breakpoint registries
- attach/detach transitions
Pattern (same as LLDB CLI backend):
private queue: Promise<unknown> = Promise.resolve();
private enqueue<T>(work: () => Promise<T>): Promise<T> { ... }Reasoning
- Prevent races such as:
- addBreakpoint + removeBreakpoint in parallel, reissuing
setBreakpointsinconsistently.
- addBreakpoint + removeBreakpoint in parallel, reissuing
- Backend throws
Errorwith clear messages. - MCP tools already catch and wrap errors via
createErrorResponse(...).
DapTransport:log('debug', ...)for raw events (optionally gated by env)log('error', ...)on process exit while requests are pending
DapBackend:- minimal
infologs on attach/detach debuglogs for request mapping (command names, not full payloads unless opted in)
- minimal
Document these (no need to require them):
XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS(default to 30_000)XCODEBUILDMCP_DAP_LOG_EVENTS=true(default false)
Even though this is “testing”, it directly impacts design because default spawners/executors throw under Vitest.
Add:
export function createMockInteractiveSpawner(script: {
// map writes -> stdout/stderr emissions, or a programmable fake
}): InteractiveSpawner;This avoids ad-hoc manual mocks and matches the project’s “approved mocks live in test-utils” philosophy.
New: src/utils/debugger/dap/__tests__/transport-framing.test.ts
- Feed partial header/body chunks into the transport parser using
PassThroughstreams behind a mock InteractiveProcess. - Assert:
- correct parsing across chunk boundaries
- multiple messages in one chunk
- invalid Content-Length handling
New: src/utils/debugger/backends/__tests__/dap-backend.test.ts
- Use
createMockExecutor()to fake adapter discovery. - Use
createMockInteractiveSpawner()to simulate an adapter that returns scripted DAP responses:- initialize → success
- attach → success
- threads/stackTrace/scopes/variables → stable fixtures
- Validate:
getStack()formattinggetVariables()formatting- breakpoint add/remove registry behavior
dispose()never throws
New: src/utils/debugger/__tests__/debugger-manager-dap.test.ts
- Inject a custom
backendFactorythat returns a fake backend (or the scripted DAP backend) and verify:- env selection
- attach failure triggers dispose
- current session behavior unchanged
Replace/extend the existing outline with the following:
- finalized module list (
dap/types.ts,dap/transport.ts, discovery helper) - breakpoint strategy (stateful re-issue
setBreakpoints) - explicit mapping table per MCP tool
Add a section “DAP Backend (lldb-dap)”:
- how it’s selected (opt-in)
- differences vs LLDB CLI (structured stack/variables, breakpoint reapplication)
- note about process state (stack/variables usually require stopped context)
- explain that conditional breakpoints are implemented backend-side
- Ensure
lldb-dapis discoverable:xcrun --find lldb-dap
- Run server with DAP enabled:
XCODEBUILDMCP_DEBUGGER_BACKEND=dap node build/index.js
- Use existing MCP tool flow:
debug_attach_sim(attach by PID or bundleId)debug_breakpoint_add(with condition)- trigger breakpoint (or pause via
debug_lldb_commandif implemented via evaluate) debug_stack,debug_variablesdebug_detach
- If the target is running and no stop context exists, DAP
stackTrace/variablesmay fail; return guidance in tool output (“pause or set breakpoint”).
src/utils/debugger/dap/types.tssrc/utils/debugger/dap/transport.tssrc/utils/debugger/dap/adapter-discovery.ts(recommended)
src/utils/debugger/backends/dap-backend.ts(real implementation)src/utils/debugger/backends/DebuggerBackend.ts(add breakpoint condition option)src/utils/debugger/backends/lldb-cli-backend.ts(support condition via new opts)src/utils/debugger/debugger-manager.ts(pass-through opts; optional improved error handling)src/mcp/tools/debugging/debug_breakpoint_add.ts(use backend-level condition support)src/mcp/tools/doctor/doctor.ts(reportlldb-dapavailability)docs/DAP_BACKEND_IMPLEMENTATION_PLAN.mddocs/DEBUGGING_ARCHITECTURE.mdsrc/test-utils/mock-executors.ts(add mock interactive spawner)
dispose()in DAP backend and transport must be best-effort and never throw becauseDebuggerManager.createSession()will call dispose on attach failure.- Avoid any use of default executors/spawners in tests; ensure
createDapBackend()accepts injectedexecutor+spawner. - Breakpoint removal requires stateful re-application with
setBreakpoints/setFunctionBreakpoints; plan for breakpoint registries from day one.