From b8e6a16cb9927208d8382686fbf02b4c2b34d43e Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 2 Jan 2026 11:22:33 +0000 Subject: [PATCH 01/20] WIP --- docs/DEBUGGING_ARCHITECTURE.md | 277 ++++++++++++++++++ docs/TOOLS.md | 20 +- docs/dev/ARCHITECTURE.md | 19 +- .../issue-154-screenshot-downscaling.md | 44 +++ ...describe-ui-empty-after-debugger-resume.md | 53 ++++ src/core/generated-plugins.ts | 34 +++ src/index.ts | 3 + src/mcp/tools/debugging/debug_attach_sim.ts | 157 ++++++++++ .../tools/debugging/debug_breakpoint_add.ts | 80 +++++ .../debugging/debug_breakpoint_remove.ts | 42 +++ src/mcp/tools/debugging/debug_detach.ts | 43 +++ src/mcp/tools/debugging/debug_lldb_command.ts | 53 ++++ src/mcp/tools/debugging/debug_stack.ts | 46 +++ src/mcp/tools/debugging/debug_variables.ts | 44 +++ src/mcp/tools/debugging/index.ts | 5 + .../ui-testing/__tests__/describe_ui.test.ts | 3 +- src/mcp/tools/ui-testing/describe_ui.ts | 3 +- src/utils/__tests__/debugger-simctl.test.ts | 50 ++++ .../debugger/backends/DebuggerBackend.ts | 18 ++ src/utils/debugger/backends/dap-backend.ts | 52 ++++ .../debugger/backends/lldb-cli-backend.ts | 222 ++++++++++++++ src/utils/debugger/debugger-manager.ts | 176 +++++++++++ src/utils/debugger/index.ts | 19 ++ src/utils/debugger/simctl.ts | 38 +++ src/utils/debugger/tool-context.ts | 16 + src/utils/debugger/types.ts | 20 ++ src/utils/execution/index.ts | 6 + src/utils/execution/interactive-process.ts | 80 +++++ src/utils/typed-tool-factory.ts | 82 ++++-- 29 files changed, 1673 insertions(+), 32 deletions(-) create mode 100644 docs/DEBUGGING_ARCHITECTURE.md create mode 100644 docs/investigations/issue-154-screenshot-downscaling.md create mode 100644 docs/investigations/issue-describe-ui-empty-after-debugger-resume.md create mode 100644 src/mcp/tools/debugging/debug_attach_sim.ts create mode 100644 src/mcp/tools/debugging/debug_breakpoint_add.ts create mode 100644 src/mcp/tools/debugging/debug_breakpoint_remove.ts create mode 100644 src/mcp/tools/debugging/debug_detach.ts create mode 100644 src/mcp/tools/debugging/debug_lldb_command.ts create mode 100644 src/mcp/tools/debugging/debug_stack.ts create mode 100644 src/mcp/tools/debugging/debug_variables.ts create mode 100644 src/mcp/tools/debugging/index.ts create mode 100644 src/utils/__tests__/debugger-simctl.test.ts create mode 100644 src/utils/debugger/backends/DebuggerBackend.ts create mode 100644 src/utils/debugger/backends/dap-backend.ts create mode 100644 src/utils/debugger/backends/lldb-cli-backend.ts create mode 100644 src/utils/debugger/debugger-manager.ts create mode 100644 src/utils/debugger/index.ts create mode 100644 src/utils/debugger/simctl.ts create mode 100644 src/utils/debugger/tool-context.ts create mode 100644 src/utils/debugger/types.ts create mode 100644 src/utils/execution/interactive-process.ts diff --git a/docs/DEBUGGING_ARCHITECTURE.md b/docs/DEBUGGING_ARCHITECTURE.md new file mode 100644 index 00000000..7e81cc8d --- /dev/null +++ b/docs/DEBUGGING_ARCHITECTURE.md @@ -0,0 +1,277 @@ +# Debugging Architecture + +This document describes how the simulator debugging tools are wired, how sessions are managed, +and how external tools (simctl, Simulator, LLDB, xcodebuild) are invoked. + +## Scope + +- Tools: `src/mcp/tools/debugging/*` +- Debugger subsystem: `src/utils/debugger/*` +- Execution and tool wiring: `src/utils/typed-tool-factory.ts`, `src/utils/execution/*` +- External invocation: `xcrun simctl`, `xcrun lldb`, `xcodebuild` + +## Registration and Wiring + +- Workflow discovery is automatic: `src/core/plugin-registry.ts` loads debugging tools via the + generated workflow loaders (`src/core/generated-plugins.ts`). +- Tool handlers are created with the typed tool factory: + - `createTypedToolWithContext` for standard tools (Zod validation + dependency injection). + - `createSessionAwareToolWithContext` for session-aware tools (merges session defaults and + validates requirements). +- Debugging tools inject a `DebuggerToolContext` that provides: + - `executor`: a `CommandExecutor` used for simctl and other command execution. + - `debugger`: a shared `DebuggerManager` instance. + +## Session Defaults and Validation + +- Session defaults live in `src/utils/session-store.ts` and are merged with user args by the + session-aware tool factory. +- `debug_attach_sim` is session-aware; it can omit `simulatorId`/`simulatorName` in the public + schema and rely on session defaults. +- The `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS` env flag exposes legacy schemas that include all + parameters (no session default hiding). + +## Debug Session Lifecycle + +`DebuggerManager` owns lifecycle, state, and backend routing: + +Backend selection happens inside `DebuggerManager.createSession`: + +- Selection order: explicit `backend` argument -> `XCODEBUILDMCP_DEBUGGER_BACKEND` -> default `lldb-cli`. +- Env values: `lldb-cli`/`lldb` -> `lldb-cli`, `dap` -> `dap`, anything else throws. +- Backend factory: `defaultBackendFactory` maps `lldb-cli` to `createLldbCliBackend` and `dap` to + `createDapBackend`. A custom factory can be injected for tests or extensions. + +1. `debug_attach_sim` resolves simulator UUID and PID, then calls + `DebuggerManager.createSession`. +2. `DebuggerManager` creates a backend (default `lldb-cli`), attaches to the process, and stores + session metadata (id, simulatorId, pid, timestamps). +3. Debugging tools (`debug_lldb_command`, `debug_stack`, `debug_variables`, + `debug_breakpoint_add/remove`) look up the session (explicit id or current) and route commands + to the backend. +4. `debug_detach` calls `DebuggerManager.detachSession` to detach and dispose the backend. + +## Debug Session + Command Execution Flow + +Session lifecycle flow (text): + +1. Client calls `debug_attach_sim`. +2. `debug_attach_sim` resolves simulator UUID and PID, then calls `DebuggerManager.createSession`. +3. `DebuggerManager.createSession` resolves backend kind (explicit/env/default), instantiates the + backend, and calls `backend.attach`. +4. Command tools (`debug_lldb_command`, `debug_stack`, `debug_variables`) call + `DebuggerManager.runCommand`/`getStack`/`getVariables`, which route to the backend. +5. `debug_detach` calls `DebuggerManager.detachSession`, which invokes `backend.detach` and + `backend.dispose`. + +`LldbCliBackend.runCommand()` flow (text): + +1. Enqueue the command to serialize LLDB access. +2. Await backend readiness (`initialize` completed). +3. Write the command to the interactive process. +4. Write `script print("__XCODEBUILDMCP_DONE__")` to emit the sentinel marker. +5. Buffer stdout/stderr until the sentinel is detected. +6. Trim the buffer to the next prompt, sanitize output, and return the result. + +
+Sequence diagrams (Mermaid) + +```mermaid +sequenceDiagram + participant U as User/Client + participant A as debug_attach_sim + participant M as DebuggerManager + participant F as backendFactory + participant B as DebuggerBackend (lldb-cli|dap) + participant L as LldbCliBackend + participant P as InteractiveProcess (xcrun lldb) + + U->>A: debug_attach_sim(simulator*, bundleId|pid) + A->>A: determineSimulatorUuid(...) + A->>A: resolveSimulatorAppPid(...) (if bundleId) + A->>M: createSession({simulatorId, pid, waitFor}) + M->>M: resolveBackendKind(explicit/env/default) + M->>F: create backend(kind) + F-->>M: backend instance + M->>B: attach({pid, simulatorId, waitFor}) + alt kind == lldb-cli + B-->>L: (is LldbCliBackend) + L->>P: spawn xcrun lldb + initialize prompt/sentinel + else kind == dap + B-->>M: throws DAP_ERROR_MESSAGE + end + M-->>A: DebugSessionInfo {id, backend, ...} + A->>M: setCurrentSession(id) (optional) + U->>M: runCommand(id?, "thread backtrace") + M->>B: runCommand(...) + U->>M: detachSession(id?) + M->>B: detach() + M->>B: dispose() +``` + +```mermaid +sequenceDiagram + participant T as debug_lldb_command + participant M as DebuggerManager + participant L as LldbCliBackend + participant P as InteractiveProcess + participant S as stdout/stderr buffer + + T->>M: runCommand(sessionId?, command, {timeoutMs?}) + M->>L: runCommand(command) + L->>L: enqueue(work) + L->>L: await ready (initialize()) + L->>P: write(command + "\n") + L->>P: write('script print("__XCODEBUILDMCP_DONE__")\n') + P-->>S: stdout/stderr chunks + S-->>L: handleData() appends to buffer + L->>L: checkPending() finds sentinel + L->>L: slice output up to sentinel + L->>L: trim buffer to next prompt (if present) + L->>L: sanitizeOutput() + trimEnd() + L-->>M: output string + M-->>T: output string +``` + +
+ +## LLDB CLI Backend (Default) + +- Backend implementation: `src/utils/debugger/backends/lldb-cli-backend.ts`. +- Uses `InteractiveSpawner` from `src/utils/execution/interactive-process.ts` to keep a single + long-lived `xcrun lldb` process alive. +- Keeps LLDB state (breakpoints, selected frames, target) across tool calls without reattaching. + +### Internals: interactive process model + +- The backend spawns `xcrun lldb --no-lldbinit -o "settings set prompt "`. +- `InteractiveProcess.write()` is used to send commands; stdout and stderr are merged into a single + parse buffer. +- `InteractiveProcess.dispose()` closes stdin, removes listeners, and kills the process. + +### Prompt and sentinel protocol + +The backend uses a prompt + sentinel protocol to detect command completion reliably: + +- `LLDB_PROMPT = "XCODEBUILDMCP_LLDB> "` +- `COMMAND_SENTINEL = "__XCODEBUILDMCP_DONE__"` + +Definitions: + +- Prompt: the LLDB REPL prompt string that indicates LLDB is ready to accept the next command. +- Sentinel: a unique marker explicitly printed after each command to mark the end of that + command's output. + +Protocol flow: + +1. Startup: write `script print("__XCODEBUILDMCP_DONE__")` to prime the prompt parser. +2. For each command: + - Write the command. + - Write `script print("__XCODEBUILDMCP_DONE__")`. + - Read until the sentinel is observed, then trim up to the next prompt. + +The sentinel marks command completion, while the prompt indicates the REPL is ready for the next +command. + +Why both a prompt and a sentinel? + +- The sentinel is the explicit end-of-output marker; LLDB does not provide a reliable boundary for + arbitrary command output otherwise. +- The prompt is used to cleanly align the buffer for the next command after the sentinel is seen. + +Annotated example (simplified): + +1. Backend writes: + - `thread backtrace` + - `script print("__XCODEBUILDMCP_DONE__")` +2. LLDB emits (illustrative): + - `... thread backtrace output ...` + - `__XCODEBUILDMCP_DONE__` + - `XCODEBUILDMCP_LLDB> ` +3. Parser behavior: + - Sentinel marks the end of the command output payload. + - Prompt is used to trim the buffer so the next command starts cleanly. + +### Output parsing and sanitization + +- `handleData()` appends to an internal buffer, and `checkPending()` scans for the sentinel regex + `/(^|\\r?\\n)__XCODEBUILDMCP_DONE__(\\r?\\n)/`. +- Output is the buffer up to the sentinel. The remainder is trimmed to the next prompt if present. +- `sanitizeOutput()` removes prompt echoes, sentinel lines, the `script print(...)` lines, and empty + lines, then `runCommand()` returns `trimEnd()` output. + +### Concurrency model (queueing) + +- Commands are serialized through a promise queue to avoid interleaved output. +- `waitForSentinel()` rejects if a pending command exists, acting as a safety check. + +### Timeouts, errors, and disposal + +- Startup timeout: `DEFAULT_STARTUP_TIMEOUT_MS = 10_000`. +- Per-command timeout: `DEFAULT_COMMAND_TIMEOUT_MS = 30_000` (override via `runCommand` opts). +- Timeout failure clears the pending command and rejects the promise. +- `assertNoLldbError()` throws if `/error:/i` appears in output (simple heuristic). +- Process exit triggers `failPending(new Error(...))` so in-flight calls fail promptly. +- `runCommand()` rejects immediately if the backend is already disposed. + +### Testing and injection + +`getDefaultInteractiveSpawner()` throws in test environments to prevent spawning real interactive +processes. Tests should inject a mock `InteractiveSpawner` into `createLldbCliBackend()` or a custom +`DebuggerManager` backend factory. + +## DAP Backend (Stub / Not Implemented) + +- Implementation: `src/utils/debugger/backends/dap-backend.ts`. +- Selected via backend selection (explicit `backend`, `XCODEBUILDMCP_DEBUGGER_BACKEND=dap`). +- Current status: not implemented. All `DebuggerBackend` methods throw `DAP_ERROR_MESSAGE`, + including `dispose()`. + +Practical effect: + +- Setting `XCODEBUILDMCP_DEBUGGER_BACKEND=dap` causes `debug_attach_sim` to fail during + session creation because `backend.attach()` throws. +- `DebuggerManager.createSession` attempts to call `dispose()` on failure, but the stub `dispose()` + also throws (same message). This is expected until a real DAP backend exists. + +Use `lldb-cli` (default) for actual debugging. + +## External Tool Invocation + +### simctl and Simulator + +- Simulator UUID resolution uses `xcrun simctl list devices available -j` + (`determineSimulatorUuid` in `src/utils/simulator-utils.ts`). +- PID lookup uses `xcrun simctl spawn launchctl list` + (`resolveSimulatorAppPid` in `src/utils/debugger/simctl.ts`). + +### LLDB + +- Attachment uses `xcrun lldb --no-lldbinit` in the interactive backend. +- Breakpoint conditions are applied by issuing an additional LLDB command: + `breakpoint modify -c "" `. + +### xcodebuild (Build/Launch Context) + +- Debugging assumes a running simulator app. +- The typical flow is build and launch via simulator tools (for example `build_sim`), + which uses `executeXcodeBuildCommand` to invoke `xcodebuild` (or `xcodemake` when enabled). +- After launch, `debug_attach_sim` attaches LLDB to the simulator process. + +## Typical Tool Flow + +1. Build and launch the app on a simulator (`build_sim`, `launch_app_sim`). +2. Attach LLDB (`debug_attach_sim`) using session defaults or explicit simulator + bundle ID. +3. Set breakpoints (`debug_breakpoint_add`), inspect stack/variables (`debug_stack`, + `debug_variables`), and issue arbitrary LLDB commands (`debug_lldb_command`). +4. Detach when done (`debug_detach`). + +## Integration Points Summary + +- Tool entrypoints: `src/mcp/tools/debugging/*` +- Session defaults: `src/utils/session-store.ts` +- Debug session manager: `src/utils/debugger/debugger-manager.ts` +- Backends: `src/utils/debugger/backends/lldb-cli-backend.ts` (default), + `src/utils/debugger/backends/dap-backend.ts` (stub) +- Interactive execution: `src/utils/execution/interactive-process.ts` (used by LLDB CLI backend) +- External commands: `xcrun simctl`, `xcrun lldb`, `xcodebuild` diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 962e3cff..42fbcff6 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,6 +1,6 @@ # XcodeBuildMCP Tools Reference -XcodeBuildMCP provides 63 tools organized into 12 workflow groups for comprehensive Apple development workflows. +XcodeBuildMCP provides 70 tools organized into 13 workflow groups for comprehensive Apple development workflows. ## Workflow Groups @@ -68,6 +68,16 @@ XcodeBuildMCP provides 63 tools organized into 12 workflow groups for comprehens - `session_clear_defaults` - Clear selected or all session defaults. - `session_set_defaults` - Set the session defaults needed by many tools. Most tools require one or more session defaults to be set before they can be used. Agents should set all relevant defaults up front in a single call (e.g., project/workspace, scheme, simulator or device ID, useLatestOS) to avoid iterative prompts; only set the keys your workflow needs. - `session_show_defaults` - Show current session defaults. +### Simulator Debugging (`debugging`) +**Purpose**: Interactive iOS Simulator debugging tools: attach LLDB, manage breakpoints, inspect stack/variables, and run LLDB commands. (7 tools) + +- `debug_attach_sim` - Attach LLDB to a running iOS simulator app. Provide bundleId or pid, plus simulator defaults. +- `debug_breakpoint_add` - Add a breakpoint by file/line or function name for the active debug session. +- `debug_breakpoint_remove` - Remove a breakpoint by id for the active debug session. +- `debug_detach` - Detach the current debugger session or a specific debugSessionId. +- `debug_lldb_command` - Run an arbitrary LLDB command within the active debug session. +- `debug_stack` - Return a thread backtrace from the active debug session. +- `debug_variables` - Return variables for a selected frame in the active debug session. ### Simulator Management (`simulator-management`) **Purpose**: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. (5 tools) @@ -93,7 +103,7 @@ XcodeBuildMCP provides 63 tools organized into 12 workflow groups for comprehens **Purpose**: UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows. (11 tools) - `button` - Press hardware button on iOS simulator. Supported buttons: apple-pay, home, lock, side-button, siri -- `describe_ui` - Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation. +- `describe_ui` - Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation. Requires the target process to be running; paused debugger/breakpoints can yield an empty tree. - `gesture` - Perform gesture on iOS simulator using preset gestures: scroll-up, scroll-down, scroll-left, scroll-right, swipe-from-left-edge, swipe-from-right-edge, swipe-from-top-edge, swipe-from-bottom-edge - `key_press` - Press a single key by keycode on the simulator. Common keycodes: 40=Return, 42=Backspace, 43=Tab, 44=Space, 58-67=F1-F10. - `key_sequence` - Press key sequence using HID keycodes on iOS simulator with configurable delay @@ -106,9 +116,9 @@ XcodeBuildMCP provides 63 tools organized into 12 workflow groups for comprehens ## Summary Statistics -- **Total Tools**: 63 canonical tools + 22 re-exports = 85 total -- **Workflow Groups**: 12 +- **Total Tools**: 70 canonical tools + 22 re-exports = 92 total +- **Workflow Groups**: 13 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2025-12-30* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-02* diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index 4665d94f..f466b7d2 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -223,14 +223,14 @@ export async function someToolLogic( executor: CommandExecutor, ): Promise { log('info', `Executing some_tool with param: ${params.requiredParam}`); - + try { const result = await executor(['some', 'command'], 'Some Tool Operation'); - + if (!result.success) { return createErrorResponse('Operation failed', result.error); } - + return createTextResponse(`✅ Success: ${result.output}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -243,7 +243,7 @@ export default { name: 'some_tool', description: 'Tool description for AI agents. Example: some_tool({ requiredParam: "value" })', schema: someToolSchema.shape, // Expose shape for MCP SDK - + // 5. Create the handler using the type-safe factory handler: createTypedTool( someToolSchema, @@ -260,6 +260,15 @@ This pattern ensures that: - Import paths use focused facades for clear dependency management ``` +### Debugger Subsystem + +The debugging workflow relies on a long-lived, interactive LLDB subprocess. A `DebuggerManager` owns the session lifecycle and routes tool calls to a backend implementation. The default backend is the LLDB CLI (`xcrun lldb --no-lldbinit`) and configures a unique prompt sentinel to safely read command results. A stub DAP backend exists for future expansion. + +Key elements: +- **Interactive execution**: Uses a dedicated interactive spawner with `stdin: 'pipe'` so LLDB commands can be streamed across multiple tool calls. +- **Session manager**: Tracks debug session metadata (session id, simulator id, pid, timestamps) and maintains a “current” session. +- **Backend abstraction**: `DebuggerBackend` keeps the tool contract stable while allowing future DAP support. + ### MCP Resources System XcodeBuildMCP provides dual interfaces: traditional MCP tools and efficient MCP resources for supported clients. Resources are located in `src/mcp/resources/` and are automatically discovered **at build time**. The build process generates `src/core/generated-resources.ts`, which contains dynamic loaders for each resource, improving startup performance. For more details on creating resources, see the [Plugin Development Guide](PLUGIN_DEVELOPMENT.md). @@ -432,7 +441,7 @@ describe('Tool Name', () => { // 2. Call the tool's logic function, injecting the mock executor const result = await someToolLogic({ requiredParam: 'value' }, mockExecutor); - + // 3. Assert the final result expect(result).toEqual({ content: [{ type: 'text', text: 'Expected output' }], diff --git a/docs/investigations/issue-154-screenshot-downscaling.md b/docs/investigations/issue-154-screenshot-downscaling.md new file mode 100644 index 00000000..b7fe4ee9 --- /dev/null +++ b/docs/investigations/issue-154-screenshot-downscaling.md @@ -0,0 +1,44 @@ +# Investigation: Optional Screenshot Downscaling (Issue #154) + +## Summary +Investigation started; initial context gathered from the issue description. Context builder failed (Gemini CLI usage error), so manual exploration is proceeding. + +## Symptoms +- Screenshots captured for UI automation are full-resolution by default. +- High-resolution screenshots increase multimodal token usage and cost. + +## Investigation Log + +### 2025-01-XX - Initial assessment +**Hypothesis:** Screenshot pipeline always emits full-resolution images and lacks an opt-in scaling path. +**Findings:** Issue describes full-res screenshots and requests optional downscaling. No code inspected yet. +**Evidence:** GitHub issue #154 body. +**Conclusion:** Needs codebase investigation. + +### 2025-01-XX - Context builder attempt +**Hypothesis:** Use automated context discovery to map screenshot capture flow. +**Findings:** `context_builder` failed due to Gemini CLI usage error in this environment. +**Evidence:** Tool error output in session (Gemini CLI usage/help text). +**Conclusion:** Proceeding with manual code inspection. + +### 2025-01-XX - Screenshot capture implementation +**Hypothesis:** Screenshot tool stores and returns full-resolution PNGs. +**Findings:** The `screenshot` tool captures a PNG, then immediately downscales/optimizes via `sips` to max 800px width, JPEG format, quality 75%, and returns the JPEG. Optimization is always attempted; on failure it falls back to original PNG. +**Evidence:** `src/mcp/tools/ui-testing/screenshot.ts` (sips `-Z 800`, `format jpeg`, `formatOptions 75`). +**Conclusion:** The current implementation already downscales by default; the gap is configurability (opt-in/out, size/quality controls) and documentation. + +### 2025-01-XX - Git history check +**Hypothesis:** Recent commits might have added/changed screenshot optimization behavior. +**Findings:** Recent history shows tool annotations and session-awareness changes, but no indication of configurable screenshot scaling. +**Evidence:** `git log -n 5 -- src/mcp/tools/ui-testing/screenshot.ts`. +**Conclusion:** No recent change introduces optional scaling controls. + +## Root Cause +The issue report assumes full-resolution screenshots, but the current `screenshot` tool already downsamples to 800px max width and JPEG 75% every time. There is no parameter to disable or tune this behavior, and docs do not mention the optimization. + +## Recommendations +1. Document existing downscaling behavior and defaults in tool docs (and in the screenshot tool description). +2. Add optional parameters to `screenshot` for max width/quality/format or a boolean to disable optimization, preserving current defaults. + +## Preventive Measures +- Add a section in docs/TOOLS.md or tool-specific docs describing image processing defaults and token tradeoffs. diff --git a/docs/investigations/issue-describe-ui-empty-after-debugger-resume.md b/docs/investigations/issue-describe-ui-empty-after-debugger-resume.md new file mode 100644 index 00000000..5baf4ee6 --- /dev/null +++ b/docs/investigations/issue-describe-ui-empty-after-debugger-resume.md @@ -0,0 +1,53 @@ +# RCA: describe_ui returns empty tree after debugger resume + +## Summary +When the app is stopped under LLDB (breakpoints hit), the `describe_ui` tool frequently returns an empty accessibility tree (0x0 frame, no children). This is not caused by a short timing gap after resume. The root cause is that the process is still stopped (or immediately re-stopped) due to active breakpoints, so AX snapshotting cannot retrieve a live hierarchy. + +## Impact +- UI automation appears "broken" after resuming from breakpoints. +- Simulator UI may visually update only after detaching or clearing breakpoints because the process is repeatedly stopped. +- `describe_ui` can return misleading empty trees even though the app is running in the simulator. + +## Environment +- App: Calculator (example project) +- Simulator: iPhone 16 (2FCB5689-88F1-4CDF-9E7F-8E310CD41D72) +- Debug backend: LLDB CLI + +## Repro Steps +1. Attach debugger to the simulator app (`debug_attach_sim`). +2. Set breakpoint at `CalculatorButton.swift:18` and `CalculatorInputHandler.swift:12`. +3. `debug_lldb_command` -> `continue`. +4. Tap a button (e.g., "7") so breakpoints fire. +5. `debug_lldb_command` -> `continue`. +6. Call `describe_ui` immediately after resume. + +## Observations +- `debug_stack` immediately after resume shows stop reason `breakpoint 1.2` or `breakpoint 2.1`. +- Multiple `continue` calls quickly re-stop the process due to breakpoints in SwiftUI button handling and input processing. +- While stopped, `describe_ui` often returns: + - Application frame: `{{0,0},{0,0}}` + - `AXLabel` null + - No children +- Waiting does not help. We tested 1s, 2s, 3s, 5s, 8s, and 10s delays; the tree remained empty in a stopped state. +- Once breakpoints are removed and the process is running, `describe_ui` returns the full tree immediately. +- Detaching the debugger also restores `describe_ui` output. + +## Root Cause +The process is stopped due to breakpoints, or repeatedly re-stopped after resume. AX snapshots cannot read a paused process, so `describe_ui` returns an empty hierarchy. + +## Confirming Evidence +- `debug_stack` after `continue` shows: + - `stop reason = breakpoint 1.2` at `CalculatorButton.swift:18` + - `stop reason = breakpoint 2.1` at `CalculatorInputHandler.swift:12` +- After removing breakpoints and `continue`, `describe_ui` returns a full hierarchy (buttons + display values). + +## Current Workarounds +- Clear or remove breakpoints before calling `describe_ui`. +- Detach the debugger to allow the app to run normally. + +## Recommendations +- Document that `describe_ui` requires the target process to be running (not stopped under LLDB). +- Provide guidance to: + - Remove or disable breakpoints before UI automation. + - Avoid calling `describe_ui` immediately after breakpoints unless resumed and confirmed running. +- Optional future enhancement: add a tool-level warning when the debugger session is stopped, or add a helper command that validates "running" state before UI inspection. diff --git a/src/core/generated-plugins.ts b/src/core/generated-plugins.ts index 0ca52998..7c4daf98 100644 --- a/src/core/generated-plugins.ts +++ b/src/core/generated-plugins.ts @@ -3,6 +3,35 @@ // Generated based on filesystem scan export const WORKFLOW_LOADERS = { + debugging: async () => { + const { workflow } = await import('../mcp/tools/debugging/index.js'); + const tool_0 = await import('../mcp/tools/debugging/debug_attach_sim.js').then( + (m) => m.default, + ); + const tool_1 = await import('../mcp/tools/debugging/debug_breakpoint_add.js').then( + (m) => m.default, + ); + const tool_2 = await import('../mcp/tools/debugging/debug_breakpoint_remove.js').then( + (m) => m.default, + ); + const tool_3 = await import('../mcp/tools/debugging/debug_detach.js').then((m) => m.default); + const tool_4 = await import('../mcp/tools/debugging/debug_lldb_command.js').then( + (m) => m.default, + ); + const tool_5 = await import('../mcp/tools/debugging/debug_stack.js').then((m) => m.default); + const tool_6 = await import('../mcp/tools/debugging/debug_variables.js').then((m) => m.default); + + return { + workflow, + debug_attach_sim: tool_0, + debug_breakpoint_add: tool_1, + debug_breakpoint_remove: tool_2, + debug_detach: tool_3, + debug_lldb_command: tool_4, + debug_stack: tool_5, + debug_variables: tool_6, + }; + }, device: async () => { const { workflow } = await import('../mcp/tools/device/index.js'); const tool_0 = await import('../mcp/tools/device/build_device.js').then((m) => m.default); @@ -333,6 +362,11 @@ export type WorkflowName = keyof typeof WORKFLOW_LOADERS; // Optional: Export workflow metadata for quick access export const WORKFLOW_METADATA = { + debugging: { + name: 'Simulator Debugging', + description: + 'Interactive iOS Simulator debugging tools: attach LLDB, manage breakpoints, inspect stack/variables, and run LLDB commands.', + }, device: { name: 'iOS Device Development', description: diff --git a/src/index.ts b/src/index.ts index 4ce746e8..17892008 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { createServer, startServer } from './server/server.ts'; // Import utilities import { log } from './utils/logger.ts'; import { initSentry } from './utils/sentry.ts'; +import { getDefaultDebuggerManager } from './utils/debugger/index.ts'; // Import version import { version } from './version.ts'; @@ -64,11 +65,13 @@ async function main(): Promise { // Clean up on exit process.on('SIGTERM', async () => { + await getDefaultDebuggerManager().disposeAll(); await server.close(); process.exit(0); }); process.on('SIGINT', async () => { + await getDefaultDebuggerManager().disposeAll(); await server.close(); process.exit(0); }); diff --git a/src/mcp/tools/debugging/debug_attach_sim.ts b/src/mcp/tools/debugging/debug_attach_sim.ts new file mode 100644 index 00000000..b749d19a --- /dev/null +++ b/src/mcp/tools/debugging/debug_attach_sim.ts @@ -0,0 +1,157 @@ +import * as z from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts'; +import { + createSessionAwareToolWithContext, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { + getDefaultDebuggerToolContext, + resolveSimulatorAppPid, + type DebuggerToolContext, +} from '../../../utils/debugger/index.ts'; + +const baseSchemaObject = z.object({ + simulatorId: z + .string() + .optional() + .describe( + 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', + ), + simulatorName: z + .string() + .optional() + .describe( + "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", + ), + bundleId: z + .string() + .optional() + .describe("Bundle identifier of the app to attach (e.g., 'com.example.MyApp')"), + pid: z.number().int().positive().optional().describe('Process ID to attach directly'), + waitFor: z.boolean().optional().describe('Wait for the process to appear when attaching'), + makeCurrent: z + .boolean() + .optional() + .default(true) + .describe('Set this debug session as the current session (default: true)'), +}); + +const debugAttachSchema = z.preprocess( + nullifyEmptyStrings, + baseSchemaObject + .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { + message: 'Either simulatorId or simulatorName is required.', + }) + .refine((val) => !(val.simulatorId && val.simulatorName), { + message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', + }) + .refine((val) => val.bundleId !== undefined || val.pid !== undefined, { + message: 'Provide either bundleId or pid to attach.', + }) + .refine((val) => !(val.bundleId && val.pid), { + message: 'bundleId and pid are mutually exclusive. Provide only one.', + }), +); + +export type DebugAttachSimParams = z.infer; + +export async function debug_attach_simLogic( + params: DebugAttachSimParams, + ctx: DebuggerToolContext, +): Promise { + const { executor, debugger: debuggerManager } = ctx; + + const simResult = await determineSimulatorUuid( + { simulatorId: params.simulatorId, simulatorName: params.simulatorName }, + executor, + ); + + if (simResult.error) { + return simResult.error; + } + + const simulatorId = simResult.uuid; + if (!simulatorId) { + return createErrorResponse('Simulator resolution failed', 'Unable to determine simulator UUID'); + } + + let pid = params.pid; + if (!pid && params.bundleId) { + try { + pid = await resolveSimulatorAppPid({ + executor, + simulatorId, + bundleId: params.bundleId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createErrorResponse('Failed to resolve simulator PID', message); + } + } + + if (!pid) { + return createErrorResponse('Missing PID', 'Unable to resolve process ID to attach'); + } + + try { + const session = await debuggerManager.createSession({ + simulatorId, + pid, + waitFor: params.waitFor, + }); + + const isCurrent = params.makeCurrent ?? true; + if (isCurrent) { + debuggerManager.setCurrentSession(session.id); + } + + const warningText = simResult.warning ? `⚠️ ${simResult.warning}\n\n` : ''; + const currentText = isCurrent + ? 'This session is now the current debug session.' + : 'This session is not set as the current session.'; + + return createTextResponse( + `${warningText}✅ Attached LLDB to simulator process ${pid} (${simulatorId}).\n\n` + + `Debug session ID: ${session.id}\n` + + `${currentText}\n\n` + + `Next steps:\n` + + `1. debug_breakpoint_add({ debugSessionId: "${session.id}", file: "...", line: 123 })\n` + + `2. debug_lldb_command({ debugSessionId: "${session.id}", command: "continue" })\n` + + `3. debug_stack({ debugSessionId: "${session.id}" })`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log('error', `Failed to attach LLDB: ${message}`); + return createErrorResponse('Failed to attach debugger', message); + } +} + +const publicSchemaObject = z.strictObject( + baseSchemaObject.omit({ + simulatorId: true, + simulatorName: true, + }).shape, +); + +export default { + name: 'debug_attach_sim', + description: + 'Attach LLDB to a running iOS simulator app. Provide bundleId or pid, plus simulator defaults.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + handler: createSessionAwareToolWithContext({ + internalSchema: debugAttachSchema as unknown as z.ZodType, + logicFunction: debug_attach_simLogic, + getContext: getDefaultDebuggerToolContext, + requirements: [ + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [['simulatorId', 'simulatorName']], + }), +}; diff --git a/src/mcp/tools/debugging/debug_breakpoint_add.ts b/src/mcp/tools/debugging/debug_breakpoint_add.ts new file mode 100644 index 00000000..dbcb9b89 --- /dev/null +++ b/src/mcp/tools/debugging/debug_breakpoint_add.ts @@ -0,0 +1,80 @@ +import * as z from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { + getDefaultDebuggerToolContext, + type DebuggerToolContext, + type BreakpointSpec, +} from '../../../utils/debugger/index.ts'; + +const baseSchemaObject = z.object({ + debugSessionId: z + .string() + .optional() + .describe('Debug session ID to target (defaults to current session)'), + file: z.string().optional().describe('Source file path for breakpoint'), + line: z.number().int().positive().optional().describe('Line number for breakpoint'), + function: z.string().optional().describe('Function name to break on'), + condition: z.string().optional().describe('Optional condition expression for the breakpoint'), +}); + +const debugBreakpointAddSchema = z.preprocess( + nullifyEmptyStrings, + baseSchemaObject + .refine((val) => !(val.file && val.function), { + message: 'Provide either file/line or function, not both.', + }) + .refine((val) => Boolean(val.function ?? (val.file && val.line !== undefined)), { + message: 'Provide file + line or function.', + }) + .refine((val) => !(val.line && !val.file), { + message: 'file is required when line is provided.', + }), +); + +export type DebugBreakpointAddParams = z.infer; + +function formatCondition(condition: string): string { + const escaped = condition.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}"`; +} + +export async function debug_breakpoint_addLogic( + params: DebugBreakpointAddParams, + ctx: DebuggerToolContext, +): Promise { + try { + const spec: BreakpointSpec = params.function + ? { kind: 'function', name: params.function } + : { kind: 'file-line', file: params.file ?? '', line: params.line ?? 0 }; + + if (spec.kind === 'file-line' && (!spec.file || !spec.line)) { + return createErrorResponse('Invalid breakpoint', 'file and line are required.'); + } + + const result = await ctx.debugger.addBreakpoint(params.debugSessionId, spec); + + if (params.condition) { + const conditionCommand = `breakpoint modify -c ${formatCondition(params.condition)} ${result.id}`; + await ctx.debugger.runCommand(params.debugSessionId, conditionCommand); + } + + return createTextResponse(`✅ Breakpoint ${result.id} set.\n\n${result.rawOutput.trim()}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createErrorResponse('Failed to add breakpoint', message); + } +} + +export default { + name: 'debug_breakpoint_add', + description: 'Add a breakpoint by file/line or function name for the active debug session.', + schema: baseSchemaObject.shape, + handler: createTypedToolWithContext( + debugBreakpointAddSchema as unknown as z.ZodType, + debug_breakpoint_addLogic, + getDefaultDebuggerToolContext, + ), +}; diff --git a/src/mcp/tools/debugging/debug_breakpoint_remove.ts b/src/mcp/tools/debugging/debug_breakpoint_remove.ts new file mode 100644 index 00000000..a4ff1de2 --- /dev/null +++ b/src/mcp/tools/debugging/debug_breakpoint_remove.ts @@ -0,0 +1,42 @@ +import * as z from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { + getDefaultDebuggerToolContext, + type DebuggerToolContext, +} from '../../../utils/debugger/index.ts'; + +const debugBreakpointRemoveSchema = z.object({ + debugSessionId: z + .string() + .optional() + .describe('Debug session ID to target (defaults to current session)'), + breakpointId: z.number().int().positive().describe('Breakpoint id to remove'), +}); + +export type DebugBreakpointRemoveParams = z.infer; + +export async function debug_breakpoint_removeLogic( + params: DebugBreakpointRemoveParams, + ctx: DebuggerToolContext, +): Promise { + try { + const output = await ctx.debugger.removeBreakpoint(params.debugSessionId, params.breakpointId); + return createTextResponse(`✅ Breakpoint ${params.breakpointId} removed.\n\n${output.trim()}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createErrorResponse('Failed to remove breakpoint', message); + } +} + +export default { + name: 'debug_breakpoint_remove', + description: 'Remove a breakpoint by id for the active debug session.', + schema: debugBreakpointRemoveSchema.shape, + handler: createTypedToolWithContext( + debugBreakpointRemoveSchema, + debug_breakpoint_removeLogic, + getDefaultDebuggerToolContext, + ), +}; diff --git a/src/mcp/tools/debugging/debug_detach.ts b/src/mcp/tools/debugging/debug_detach.ts new file mode 100644 index 00000000..ce22f85a --- /dev/null +++ b/src/mcp/tools/debugging/debug_detach.ts @@ -0,0 +1,43 @@ +import * as z from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { + getDefaultDebuggerToolContext, + type DebuggerToolContext, +} from '../../../utils/debugger/index.ts'; + +const debugDetachSchema = z.object({ + debugSessionId: z + .string() + .optional() + .describe('Debug session ID to detach (defaults to current session)'), +}); + +export type DebugDetachParams = z.infer; + +export async function debug_detachLogic( + params: DebugDetachParams, + ctx: DebuggerToolContext, +): Promise { + try { + const targetId = params.debugSessionId ?? ctx.debugger.getCurrentSessionId(); + await ctx.debugger.detachSession(params.debugSessionId); + + return createTextResponse(`✅ Detached debugger session${targetId ? ` ${targetId}` : ''}.`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createErrorResponse('Failed to detach debugger', message); + } +} + +export default { + name: 'debug_detach', + description: 'Detach the current debugger session or a specific debugSessionId.', + schema: debugDetachSchema.shape, + handler: createTypedToolWithContext( + debugDetachSchema, + debug_detachLogic, + getDefaultDebuggerToolContext, + ), +}; diff --git a/src/mcp/tools/debugging/debug_lldb_command.ts b/src/mcp/tools/debugging/debug_lldb_command.ts new file mode 100644 index 00000000..6fae6160 --- /dev/null +++ b/src/mcp/tools/debugging/debug_lldb_command.ts @@ -0,0 +1,53 @@ +import * as z from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { + getDefaultDebuggerToolContext, + type DebuggerToolContext, +} from '../../../utils/debugger/index.ts'; + +const debugLldbCommandSchema = z.preprocess( + nullifyEmptyStrings, + z.object({ + debugSessionId: z + .string() + .optional() + .describe('Debug session ID to target (defaults to current session)'), + command: z.string().describe('LLDB command to run (e.g., "continue", "thread backtrace")'), + timeoutMs: z.number().int().positive().optional().describe('Override command timeout (ms)'), + }), +); + +export type DebugLldbCommandParams = z.infer; + +export async function debug_lldb_commandLogic( + params: DebugLldbCommandParams, + ctx: DebuggerToolContext, +): Promise { + try { + const output = await ctx.debugger.runCommand(params.debugSessionId, params.command, { + timeoutMs: params.timeoutMs, + }); + return createTextResponse(output.trim()); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createErrorResponse('Failed to run LLDB command', message); + } +} + +export default { + name: 'debug_lldb_command', + description: 'Run an arbitrary LLDB command within the active debug session.', + schema: z.object({ + debugSessionId: z.string().optional(), + command: z.string(), + timeoutMs: z.number().int().positive().optional(), + }).shape, + handler: createTypedToolWithContext( + debugLldbCommandSchema as unknown as z.ZodType, + debug_lldb_commandLogic, + getDefaultDebuggerToolContext, + ), +}; diff --git a/src/mcp/tools/debugging/debug_stack.ts b/src/mcp/tools/debugging/debug_stack.ts new file mode 100644 index 00000000..ddfb2899 --- /dev/null +++ b/src/mcp/tools/debugging/debug_stack.ts @@ -0,0 +1,46 @@ +import * as z from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { + getDefaultDebuggerToolContext, + type DebuggerToolContext, +} from '../../../utils/debugger/index.ts'; + +const debugStackSchema = z.object({ + debugSessionId: z + .string() + .optional() + .describe('Debug session ID to target (defaults to current session)'), + threadIndex: z.number().int().nonnegative().optional().describe('Thread index for backtrace'), + maxFrames: z.number().int().positive().optional().describe('Maximum frames to return'), +}); + +export type DebugStackParams = z.infer; + +export async function debug_stackLogic( + params: DebugStackParams, + ctx: DebuggerToolContext, +): Promise { + try { + const output = await ctx.debugger.getStack(params.debugSessionId, { + threadIndex: params.threadIndex, + maxFrames: params.maxFrames, + }); + return createTextResponse(output.trim()); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createErrorResponse('Failed to get stack', message); + } +} + +export default { + name: 'debug_stack', + description: 'Return a thread backtrace from the active debug session.', + schema: debugStackSchema.shape, + handler: createTypedToolWithContext( + debugStackSchema, + debug_stackLogic, + getDefaultDebuggerToolContext, + ), +}; diff --git a/src/mcp/tools/debugging/debug_variables.ts b/src/mcp/tools/debugging/debug_variables.ts new file mode 100644 index 00000000..2442dbdb --- /dev/null +++ b/src/mcp/tools/debugging/debug_variables.ts @@ -0,0 +1,44 @@ +import * as z from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { + getDefaultDebuggerToolContext, + type DebuggerToolContext, +} from '../../../utils/debugger/index.ts'; + +const debugVariablesSchema = z.object({ + debugSessionId: z + .string() + .optional() + .describe('Debug session ID to target (defaults to current session)'), + frameIndex: z.number().int().nonnegative().optional().describe('Frame index to inspect'), +}); + +export type DebugVariablesParams = z.infer; + +export async function debug_variablesLogic( + params: DebugVariablesParams, + ctx: DebuggerToolContext, +): Promise { + try { + const output = await ctx.debugger.getVariables(params.debugSessionId, { + frameIndex: params.frameIndex, + }); + return createTextResponse(output.trim()); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createErrorResponse('Failed to get variables', message); + } +} + +export default { + name: 'debug_variables', + description: 'Return variables for a selected frame in the active debug session.', + schema: debugVariablesSchema.shape, + handler: createTypedToolWithContext( + debugVariablesSchema, + debug_variablesLogic, + getDefaultDebuggerToolContext, + ), +}; diff --git a/src/mcp/tools/debugging/index.ts b/src/mcp/tools/debugging/index.ts new file mode 100644 index 00000000..e417e654 --- /dev/null +++ b/src/mcp/tools/debugging/index.ts @@ -0,0 +1,5 @@ +export const workflow = { + name: 'Simulator Debugging', + description: + 'Interactive iOS Simulator debugging tools: attach LLDB, manage breakpoints, inspect stack/variables, and run LLDB commands.', +}; diff --git a/src/mcp/tools/ui-testing/__tests__/describe_ui.test.ts b/src/mcp/tools/ui-testing/__tests__/describe_ui.test.ts index d3b974ea..e19193ca 100644 --- a/src/mcp/tools/ui-testing/__tests__/describe_ui.test.ts +++ b/src/mcp/tools/ui-testing/__tests__/describe_ui.test.ts @@ -20,7 +20,7 @@ describe('Describe UI Plugin', () => { it('should have correct description', () => { expect(describeUIPlugin.description).toBe( - 'Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation.', + 'Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation. Requires the target process to be running; paused debugger/breakpoints can yield an empty tree.', ); }); @@ -113,6 +113,7 @@ describe('Describe UI Plugin', () => { text: `Next Steps: - Use frame coordinates for tap/swipe (center: x+width/2, y+height/2) - Re-run describe_ui after layout changes +- If a debugger is attached, ensure the app is running (not stopped on breakpoints) - Screenshots are for visual verification only`, }, ], diff --git a/src/mcp/tools/ui-testing/describe_ui.ts b/src/mcp/tools/ui-testing/describe_ui.ts index 30a6b491..e24b48e8 100644 --- a/src/mcp/tools/ui-testing/describe_ui.ts +++ b/src/mcp/tools/ui-testing/describe_ui.ts @@ -84,6 +84,7 @@ export async function describe_uiLogic( text: `Next Steps: - Use frame coordinates for tap/swipe (center: x+width/2, y+height/2) - Re-run describe_ui after layout changes +- If a debugger is attached, ensure the app is running (not stopped on breakpoints) - Screenshots are for visual verification only`, }, ], @@ -116,7 +117,7 @@ const publicSchemaObject = z.strictObject( export default { name: 'describe_ui', description: - 'Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation.', + 'Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation. Requires the target process to be running; paused debugger/breakpoints can yield an empty tree.', schema: getSessionAwareToolSchemaShape({ sessionAware: publicSchemaObject, legacy: describeUiSchema, diff --git a/src/utils/__tests__/debugger-simctl.test.ts b/src/utils/__tests__/debugger-simctl.test.ts new file mode 100644 index 00000000..111d680d --- /dev/null +++ b/src/utils/__tests__/debugger-simctl.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { resolveSimulatorAppPid } from '../debugger/simctl.ts'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; + +describe('resolveSimulatorAppPid', () => { + it('returns PID when bundle id is found', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '1234 0 com.example.MyApp\n', + }); + + const pid = await resolveSimulatorAppPid({ + executor: mockExecutor, + simulatorId: 'SIM-123', + bundleId: 'com.example.MyApp', + }); + + expect(pid).toBe(1234); + }); + + it('throws when bundle id is missing', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '999 0 other.app\n', + }); + + await expect( + resolveSimulatorAppPid({ + executor: mockExecutor, + simulatorId: 'SIM-123', + bundleId: 'com.example.MyApp', + }), + ).rejects.toThrow('No running process found'); + }); + + it('throws when PID is missing', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '- 0 com.example.MyApp\n', + }); + + await expect( + resolveSimulatorAppPid({ + executor: mockExecutor, + simulatorId: 'SIM-123', + bundleId: 'com.example.MyApp', + }), + ).rejects.toThrow('not running'); + }); +}); diff --git a/src/utils/debugger/backends/DebuggerBackend.ts b/src/utils/debugger/backends/DebuggerBackend.ts new file mode 100644 index 00000000..04429889 --- /dev/null +++ b/src/utils/debugger/backends/DebuggerBackend.ts @@ -0,0 +1,18 @@ +import type { BreakpointInfo, BreakpointSpec } from '../types.ts'; + +export interface DebuggerBackend { + readonly kind: 'lldb-cli' | 'dap'; + + attach(opts: { pid: number; simulatorId: string; waitFor?: boolean }): Promise; + detach(): Promise; + + runCommand(command: string, opts?: { timeoutMs?: number }): Promise; + + addBreakpoint(spec: BreakpointSpec): Promise; + removeBreakpoint(id: number): Promise; + + getStack(opts?: { threadIndex?: number; maxFrames?: number }): Promise; + getVariables(opts?: { frameIndex?: number }): Promise; + + dispose(): Promise; +} diff --git a/src/utils/debugger/backends/dap-backend.ts b/src/utils/debugger/backends/dap-backend.ts new file mode 100644 index 00000000..d1f17ef5 --- /dev/null +++ b/src/utils/debugger/backends/dap-backend.ts @@ -0,0 +1,52 @@ +import type { DebuggerBackend } from './DebuggerBackend.ts'; +import type { BreakpointInfo, BreakpointSpec } from '../types.ts'; + +const DAP_ERROR_MESSAGE = + 'DAP backend is not implemented yet. Set XCODEBUILDMCP_DEBUGGER_BACKEND=lldb-cli to use the default LLDB CLI backend.'; + +class DapBackend implements DebuggerBackend { + readonly kind = 'dap' as const; + + async attach(neverOpts: { pid: number; simulatorId: string; waitFor?: boolean }): Promise { + void neverOpts; + throw new Error(DAP_ERROR_MESSAGE); + } + + async detach(): Promise { + throw new Error(DAP_ERROR_MESSAGE); + } + + async runCommand(neverCommand: string, neverOpts?: { timeoutMs?: number }): Promise { + void neverCommand; + void neverOpts; + throw new Error(DAP_ERROR_MESSAGE); + } + + async addBreakpoint(neverSpec: BreakpointSpec): Promise { + void neverSpec; + throw new Error(DAP_ERROR_MESSAGE); + } + + async removeBreakpoint(neverId: number): Promise { + void neverId; + throw new Error(DAP_ERROR_MESSAGE); + } + + async getStack(neverOpts?: { threadIndex?: number; maxFrames?: number }): Promise { + void neverOpts; + throw new Error(DAP_ERROR_MESSAGE); + } + + async getVariables(neverOpts?: { frameIndex?: number }): Promise { + void neverOpts; + throw new Error(DAP_ERROR_MESSAGE); + } + + async dispose(): Promise { + throw new Error(DAP_ERROR_MESSAGE); + } +} + +export async function createDapBackend(): Promise { + return new DapBackend(); +} diff --git a/src/utils/debugger/backends/lldb-cli-backend.ts b/src/utils/debugger/backends/lldb-cli-backend.ts new file mode 100644 index 00000000..a84ca592 --- /dev/null +++ b/src/utils/debugger/backends/lldb-cli-backend.ts @@ -0,0 +1,222 @@ +import type { InteractiveSpawner } from '../../execution/index.ts'; +import { getDefaultInteractiveSpawner } from '../../execution/index.ts'; +import type { DebuggerBackend } from './DebuggerBackend.ts'; +import type { BreakpointInfo, BreakpointSpec } from '../types.ts'; + +const DEFAULT_COMMAND_TIMEOUT_MS = 30_000; +const DEFAULT_STARTUP_TIMEOUT_MS = 10_000; +const LLDB_PROMPT = 'XCODEBUILDMCP_LLDB> '; +const COMMAND_SENTINEL = '__XCODEBUILDMCP_DONE__'; + +class LldbCliBackend implements DebuggerBackend { + readonly kind = 'lldb-cli' as const; + + private readonly spawner: InteractiveSpawner; + private readonly prompt = LLDB_PROMPT; + private readonly process; + private buffer = ''; + private pending: { + resolve: (output: string) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + } | null = null; + private queue: Promise = Promise.resolve(); + private ready: Promise; + private disposed = false; + + constructor(spawner: InteractiveSpawner) { + this.spawner = spawner; + const lldbCommand = [ + 'xcrun', + 'lldb', + '--no-lldbinit', + '-o', + `settings set prompt "${this.prompt}"`, + ]; + + this.process = this.spawner(lldbCommand); + + this.process.process.stdout?.on('data', (data: Buffer) => this.handleData(data)); + this.process.process.stderr?.on('data', (data: Buffer) => this.handleData(data)); + this.process.process.on('exit', (code, signal) => { + const detail = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`; + this.failPending(new Error(`LLDB process exited (${detail})`)); + }); + + this.ready = this.initialize(); + } + + private async initialize(): Promise { + // Prime the prompt by running a sentinel command we can parse reliably. + this.process.write(`script print("${COMMAND_SENTINEL}")\n`); + await this.waitForSentinel(DEFAULT_STARTUP_TIMEOUT_MS); + } + + async waitUntilReady(): Promise { + await this.ready; + } + + async attach(opts: { pid: number; simulatorId: string; waitFor?: boolean }): Promise { + const command = opts.waitFor + ? `process attach --pid ${opts.pid} --waitfor` + : `process attach --pid ${opts.pid}`; + const output = await this.runCommand(command); + assertNoLldbError('attach', output); + } + + async detach(): Promise { + const output = await this.runCommand('process detach'); + assertNoLldbError('detach', output); + } + + async runCommand(command: string, opts?: { timeoutMs?: number }): Promise { + return this.enqueue(async () => { + if (this.disposed) { + throw new Error('LLDB backend disposed'); + } + await this.ready; + this.process.write(`${command}\n`); + this.process.write(`script print("${COMMAND_SENTINEL}")\n`); + const output = await this.waitForSentinel(opts?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS); + return sanitizeOutput(output, this.prompt).trimEnd(); + }); + } + + async addBreakpoint(spec: BreakpointSpec): Promise { + const command = + spec.kind === 'file-line' + ? `breakpoint set --file "${spec.file}" --line ${spec.line}` + : `breakpoint set --name "${spec.name}"`; + const output = await this.runCommand(command); + assertNoLldbError('breakpoint', output); + + const match = output.match(/Breakpoint\s+(\d+):/); + if (!match) { + throw new Error(`Unable to parse breakpoint id from output: ${output}`); + } + + return { + id: Number(match[1]), + spec, + rawOutput: output, + }; + } + + async removeBreakpoint(id: number): Promise { + const output = await this.runCommand(`breakpoint delete ${id}`); + assertNoLldbError('breakpoint delete', output); + return output; + } + + async getStack(opts?: { threadIndex?: number; maxFrames?: number }): Promise { + let command = 'thread backtrace'; + if (typeof opts?.threadIndex === 'number') { + command += ` -t ${opts.threadIndex}`; + } + if (typeof opts?.maxFrames === 'number') { + command += ` -m ${opts.maxFrames}`; + } + return this.runCommand(command); + } + + async getVariables(opts?: { frameIndex?: number }): Promise { + if (typeof opts?.frameIndex === 'number') { + await this.runCommand(`frame select ${opts.frameIndex}`); + } + return this.runCommand('frame variable'); + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.failPending(new Error('LLDB backend disposed')); + this.process.dispose(); + } + + private enqueue(work: () => Promise): Promise { + const next = this.queue.then(work, work) as Promise; + this.queue = next.then( + () => undefined, + () => undefined, + ); + return next; + } + + private handleData(data: Buffer): void { + this.buffer += data.toString('utf8'); + this.checkPending(); + } + + private waitForSentinel(timeoutMs: number): Promise { + if (this.pending) { + return Promise.reject(new Error('LLDB command already pending')); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending = null; + reject(new Error(`LLDB command timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + this.pending = { resolve, reject, timeout }; + this.checkPending(); + }); + } + + private checkPending(): void { + if (!this.pending) return; + const sentinelMatch = this.buffer.match(/(^|\r?\n)__XCODEBUILDMCP_DONE__(\r?\n)/); + const sentinelIndex = sentinelMatch?.index; + const sentinelLength = sentinelMatch?.[0].length; + if (sentinelIndex == null || sentinelLength == null) return; + + const output = this.buffer.slice(0, sentinelIndex); + const remainderStart = sentinelIndex + sentinelLength; + + const promptIndex = this.buffer.indexOf(this.prompt, remainderStart); + if (promptIndex !== -1) { + this.buffer = this.buffer.slice(promptIndex + this.prompt.length); + } else { + this.buffer = this.buffer.slice(remainderStart); + } + + const { resolve, timeout } = this.pending; + this.pending = null; + clearTimeout(timeout); + resolve(output); + } + + private failPending(error: Error): void { + if (!this.pending) return; + const { reject, timeout } = this.pending; + this.pending = null; + clearTimeout(timeout); + reject(error); + } +} + +function assertNoLldbError(context: string, output: string): void { + if (/error:/i.test(output)) { + throw new Error(`LLDB ${context} failed: ${output.trim()}`); + } +} + +function sanitizeOutput(output: string, prompt: string): string { + const lines = output.split(/\r?\n/); + const filtered = lines.filter((line) => { + if (!line) return false; + if (line.startsWith(prompt)) return false; + if (line.includes(`script print("${COMMAND_SENTINEL}")`)) return false; + if (line.includes(COMMAND_SENTINEL)) return false; + return true; + }); + return filtered.join('\n'); +} + +export async function createLldbCliBackend( + spawner: InteractiveSpawner = getDefaultInteractiveSpawner(), +): Promise { + const backend = new LldbCliBackend(spawner); + await backend.waitUntilReady(); + return backend; +} diff --git a/src/utils/debugger/debugger-manager.ts b/src/utils/debugger/debugger-manager.ts new file mode 100644 index 00000000..6940b2eb --- /dev/null +++ b/src/utils/debugger/debugger-manager.ts @@ -0,0 +1,176 @@ +import { v4 as uuidv4 } from 'uuid'; +import type { DebuggerBackend } from './backends/DebuggerBackend.ts'; +import { createDapBackend } from './backends/dap-backend.ts'; +import { createLldbCliBackend } from './backends/lldb-cli-backend.ts'; +import type { + BreakpointInfo, + BreakpointSpec, + DebugSessionInfo, + DebuggerBackendKind, +} from './types.ts'; + +export type DebuggerBackendFactory = (kind: DebuggerBackendKind) => Promise; + +export class DebuggerManager { + private readonly backendFactory: DebuggerBackendFactory; + private readonly sessions = new Map< + string, + { info: DebugSessionInfo; backend: DebuggerBackend } + >(); + private currentSessionId: string | null = null; + + constructor(options: { backendFactory?: DebuggerBackendFactory } = {}) { + this.backendFactory = options.backendFactory ?? defaultBackendFactory; + } + + async createSession(opts: { + simulatorId: string; + pid: number; + backend?: DebuggerBackendKind; + waitFor?: boolean; + }): Promise { + const backendKind = resolveBackendKind(opts.backend); + const backend = await this.backendFactory(backendKind); + + try { + await backend.attach({ pid: opts.pid, simulatorId: opts.simulatorId, waitFor: opts.waitFor }); + } catch (error) { + await backend.dispose(); + throw error; + } + + const now = Date.now(); + const info: DebugSessionInfo = { + id: uuidv4(), + backend: backendKind, + simulatorId: opts.simulatorId, + pid: opts.pid, + createdAt: now, + lastUsedAt: now, + }; + + this.sessions.set(info.id, { info, backend }); + return info; + } + + getSession(id?: string): { info: DebugSessionInfo; backend: DebuggerBackend } | null { + const resolvedId = id ?? this.currentSessionId; + if (!resolvedId) return null; + return this.sessions.get(resolvedId) ?? null; + } + + setCurrentSession(id: string): void { + if (!this.sessions.has(id)) { + throw new Error(`Debug session not found: ${id}`); + } + this.currentSessionId = id; + } + + getCurrentSessionId(): string | null { + return this.currentSessionId; + } + + async detachSession(id?: string): Promise { + const session = this.requireSession(id); + try { + await session.backend.detach(); + } finally { + await session.backend.dispose(); + this.sessions.delete(session.info.id); + if (this.currentSessionId === session.info.id) { + this.currentSessionId = null; + } + } + } + + async disposeAll(): Promise { + for (const session of this.sessions.values()) { + try { + await session.backend.detach(); + } catch { + // Best-effort cleanup; detach can fail if the process exited. + } finally { + await session.backend.dispose(); + } + } + this.sessions.clear(); + this.currentSessionId = null; + } + + async addBreakpoint(id: string | undefined, spec: BreakpointSpec): Promise { + const session = this.requireSession(id); + const result = await session.backend.addBreakpoint(spec); + this.touch(session.info.id); + return result; + } + + async removeBreakpoint(id: string | undefined, breakpointId: number): Promise { + const session = this.requireSession(id); + const result = await session.backend.removeBreakpoint(breakpointId); + this.touch(session.info.id); + return result; + } + + async getStack( + id: string | undefined, + opts?: { threadIndex?: number; maxFrames?: number }, + ): Promise { + const session = this.requireSession(id); + const result = await session.backend.getStack(opts); + this.touch(session.info.id); + return result; + } + + async getVariables(id: string | undefined, opts?: { frameIndex?: number }): Promise { + const session = this.requireSession(id); + const result = await session.backend.getVariables(opts); + this.touch(session.info.id); + return result; + } + + async runCommand( + id: string | undefined, + command: string, + opts?: { timeoutMs?: number }, + ): Promise { + const session = this.requireSession(id); + const result = await session.backend.runCommand(command, opts); + this.touch(session.info.id); + return result; + } + + private requireSession(id?: string): { info: DebugSessionInfo; backend: DebuggerBackend } { + const session = this.getSession(id); + if (!session) { + throw new Error('No active debug session. Provide debugSessionId or attach first.'); + } + return session; + } + + private touch(id: string): void { + const session = this.sessions.get(id); + if (!session) return; + session.info.lastUsedAt = Date.now(); + } +} + +function resolveBackendKind(explicit?: DebuggerBackendKind): DebuggerBackendKind { + if (explicit) return explicit; + const envValue = process.env.XCODEBUILDMCP_DEBUGGER_BACKEND; + if (!envValue) return 'lldb-cli'; + const normalized = envValue.trim().toLowerCase(); + if (normalized === 'lldb-cli' || normalized === 'lldb') return 'lldb-cli'; + if (normalized === 'dap') return 'dap'; + throw new Error(`Unsupported debugger backend: ${envValue}`); +} + +const defaultBackendFactory: DebuggerBackendFactory = async (kind) => { + switch (kind) { + case 'lldb-cli': + return createLldbCliBackend(); + case 'dap': + return createDapBackend(); + default: + throw new Error(`Unsupported debugger backend: ${kind}`); + } +}; diff --git a/src/utils/debugger/index.ts b/src/utils/debugger/index.ts new file mode 100644 index 00000000..285061b4 --- /dev/null +++ b/src/utils/debugger/index.ts @@ -0,0 +1,19 @@ +import { DebuggerManager } from './debugger-manager.ts'; + +let defaultDebuggerManager: DebuggerManager | null = null; + +export function getDefaultDebuggerManager(): DebuggerManager { + defaultDebuggerManager ??= new DebuggerManager(); + return defaultDebuggerManager; +} + +export { DebuggerManager } from './debugger-manager.ts'; +export { getDefaultDebuggerToolContext } from './tool-context.ts'; +export { resolveSimulatorAppPid } from './simctl.ts'; +export type { + BreakpointInfo, + BreakpointSpec, + DebugSessionInfo, + DebuggerBackendKind, +} from './types.ts'; +export type { DebuggerToolContext } from './tool-context.ts'; diff --git a/src/utils/debugger/simctl.ts b/src/utils/debugger/simctl.ts new file mode 100644 index 00000000..a987f94b --- /dev/null +++ b/src/utils/debugger/simctl.ts @@ -0,0 +1,38 @@ +import type { CommandExecutor } from '../execution/index.ts'; + +export async function resolveSimulatorAppPid(opts: { + executor: CommandExecutor; + simulatorId: string; + bundleId: string; +}): Promise { + const result = await opts.executor( + ['xcrun', 'simctl', 'spawn', opts.simulatorId, 'launchctl', 'list'], + 'Resolve simulator app PID', + true, + ); + + if (!result.success) { + throw new Error(result.error ?? 'Failed to read simulator process list'); + } + + const lines = result.output.split('\n'); + for (const line of lines) { + if (!line.includes(opts.bundleId)) continue; + + const columns = line.trim().split(/\s+/); + const pidToken = columns[0]; + + if (!pidToken || pidToken === '-') { + throw new Error(`App ${opts.bundleId} is not running on simulator ${opts.simulatorId}`); + } + + const pid = Number(pidToken); + if (Number.isNaN(pid) || pid <= 0) { + throw new Error(`Unable to parse PID for ${opts.bundleId} from: ${line}`); + } + + return pid; + } + + throw new Error(`No running process found for ${opts.bundleId} on simulator ${opts.simulatorId}`); +} diff --git a/src/utils/debugger/tool-context.ts b/src/utils/debugger/tool-context.ts new file mode 100644 index 00000000..8c880315 --- /dev/null +++ b/src/utils/debugger/tool-context.ts @@ -0,0 +1,16 @@ +import type { CommandExecutor } from '../execution/index.ts'; +import { getDefaultCommandExecutor } from '../execution/index.ts'; +import type { DebuggerManager } from './debugger-manager.ts'; +import { getDefaultDebuggerManager } from './index.ts'; + +export type DebuggerToolContext = { + executor: CommandExecutor; + debugger: DebuggerManager; +}; + +export function getDefaultDebuggerToolContext(): DebuggerToolContext { + return { + executor: getDefaultCommandExecutor(), + debugger: getDefaultDebuggerManager(), + }; +} diff --git a/src/utils/debugger/types.ts b/src/utils/debugger/types.ts new file mode 100644 index 00000000..e839da4c --- /dev/null +++ b/src/utils/debugger/types.ts @@ -0,0 +1,20 @@ +export type DebuggerBackendKind = 'lldb-cli' | 'dap'; + +export interface DebugSessionInfo { + id: string; + backend: DebuggerBackendKind; + simulatorId: string; + pid: number; + createdAt: number; + lastUsedAt: number; +} + +export type BreakpointSpec = + | { kind: 'file-line'; file: string; line: number } + | { kind: 'function'; name: string }; + +export interface BreakpointInfo { + id: number; + spec: BreakpointSpec; + rawOutput: string; +} diff --git a/src/utils/execution/index.ts b/src/utils/execution/index.ts index efe9ef4f..e7615e0c 100644 --- a/src/utils/execution/index.ts +++ b/src/utils/execution/index.ts @@ -3,7 +3,13 @@ * Prefer importing from 'utils/execution/index.js' instead of the legacy utils barrel. */ export { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../command.ts'; +export { getDefaultInteractiveSpawner } from './interactive-process.ts'; // Types export type { CommandExecutor, CommandResponse, CommandExecOptions } from '../CommandExecutor.ts'; export type { FileSystemExecutor } from '../FileSystemExecutor.ts'; +export type { + InteractiveProcess, + InteractiveSpawner, + SpawnInteractiveOptions, +} from './interactive-process.ts'; diff --git a/src/utils/execution/interactive-process.ts b/src/utils/execution/interactive-process.ts new file mode 100644 index 00000000..73699b1d --- /dev/null +++ b/src/utils/execution/interactive-process.ts @@ -0,0 +1,80 @@ +import { spawn, type ChildProcess } from 'node:child_process'; + +export interface InteractiveProcess { + readonly process: ChildProcess; + write(data: string): void; + kill(signal?: NodeJS.Signals): void; + dispose(): void; +} + +export interface SpawnInteractiveOptions { + cwd?: string; + env?: Record; +} + +export type InteractiveSpawner = ( + command: string[], + opts?: SpawnInteractiveOptions, +) => InteractiveProcess; + +class DefaultInteractiveProcess implements InteractiveProcess { + readonly process: ChildProcess; + private disposed = false; + + constructor(process: ChildProcess) { + this.process = process; + } + + write(data: string): void { + if (this.disposed) { + throw new Error('Interactive process is disposed'); + } + if (!this.process.stdin) { + throw new Error('Interactive process stdin is not available'); + } + this.process.stdin.write(data); + } + + kill(signal?: NodeJS.Signals): void { + if (this.disposed) return; + this.process.kill(signal); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.process.stdin?.end(); + this.process.stdout?.removeAllListeners(); + this.process.stderr?.removeAllListeners(); + this.process.removeAllListeners(); + if (!this.process.killed) { + this.process.kill(); + } + } +} + +function createInteractiveProcess( + command: string[], + opts?: SpawnInteractiveOptions, +): InteractiveProcess { + const [executable, ...args] = command; + const childProcess = spawn(executable, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...(opts?.env ?? {}) }, + cwd: opts?.cwd, + }); + + return new DefaultInteractiveProcess(childProcess); +} + +export function getDefaultInteractiveSpawner(): InteractiveSpawner { + if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') { + throw new Error( + `🚨 REAL INTERACTIVE SPAWNER DETECTED IN TEST! 🚨\n` + + `This test is trying to spawn a real interactive process.\n` + + `Fix: Inject a mock InteractiveSpawner in your test setup.`, + ); + } + + return createInteractiveProcess; +} diff --git a/src/utils/typed-tool-factory.ts b/src/utils/typed-tool-factory.ts index 37a72f44..a3212575 100644 --- a/src/utils/typed-tool-factory.ts +++ b/src/utils/typed-tool-factory.ts @@ -16,23 +16,11 @@ import { createErrorResponse } from './responses/index.ts'; import { sessionStore, type SessionDefaults } from './session-store.ts'; import { isSessionDefaultsSchemaOptOutEnabled } from './environment.ts'; -/** - * Creates a type-safe tool handler that validates parameters at runtime - * before passing them to the typed logic function. - * - * This is the ONLY safe way to cross the type boundary from the generic - * MCP handler signature to our typed domain logic. - * - * @param schema - Zod schema for parameter validation - * @param logicFunction - The typed logic function to execute - * @param getExecutor - Function to get the command executor (must be provided) - * @returns A handler function compatible with MCP SDK requirements - */ -export function createTypedTool( +function createValidatedHandler( schema: z.ZodType, - logicFunction: (params: TParams, executor: CommandExecutor) => Promise, - getExecutor: () => CommandExecutor, -) { + logicFunction: (params: TParams, context: TContext) => Promise, + getContext: () => TContext, +): (args: Record) => Promise { return async (args: Record): Promise => { try { // Runtime validation - the ONLY safe way to cross the type boundary @@ -40,7 +28,7 @@ export function createTypedTool( const validatedParams = schema.parse(args); // Now we have guaranteed type safety - no assertions needed! - return await logicFunction(validatedParams, getExecutor()); + return await logicFunction(validatedParams, getContext()); } catch (error) { if (error instanceof z.ZodError) { const details = `Invalid parameters:\n${formatZodIssues(error)}`; @@ -53,6 +41,34 @@ export function createTypedTool( }; } +/** + * Creates a type-safe tool handler that validates parameters at runtime + * before passing them to the typed logic function. + * + * This is the ONLY safe way to cross the type boundary from the generic + * MCP handler signature to our typed domain logic. + * + * @param schema - Zod schema for parameter validation + * @param logicFunction - The typed logic function to execute + * @param getExecutor - Function to get the command executor (must be provided) + * @returns A handler function compatible with MCP SDK requirements + */ +export function createTypedTool( + schema: z.ZodType, + logicFunction: (params: TParams, executor: CommandExecutor) => Promise, + getExecutor: () => CommandExecutor, +): (args: Record) => Promise { + return createValidatedHandler(schema, logicFunction, getExecutor); +} + +export function createTypedToolWithContext( + schema: z.ZodType, + logicFunction: (params: TParams, context: TContext) => Promise, + getContext: () => TContext, +): (args: Record) => Promise { + return createValidatedHandler(schema, logicFunction, getContext); +} + export type SessionRequirement = | { allOf: (keyof SessionDefaults)[]; message?: string } | { oneOf: (keyof SessionDefaults)[]; message?: string }; @@ -93,11 +109,37 @@ export function createSessionAwareTool(opts: { getExecutor: () => CommandExecutor; requirements?: SessionRequirement[]; exclusivePairs?: (keyof SessionDefaults)[][]; // when args provide one side, drop conflicting session-default side(s) -}) { +}): (rawArgs: Record) => Promise { + return createSessionAwareHandler({ + internalSchema: opts.internalSchema, + logicFunction: opts.logicFunction, + getContext: opts.getExecutor, + requirements: opts.requirements, + exclusivePairs: opts.exclusivePairs, + }); +} + +export function createSessionAwareToolWithContext(opts: { + internalSchema: z.ZodType; + logicFunction: (params: TParams, context: TContext) => Promise; + getContext: () => TContext; + requirements?: SessionRequirement[]; + exclusivePairs?: (keyof SessionDefaults)[][]; +}): (rawArgs: Record) => Promise { + return createSessionAwareHandler(opts); +} + +function createSessionAwareHandler(opts: { + internalSchema: z.ZodType; + logicFunction: (params: TParams, context: TContext) => Promise; + getContext: () => TContext; + requirements?: SessionRequirement[]; + exclusivePairs?: (keyof SessionDefaults)[][]; +}): (rawArgs: Record) => Promise { const { internalSchema, logicFunction, - getExecutor, + getContext, requirements = [], exclusivePairs = [], } = opts; @@ -176,7 +218,7 @@ export function createSessionAwareTool(opts: { } const validated = internalSchema.parse(merged); - return await logicFunction(validated, getExecutor()); + return await logicFunction(validated, getContext()); } catch (error) { if (error instanceof z.ZodError) { const details = `Invalid parameters:\n${formatZodIssues(error)}`; From 01e689f40a7cc6b0b65da1fc7f5beeb49d035027 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 2 Jan 2026 22:21:59 +0000 Subject: [PATCH 02/20] DAP backend --- docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md | 573 ++++++++++++++++++ docs/DEBUGGING_ARCHITECTURE.md | 31 +- .../tools/debugging/debug_breakpoint_add.ts | 14 +- src/mcp/tools/doctor/doctor.ts | 27 + src/mcp/tools/doctor/lib/doctor.deps.ts | 4 +- src/test-utils/mock-executors.ts | 98 ++- .../__tests__/debugger-manager-dap.test.ts | 75 +++ .../debugger/backends/DebuggerBackend.ts | 2 +- .../backends/__tests__/dap-backend.test.ts | 171 ++++++ src/utils/debugger/backends/dap-backend.ts | 526 +++++++++++++++- .../debugger/backends/lldb-cli-backend.ts | 20 +- .../dap/__tests__/transport-framing.test.ts | 112 ++++ src/utils/debugger/dap/adapter-discovery.ts | 30 + src/utils/debugger/dap/transport.ts | 212 +++++++ src/utils/debugger/dap/types.ts | 91 +++ src/utils/debugger/debugger-manager.ts | 14 +- 16 files changed, 1940 insertions(+), 60 deletions(-) create mode 100644 docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md create mode 100644 src/utils/debugger/__tests__/debugger-manager-dap.test.ts create mode 100644 src/utils/debugger/backends/__tests__/dap-backend.test.ts create mode 100644 src/utils/debugger/dap/__tests__/transport-framing.test.ts create mode 100644 src/utils/debugger/dap/adapter-discovery.ts create mode 100644 src/utils/debugger/dap/transport.ts create mode 100644 src/utils/debugger/dap/types.ts diff --git a/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md b/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..ed8c63a3 --- /dev/null +++ b/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md @@ -0,0 +1,573 @@ + + +## Goal & constraints (grounded in current code) + +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` + +--- + +## Implementation status (current) + +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.ts` passes conditions to backend +- Health check: `doctor` now reports `lldb-dap` availability +- Tests: DAP transport framing, backend mapping, and debugger manager selection tests + +### MCP tool → DAP request mapping (current) + +| 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` | + +### Breakpoint strategy (current) + +- Breakpoints are stateful: DAP removal re-applies `setBreakpoints`/`setFunctionBreakpoints` with the remaining list. +- Conditions are passed as part of the breakpoint request in both backends: + - DAP: `breakpoints[].condition` or `functionBreakpoints[].condition` + - LLDB CLI: `breakpoint modify -c "" ` + +--- + +## Architectural decisions to make (explicit) + +### 1) Spawn model: one `lldb-dap` process per debug session +**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 `lldb` per session). +- Keeps multi-session support (`DebuggerManager.sessions: Map`) straightforward. + +### 2) Transport abstraction: DAP framing + request correlation in a dedicated module +**Decision**: Build a dedicated DAP transport that: +- implements `Content-Length` framing +- correlates requests/responses by `seq` +- emits DAP events + +This keeps `DapBackend` focused on **mapping MCP tool operations → DAP requests**. + +### 3) Breakpoint conditions support: move condition handling into the backend API +**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. + +--- + +## Implementation plan (by component / file) + +### A) Add DAP protocol & transport layer + +#### New files + +##### 1) `src/utils/debugger/dap/types.ts` +Define minimal DAP types used by the backend (not a full spec). + +Example types (illustrative, not exhaustive): + +```ts +export type DapRequest = { + seq: number; + type: 'request'; + command: string; + arguments?: C; +}; + +export type DapResponse = { + seq: number; + type: 'response'; + request_seq: number; + success: boolean; + command: string; + message?: string; + body?: B; +}; + +export type DapEvent = { + seq: number; + type: 'event'; + event: string; + body?: B; +}; +``` + +Also define bodies used in mapping: +- `InitializeResponseBody` (capabilities) +- `ThreadsResponseBody` +- `StackTraceResponseBody` +- `ScopesResponseBody` +- `VariablesResponseBody` +- `SetBreakpointsResponseBody` +- `EvaluateResponseBody` +- event bodies: `StoppedEventBody`, `OutputEventBody`, `TerminatedEventBody` + +**Side effects / impact**: none outside debugger subsystem; ensures type safety inside DAP modules. + +--- + +##### 2) `src/utils/debugger/dap/transport.ts` +Implement DAP over stdio. + +**Dependencies / imports** +- `node:events` (EventEmitter) or a small typed emitter pattern +- `src/utils/execution/index.ts` for `InteractiveSpawner` and `InteractiveProcess` types +- `src/utils/logging/index.ts` for `log` +- `src/utils/CommandExecutor.ts` type (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-Length` framing +- Maintain: + - `nextSeq: number` + - `pending: Map` keyed by request `seq` +- Expose: + - `sendRequest(command, args, opts?) => Promise` + - event subscription: `onEvent(handler)` or `on('event', ...)` + - lifecycle: `dispose()` (must not throw) + +**Key function signatures** + +```ts +export type DapTransportOptions = { + spawner: InteractiveSpawner; + adapterCommand: string[]; // e.g. ['xcrun', 'lldb-dap'] or [resolvedPath] + env?: Record; + cwd?: string; + logPrefix?: string; +}; + +export class DapTransport { + constructor(opts: DapTransportOptions); + + sendRequest( + command: string, + args?: A, + opts?: { timeoutMs?: number }, + ): Promise; + + 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.parse` body into `{ type: 'response' | 'event' | 'request' }` + +**Process failure handling** +- On adapter `exit`/`error`, reject all pending requests with a clear error (and include exit detail). +- Log stderr output at `debug` level; 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** +- Adds a long-lived child process per session. +- Requires careful memory management in the framing buffer (ensure you slice consumed bytes). + +--- + +### B) Adapter discovery (`xcrun --find lldb-dap`) + +#### New helper (recommended) +##### 3) `src/utils/debugger/dap/adapter-discovery.ts` (new) +**Purpose**: centralize resolution and produce actionable errors when DAP is explicitly selected but unavailable. + +**Uses** +- `CommandExecutor` to run `xcrun --find lldb-dap` +- `log` for diagnostics +- throw a `DependencyError` (from `src/utils/errors.ts`) or plain `Error` with a consistent message + +Example signature: + +```ts +import type { CommandExecutor } from '../../execution/index.ts'; + +export async function resolveLldbDapCommand(opts: { + executor: CommandExecutor; +}): Promise; +// 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 `DapBackend` simpler. + +--- + +### C) Implement `DapBackend` (current) + +#### Modify file: `src/utils/debugger/backends/dap-backend.ts` + +**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** +- `DapTransport` +- `resolveLldbDapCommand` +- `getDefaultCommandExecutor` and `getDefaultInteractiveSpawner` (production defaults) +- `log` +- existing backend interface/types + +**Constructor / factory** +Update `createDapBackend()` to accept injectable deps, mirroring the CLI backend’s injection style. + +```ts +export async function createDapBackend(opts?: { + executor?: CommandExecutor; + spawner?: InteractiveSpawner; + requestTimeoutMs?: number; +}): Promise; +``` + +> This is critical for tests because defaults throw under Vitest. + +**Session state to maintain inside `DapBackend`** +- `transport: DapTransport | null` +- `attached: boolean` +- `lastStoppedThreadId: number | null` +- `cachedThreads: { id: number; name?: string }[] | null` (optional) +- breakpoint registry: + - `breakpointsById: Map` + - for DAP “remove breakpoint”, you must re-issue `setBreakpoints`/`setFunctionBreakpoints` with the updated list, so also keep: + - `fileLineBreakpointsByFile: Map>` + - `functionBreakpoints: Array<{ name: string; condition?: string; id?: number }>` +- optional cached stack frames from the last `stackTrace` call (for variables lookup) + +**Backend lifecycle mapping** +- `attach()`: + 1) spawn `lldb-dap` + 2) `initialize` + 3) `attach` with pid (+ waitFor mapping) + 4) `configurationDone` if required by lldb-dap behavior (plan for it even if no-op) + 5) mark attached + +- `detach()`: + - send `disconnect` with `terminateDebuggee: false` (do not kill app) + - dispose transport / kill process + +- `dispose()`: + - best-effort cleanup; **must not throw** (important because `DebuggerManager.createSession` calls dispose on attach failure) + +**Method mappings (MCP tools → DebuggerManager → DapBackend)** + +1) `runCommand(command: string, opts?)` +- Map to DAP `evaluate` with `context: 'repl'` +- Return string output from `EvaluateResponse.body.result` and/or `body.output` +- If adapter doesn’t support command-style repl evaluation, return a clear error message suggesting `lldb-cli` backend. + +2) `getStack(opts?: { threadIndex?: number; maxFrames?: number })` +- DAP sequence: + - `threads` + - select thread: + - if a `stopped` event has a `threadId`, prefer that when `threadIndex` is undefined + - else map `threadIndex` to array index (document this) + - `stackTrace({ threadId, startFrame: 0, levels: maxFrames })` +- Format output as readable text (LLDB-like) to keep tool behavior familiar: + - `frame #: at :` +- If stackTrace fails due to running state, return a helpful error: + - “Process is running; pause or hit a breakpoint to fetch stack.” + +3) `getVariables(opts?: { frameIndex?: number })` +- DAP sequence: + - resolve thread as above + - `stackTrace` to 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 = ...` + +4) `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 capture `id` +- For `function`: + - update `functionBreakpoints` + - call `setFunctionBreakpoints({ breakpoints: [{ name, condition }] })` +- Return `BreakpointInfo`: + - `id` must be a number (from DAP breakpoint id; if missing, generate a synthetic id and store mapping, but prefer real id) + - `rawOutput` can be a pretty JSON snippet or a short text summary + +5) `removeBreakpoint(id: number)` +- Look up spec in `breakpointsById` +- Remove it from the corresponding registry +- Re-issue `setBreakpoints` or `setFunctionBreakpoints` with the remaining breakpoints +- Return text confirmation + +**Important: DAP vs existing condition flow** +- Today `debug_breakpoint_add` sets condition by issuing an LLDB command after creation. +- With the above, condition becomes part of breakpoint creation and removal logic, backend-agnostic. + +--- + +### D) Internal API adjustment for conditional breakpoints (recommended) + +#### Modify: `src/utils/debugger/backends/DebuggerBackend.ts` +Update signature: + +```ts +addBreakpoint(spec: BreakpointSpec, opts?: { condition?: string }): Promise; +``` + +#### Modify: `src/utils/debugger/debugger-manager.ts` +Update method: + +```ts +async addBreakpoint( + id: string | undefined, + spec: BreakpointSpec, + opts?: { condition?: string }, +): Promise +``` + +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”. + +#### Modify: `src/utils/debugger/backends/lldb-cli-backend.ts` +Implement condition via LLDB command internally after breakpoint creation (current behavior, just moved): + +- after parsing breakpoint id: + - if `opts?.condition`, run `breakpoint modify -c "" ` + +This keeps condition support identical for LLDB CLI users. + +--- + +### E) Update MCP tool logic to use new breakpoint API + +#### Modify: `src/mcp/tools/debugging/debug_breakpoint_add.ts` +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 `runCommand` required + +**Impact / side effects** +- Output remains the same shape, but the “rawOutput” content for DAP may differ (acceptable). +- Improves backend portability. + +--- + +### F) Backend selection & opt-in behavior (already mostly correct) + +#### Modify (optional but recommended): `src/utils/debugger/debugger-manager.ts` +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 `createSession` catch, wrap `dispose()` in its own try/catch (even if backend should not throw). + +--- + +### G) Diagnostics / “doctor” integration (validation surface) + +#### Modify: `src/mcp/tools/doctor/doctor.ts` (not shown in provided contents) +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 `CommandExecutor` and call `xcrun --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. + +--- + +## Concurrency & state management plan + +### Transport-level +- Fully concurrent in-flight DAP requests supported via: + - `seq` generation + - `pending` map keyed by `seq` +- Each request can set its own timeout (`timeoutMs`). + +### Backend-level +Use a serialized queue **only where state mutation occurs**, e.g.: +- updating breakpoint registries +- attach/detach transitions + +Pattern (same as LLDB CLI backend): + +```ts +private queue: Promise = Promise.resolve(); + +private enqueue(work: () => Promise): Promise { ... } +``` + +**Reasoning** +- Prevent races like: + - addBreakpoint + removeBreakpoint in parallel reissuing `setBreakpoints` inconsistently. + +--- + +## Error handling & logging strategy + +### Error taxonomy (pragmatic, consistent with current tools) +- Backend throws `Error` with clear messages. +- MCP tools already catch and wrap errors via `createErrorResponse(...)`. + +### Where to log +- `DapTransport`: + - `log('debug', ...)` for raw events (optionally gated by env) + - `log('error', ...)` on process exit while requests pending +- `DapBackend`: + - minimal `info` logs on attach/detach + - `debug` logs for request mapping (command names, not full payloads unless opted in) + +### New optional env flags (config plan) +Document these (no need to require them): +- `XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS` (default to 30_000) +- `XCODEBUILDMCP_DAP_LOG_EVENTS=true` (default false) + +--- + +## Tests (architecture-aware, DI-compliant) + +Even though this is “testing”, it directly impacts design because default spawners/executors throw under Vitest. + +### 1) Add a first-class mock interactive spawner utility +#### Modify: `src/test-utils/mock-executors.ts` +Add: + +```ts +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. + +### 2) DAP framing tests +New: `src/utils/debugger/dap/__tests__/transport-framing.test.ts` +- Feed partial header/body chunks into the transport parser using `PassThrough` streams behind a mock InteractiveProcess. +- Assert: + - correct parsing across chunk boundaries + - multiple messages in one chunk + - invalid Content-Length handling + +### 3) Backend mapping tests (no real lldb-dap) +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()` formatting + - `getVariables()` formatting + - breakpoint add/remove registry behavior + - `dispose()` never throws + +### 4) DebuggerManager selection test +New: `src/utils/debugger/__tests__/debugger-manager-dap.test.ts` +- Inject a custom `backendFactory` that returns a fake backend (or the scripted DAP backend) and verify: + - env selection + - attach failure triggers dispose + - current session behavior unchanged + +--- + +## Docs updates (grounded in existing docs) + +### 1) Update `docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md` +Replace/extend the existing outline with: +- finalized module list (`dap/types.ts`, `dap/transport.ts`, discovery helper) +- breakpoint strategy (stateful re-issue `setBreakpoints`) +- explicit mapping table per MCP tool + +### 2) Update `docs/DEBUGGING_ARCHITECTURE.md` +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 + +--- + +## Configuration & validation steps (manual / operational) + +### Validation steps (local) +1. Ensure `lldb-dap` is discoverable: + - `xcrun --find lldb-dap` +2. Run server with DAP enabled: + - `XCODEBUILDMCP_DEBUGGER_BACKEND=dap node build/index.js` +3. 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_command` if implemented via evaluate) + - `debug_stack`, `debug_variables` + - `debug_detach` + +### Expected behavioral constraints to document +- If the target is running and no stop context exists, DAP `stackTrace`/`variables` may fail; return guidance in tool output (“pause or set breakpoint”). + +--- + +## Summary of files modified / added + +### Add +- `src/utils/debugger/dap/types.ts` +- `src/utils/debugger/dap/transport.ts` +- `src/utils/debugger/dap/adapter-discovery.ts` (recommended) + +### Modify +- `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` (report `lldb-dap` availability) +- `docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md` +- `docs/DEBUGGING_ARCHITECTURE.md` +- `src/test-utils/mock-executors.ts` (add mock interactive spawner) + +--- + +## Critical “don’t miss” requirements +- `dispose()` in DAP backend and transport must be **best-effort and never throw**, because `DebuggerManager.createSession()` will call dispose on attach failure. +- Avoid any use of default executors/spawners in tests; ensure `createDapBackend()` accepts injected `executor` + `spawner`. +- Breakpoint removal requires stateful re-application with `setBreakpoints` / `setFunctionBreakpoints`; plan for breakpoint registries from day one. diff --git a/docs/DEBUGGING_ARCHITECTURE.md b/docs/DEBUGGING_ARCHITECTURE.md index 7e81cc8d..3cf92b3c 100644 --- a/docs/DEBUGGING_ARCHITECTURE.md +++ b/docs/DEBUGGING_ARCHITECTURE.md @@ -220,21 +220,20 @@ Annotated example (simplified): processes. Tests should inject a mock `InteractiveSpawner` into `createLldbCliBackend()` or a custom `DebuggerManager` backend factory. -## DAP Backend (Stub / Not Implemented) +## DAP Backend (lldb-dap) -- Implementation: `src/utils/debugger/backends/dap-backend.ts`. +- Implementation: `src/utils/debugger/backends/dap-backend.ts`, with protocol support in + `src/utils/debugger/dap/transport.ts`, `src/utils/debugger/dap/types.ts`, and adapter discovery in + `src/utils/debugger/dap/adapter-discovery.ts`. - Selected via backend selection (explicit `backend`, `XCODEBUILDMCP_DEBUGGER_BACKEND=dap`). -- Current status: not implemented. All `DebuggerBackend` methods throw `DAP_ERROR_MESSAGE`, - including `dispose()`. - -Practical effect: - -- Setting `XCODEBUILDMCP_DEBUGGER_BACKEND=dap` causes `debug_attach_sim` to fail during - session creation because `backend.attach()` throws. -- `DebuggerManager.createSession` attempts to call `dispose()` on failure, but the stub `dispose()` - also throws (same message). This is expected until a real DAP backend exists. - -Use `lldb-cli` (default) for actual debugging. +- Adapter discovery uses `xcrun --find lldb-dap`; missing adapters raise a clear dependency error. +- One `lldb-dap` process is spawned per session; DAP framing and request correlation are handled + by `DapTransport`. +- Session handshake: `initialize` → `attach` → `configurationDone`. +- Breakpoints are stateful: adding/removing re-issues `setBreakpoints` or + `setFunctionBreakpoints` with the remaining list. Conditions are passed in the request body. +- Stack/variables typically require a stopped thread; the backend returns guidance if the process + is still running. ## External Tool Invocation @@ -248,8 +247,8 @@ Use `lldb-cli` (default) for actual debugging. ### LLDB - Attachment uses `xcrun lldb --no-lldbinit` in the interactive backend. -- Breakpoint conditions are applied by issuing an additional LLDB command: - `breakpoint modify -c "" `. +- Breakpoint conditions are applied internally by the LLDB CLI backend using + `breakpoint modify -c "" ` after creation. ### xcodebuild (Build/Launch Context) @@ -272,6 +271,6 @@ Use `lldb-cli` (default) for actual debugging. - Session defaults: `src/utils/session-store.ts` - Debug session manager: `src/utils/debugger/debugger-manager.ts` - Backends: `src/utils/debugger/backends/lldb-cli-backend.ts` (default), - `src/utils/debugger/backends/dap-backend.ts` (stub) + `src/utils/debugger/backends/dap-backend.ts` - Interactive execution: `src/utils/execution/interactive-process.ts` (used by LLDB CLI backend) - External commands: `xcrun simctl`, `xcrun lldb`, `xcodebuild` diff --git a/src/mcp/tools/debugging/debug_breakpoint_add.ts b/src/mcp/tools/debugging/debug_breakpoint_add.ts index dbcb9b89..02118e0a 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_add.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_add.ts @@ -36,11 +36,6 @@ const debugBreakpointAddSchema = z.preprocess( export type DebugBreakpointAddParams = z.infer; -function formatCondition(condition: string): string { - const escaped = condition.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - return `"${escaped}"`; -} - export async function debug_breakpoint_addLogic( params: DebugBreakpointAddParams, ctx: DebuggerToolContext, @@ -54,12 +49,9 @@ export async function debug_breakpoint_addLogic( return createErrorResponse('Invalid breakpoint', 'file and line are required.'); } - const result = await ctx.debugger.addBreakpoint(params.debugSessionId, spec); - - if (params.condition) { - const conditionCommand = `breakpoint modify -c ${formatCondition(params.condition)} ${result.id}`; - await ctx.debugger.runCommand(params.debugSessionId, conditionCommand); - } + const result = await ctx.debugger.addBreakpoint(params.debugSessionId, spec, { + condition: params.condition, + }); return createTextResponse(`✅ Breakpoint ${result.id} set.\n\n${result.rawOutput.trim()}`); } catch (error) { diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index d775bf8d..6e20819c 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -24,6 +24,15 @@ const doctorSchema = z.object({ // Use z.infer for type safety type DoctorParams = z.infer; +async function checkLldbDapAvailability(executor: CommandExecutor): Promise { + try { + const result = await executor(['xcrun', '--find', 'lldb-dap'], 'Check lldb-dap'); + return result.success && result.output.trim().length > 0; + } catch { + return false; + } +} + /** * Run the doctor tool and return the results */ @@ -52,6 +61,9 @@ export async function runDoctor( const xcodemakeEnabled = deps.features.isXcodemakeEnabled(); const xcodemakeAvailable = await deps.features.isXcodemakeAvailable(); const makefileExists = deps.features.doesMakefileExist('./'); + const lldbDapAvailable = await checkLldbDapAvailability(deps.commandExecutor); + const selectedDebuggerBackend = process.env.XCODEBUILDMCP_DEBUGGER_BACKEND?.trim(); + const dapSelected = selectedDebuggerBackend?.toLowerCase() === 'dap'; const doctorInfo = { serverVersion: version, @@ -75,6 +87,12 @@ export async function runDoctor( running_under_mise: Boolean(process.env.XCODEBUILDMCP_RUNNING_UNDER_MISE), available: binaryStatus['mise'].available, }, + debugger: { + dap: { + available: lldbDapAvailable, + selected: selectedDebuggerBackend ?? '(default lldb-cli)', + }, + }, }, pluginSystem: pluginSystemInfo, } as const; @@ -196,6 +214,15 @@ export async function runDoctor( `- Running under mise: ${doctorInfo.features.mise.running_under_mise ? '✅ Yes' : '❌ No'}`, `- Mise available: ${doctorInfo.features.mise.available ? '✅ Yes' : '❌ No'}`, + `\n### Debugger Backend (DAP)`, + `- lldb-dap available: ${doctorInfo.features.debugger.dap.available ? '✅ Yes' : '❌ No'}`, + `- Selected backend: ${doctorInfo.features.debugger.dap.selected}`, + ...(dapSelected && !lldbDapAvailable + ? [ + `- Warning: DAP backend selected but lldb-dap not available. Set XCODEBUILDMCP_DEBUGGER_BACKEND=lldb-cli to use the CLI backend.`, + ] + : []), + `\n### Available Tools`, `- Total Plugins: ${'totalPlugins' in doctorInfo.pluginSystem ? doctorInfo.pluginSystem.totalPlugins : 0}`, `- Plugin Directories: ${'pluginDirectories' in doctorInfo.pluginSystem ? doctorInfo.pluginSystem.pluginDirectories : 0}`, diff --git a/src/mcp/tools/doctor/lib/doctor.deps.ts b/src/mcp/tools/doctor/lib/doctor.deps.ts index 340ff9ba..d02f2cd4 100644 --- a/src/mcp/tools/doctor/lib/doctor.deps.ts +++ b/src/mcp/tools/doctor/lib/doctor.deps.ts @@ -87,6 +87,7 @@ export interface FeatureDetector { } export interface DoctorDependencies { + commandExecutor: CommandExecutor; binaryChecker: BinaryChecker; xcode: XcodeInfoProvider; env: EnvironmentInfoProvider; @@ -96,6 +97,7 @@ export interface DoctorDependencies { } export function createDoctorDependencies(executor: CommandExecutor): DoctorDependencies { + const commandExecutor = executor; const binaryChecker: BinaryChecker = { async checkBinaryAvailability(binary: string) { if (binary === 'axe') { @@ -292,7 +294,7 @@ export function createDoctorDependencies(executor: CommandExecutor): DoctorDepen doesMakefileExist, }; - return { binaryChecker, xcode, env, plugins, runtime, features }; + return { commandExecutor, binaryChecker, xcode, env, plugins, runtime, features }; } export type { CommandExecutor }; diff --git a/src/test-utils/mock-executors.ts b/src/test-utils/mock-executors.ts index 6b53e4d1..361b2173 100644 --- a/src/test-utils/mock-executors.ts +++ b/src/test-utils/mock-executors.ts @@ -16,10 +16,12 @@ */ import { ChildProcess } from 'child_process'; -import { EventEmitter } from 'events'; import type { WriteStream } from 'fs'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; import { CommandExecutor, type CommandResponse } from '../utils/CommandExecutor.ts'; import { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; +import type { InteractiveProcess, InteractiveSpawner } from '../utils/execution/index.ts'; export type { CommandExecutor, FileSystemExecutor }; @@ -191,6 +193,100 @@ export function createCommandMatchingMockExecutor( }; } +export type MockInteractiveSession = { + stdout: PassThrough; + stderr: PassThrough; + stdin: PassThrough; + emitExit: (code?: number | null, signal?: NodeJS.Signals | null) => void; + emitError: (error: Error) => void; +}; + +export type MockInteractiveSpawnerScript = { + onSpawn?: (session: MockInteractiveSession) => void; + onWrite?: (data: string, session: MockInteractiveSession) => void; + onKill?: (signal: NodeJS.Signals | undefined, session: MockInteractiveSession) => void; + onDispose?: (session: MockInteractiveSession) => void; +}; + +export function createMockInteractiveSpawner( + script: MockInteractiveSpawnerScript = {}, +): InteractiveSpawner { + return (): InteractiveProcess => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const stdin = new PassThrough(); + const emitter = new EventEmitter(); + const mockProcess = emitter as unknown as ChildProcess; + const mutableProcess = mockProcess as unknown as { + stdout: PassThrough | null; + stderr: PassThrough | null; + stdin: PassThrough | null; + killed: boolean; + exitCode: number | null; + signalCode: NodeJS.Signals | null; + spawnargs: string[]; + spawnfile: string; + pid: number; + }; + + mutableProcess.stdout = stdout; + mutableProcess.stderr = stderr; + mutableProcess.stdin = stdin; + mutableProcess.killed = false; + mutableProcess.exitCode = null; + mutableProcess.signalCode = null; + mutableProcess.spawnargs = []; + mutableProcess.spawnfile = 'mock'; + mutableProcess.pid = 12345; + mockProcess.kill = ((signal?: NodeJS.Signals): boolean => { + mutableProcess.killed = true; + emitter.emit('exit', 0, signal ?? null); + return true; + }) as ChildProcess['kill']; + + const session: MockInteractiveSession = { + stdout, + stderr, + stdin, + emitExit: (code = 0, signal = null) => { + emitter.emit('exit', code, signal); + }, + emitError: (error) => { + emitter.emit('error', error); + }, + }; + + script.onSpawn?.(session); + + let disposed = false; + + return { + process: mockProcess, + write(data: string): void { + if (disposed) { + throw new Error('Mock interactive process disposed'); + } + script.onWrite?.(data, session); + }, + kill(signal?: NodeJS.Signals): void { + if (disposed) return; + mutableProcess.killed = true; + script.onKill?.(signal, session); + emitter.emit('exit', 0, signal ?? null); + }, + dispose(): void { + if (disposed) return; + disposed = true; + script.onDispose?.(session); + stdout.end(); + stderr.end(); + stdin.end(); + emitter.removeAllListeners(); + }, + }; + }; +} + /** * Create a mock file system executor for testing */ diff --git a/src/utils/debugger/__tests__/debugger-manager-dap.test.ts b/src/utils/debugger/__tests__/debugger-manager-dap.test.ts new file mode 100644 index 00000000..f17b5c37 --- /dev/null +++ b/src/utils/debugger/__tests__/debugger-manager-dap.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import type { BreakpointInfo, BreakpointSpec } from '../types.ts'; +import type { DebuggerBackend } from '../backends/DebuggerBackend.ts'; +import { DebuggerManager } from '../debugger-manager.ts'; + +function createBackend(overrides: Partial = {}): DebuggerBackend { + const base: DebuggerBackend = { + kind: 'dap', + attach: async () => {}, + detach: async () => {}, + runCommand: async () => '', + addBreakpoint: async (spec: BreakpointSpec): Promise => ({ + id: 1, + spec, + rawOutput: '', + }), + removeBreakpoint: async () => '', + getStack: async () => '', + getVariables: async () => '', + dispose: async () => {}, + }; + + return { ...base, ...overrides }; +} + +describe('DebuggerManager DAP selection', () => { + it('selects dap backend when env is set', async () => { + const prevEnv = process.env.XCODEBUILDMCP_DEBUGGER_BACKEND; + process.env.XCODEBUILDMCP_DEBUGGER_BACKEND = 'dap'; + + let selected: string | null = null; + const backend = createBackend({ kind: 'dap' }); + const manager = new DebuggerManager({ + backendFactory: async (kind) => { + selected = kind; + return backend; + }, + }); + + await manager.createSession({ simulatorId: 'sim-1', pid: 1000 }); + + expect(selected).toBe('dap'); + + if (prevEnv === undefined) { + delete process.env.XCODEBUILDMCP_DEBUGGER_BACKEND; + } else { + process.env.XCODEBUILDMCP_DEBUGGER_BACKEND = prevEnv; + } + }); + + it('disposes backend when attach fails without masking error', async () => { + const error = new Error('attach failed'); + let disposeCalled = false; + + const backend = createBackend({ + attach: async () => { + throw error; + }, + dispose: async () => { + disposeCalled = true; + throw new Error('dispose failed'); + }, + }); + + const manager = new DebuggerManager({ + backendFactory: async () => backend, + }); + + await expect( + manager.createSession({ simulatorId: 'sim-1', pid: 2000, backend: 'dap' }), + ).rejects.toThrow('attach failed'); + expect(disposeCalled).toBe(true); + }); +}); diff --git a/src/utils/debugger/backends/DebuggerBackend.ts b/src/utils/debugger/backends/DebuggerBackend.ts index 04429889..906a29f8 100644 --- a/src/utils/debugger/backends/DebuggerBackend.ts +++ b/src/utils/debugger/backends/DebuggerBackend.ts @@ -8,7 +8,7 @@ export interface DebuggerBackend { runCommand(command: string, opts?: { timeoutMs?: number }): Promise; - addBreakpoint(spec: BreakpointSpec): Promise; + addBreakpoint(spec: BreakpointSpec, opts?: { condition?: string }): Promise; removeBreakpoint(id: number): Promise; getStack(opts?: { threadIndex?: number; maxFrames?: number }): Promise; diff --git a/src/utils/debugger/backends/__tests__/dap-backend.test.ts b/src/utils/debugger/backends/__tests__/dap-backend.test.ts new file mode 100644 index 00000000..8aa01bf5 --- /dev/null +++ b/src/utils/debugger/backends/__tests__/dap-backend.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; + +import type { DapEvent, DapRequest, DapResponse } from '../../dap/types.ts'; +import { createDapBackend } from '../dap-backend.ts'; +import { + createMockExecutor, + createMockInteractiveSpawner, + type MockInteractiveSession, +} from '../../../../test-utils/mock-executors.ts'; +import type { BreakpointSpec } from '../../types.ts'; + +type ResponsePlan = { + body?: Record; + events?: DapEvent[]; +}; + +function encodeMessage(message: Record): string { + const payload = JSON.stringify(message); + return `Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`; +} + +function createDapSpawner(handlers: Record ResponsePlan>) { + let buffer = Buffer.alloc(0); + let responseSeq = 1000; + + return createMockInteractiveSpawner({ + onWrite: (data: string, session: MockInteractiveSession) => { + buffer = Buffer.concat([buffer, Buffer.from(data, 'utf8')]); + while (true) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; + const header = buffer.slice(0, headerEnd).toString('utf8'); + const match = header.match(/Content-Length:\s*(\d+)/i); + if (!match) { + buffer = buffer.slice(headerEnd + 4); + continue; + } + const length = Number(match[1]); + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + length; + if (buffer.length < bodyEnd) return; + + const body = buffer.slice(bodyStart, bodyEnd).toString('utf8'); + buffer = buffer.slice(bodyEnd); + const request = JSON.parse(body) as DapRequest; + const handler = handlers[request.command]; + if (!handler) { + throw new Error(`Unexpected DAP request: ${request.command}`); + } + const plan = handler(request); + if (plan.events) { + for (const event of plan.events) { + session.stdout.write(encodeMessage(event)); + } + } + const response: DapResponse = { + seq: responseSeq++, + type: 'response', + request_seq: request.seq, + success: true, + command: request.command, + body: plan.body, + }; + session.stdout.write(encodeMessage(response)); + } + }, + }); +} + +function createDefaultHandlers() { + return { + initialize: () => ({ body: { supportsConfigurationDoneRequest: true } }), + attach: () => ({ body: {} }), + configurationDone: () => ({ body: {} }), + threads: () => ({ body: { threads: [{ id: 1, name: 'main' }] } }), + stackTrace: () => ({ + body: { + stackFrames: [ + { + id: 11, + name: 'main', + source: { path: '/tmp/main.swift' }, + line: 42, + }, + ], + }, + }), + scopes: () => ({ + body: { + scopes: [{ name: 'Locals', variablesReference: 100 }], + }, + }), + variables: () => ({ + body: { + variables: [{ name: 'answer', value: '42', type: 'Int' }], + }, + }), + evaluate: () => ({ + body: { + result: 'ok', + output: 'evaluated', + }, + }), + setBreakpoints: (request: DapRequest) => { + const args = request.arguments as { breakpoints: Array<{ line: number }> }; + const breakpoints = (args?.breakpoints ?? []).map((bp, index) => ({ + id: 100 + index, + line: bp.line, + verified: true, + })); + return { body: { breakpoints } }; + }, + setFunctionBreakpoints: (request: DapRequest) => { + const args = request.arguments as { breakpoints: Array<{ name: string }> }; + const breakpoints = (args?.breakpoints ?? []).map((bp, index) => ({ + id: 200 + index, + verified: true, + })); + return { body: { breakpoints } }; + }, + disconnect: () => ({ body: {} }), + } satisfies Record ResponsePlan>; +} + +describe('DapBackend', () => { + it('maps stack, variables, and evaluate', async () => { + const handlers = createDefaultHandlers(); + const spawner = createDapSpawner(handlers); + const executor = createMockExecutor({ success: true, output: '/usr/bin/lldb-dap' }); + + const backend = await createDapBackend({ executor, spawner, requestTimeoutMs: 1_000 }); + await backend.attach({ pid: 4242, simulatorId: 'sim-1' }); + + const stack = await backend.getStack(); + expect(stack).toContain('frame #0: main at /tmp/main.swift:42'); + + const vars = await backend.getVariables(); + expect(vars).toContain('Locals'); + expect(vars).toContain('answer (Int) = 42'); + + const output = await backend.runCommand('frame variable'); + expect(output).toContain('evaluated'); + + await backend.detach(); + await backend.dispose(); + }); + + it('adds and removes breakpoints', async () => { + const handlers = createDefaultHandlers(); + const spawner = createDapSpawner(handlers); + const executor = createMockExecutor({ success: true, output: '/usr/bin/lldb-dap' }); + + const backend = await createDapBackend({ executor, spawner, requestTimeoutMs: 1_000 }); + await backend.attach({ pid: 4242, simulatorId: 'sim-1' }); + + const fileSpec: BreakpointSpec = { kind: 'file-line', file: '/tmp/main.swift', line: 12 }; + const fileBreakpoint = await backend.addBreakpoint(fileSpec, { condition: 'answer == 42' }); + expect(fileBreakpoint.id).toBe(100); + + await backend.removeBreakpoint(fileBreakpoint.id); + + const fnSpec: BreakpointSpec = { kind: 'function', name: 'doWork' }; + const fnBreakpoint = await backend.addBreakpoint(fnSpec); + expect(fnBreakpoint.id).toBe(200); + + await backend.removeBreakpoint(fnBreakpoint.id); + + await backend.detach(); + await backend.dispose(); + }); +}); diff --git a/src/utils/debugger/backends/dap-backend.ts b/src/utils/debugger/backends/dap-backend.ts index d1f17ef5..76dd6dd8 100644 --- a/src/utils/debugger/backends/dap-backend.ts +++ b/src/utils/debugger/backends/dap-backend.ts @@ -1,52 +1,528 @@ import type { DebuggerBackend } from './DebuggerBackend.ts'; import type { BreakpointInfo, BreakpointSpec } from '../types.ts'; +import type { CommandExecutor, InteractiveSpawner } from '../../execution/index.ts'; +import { getDefaultCommandExecutor, getDefaultInteractiveSpawner } from '../../execution/index.ts'; +import { log } from '../../logging/index.ts'; +import type { + DapEvent, + EvaluateResponseBody, + ScopesResponseBody, + SetBreakpointsResponseBody, + StackTraceResponseBody, + StoppedEventBody, + ThreadsResponseBody, + VariablesResponseBody, +} from '../dap/types.ts'; +import { DapTransport } from '../dap/transport.ts'; +import { resolveLldbDapCommand } from '../dap/adapter-discovery.ts'; -const DAP_ERROR_MESSAGE = - 'DAP backend is not implemented yet. Set XCODEBUILDMCP_DEBUGGER_BACKEND=lldb-cli to use the default LLDB CLI backend.'; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const LOG_PREFIX = '[DAP Backend]'; + +type FileLineBreakpointRecord = { line: number; condition?: string; id?: number }; +type FunctionBreakpointRecord = { name: string; condition?: string; id?: number }; + +type BreakpointRecord = { + spec: BreakpointSpec; + condition?: string; +}; class DapBackend implements DebuggerBackend { readonly kind = 'dap' as const; - async attach(neverOpts: { pid: number; simulatorId: string; waitFor?: boolean }): Promise { - void neverOpts; - throw new Error(DAP_ERROR_MESSAGE); + private readonly executor: CommandExecutor; + private readonly spawner: InteractiveSpawner; + private readonly requestTimeoutMs: number; + private readonly logEvents: boolean; + + private transport: DapTransport | null = null; + private unsubscribeEvents: (() => void) | null = null; + private attached = false; + private disposed = false; + private queue: Promise = Promise.resolve(); + + private lastStoppedThreadId: number | null = null; + private breakpointsById = new Map(); + private fileLineBreakpointsByFile = new Map(); + private functionBreakpoints: FunctionBreakpointRecord[] = []; + private nextSyntheticId = -1; + + constructor(opts: { + executor: CommandExecutor; + spawner: InteractiveSpawner; + requestTimeoutMs: number; + logEvents: boolean; + }) { + this.executor = opts.executor; + this.spawner = opts.spawner; + this.requestTimeoutMs = opts.requestTimeoutMs; + this.logEvents = opts.logEvents; + } + + async attach(opts: { pid: number; simulatorId: string; waitFor?: boolean }): Promise { + void opts.simulatorId; + return this.enqueue(async () => { + if (this.disposed) { + throw new Error('DAP backend disposed'); + } + if (this.attached) { + throw new Error('DAP backend already attached'); + } + + const adapterCommand = await resolveLldbDapCommand({ executor: this.executor }); + const transport = new DapTransport({ + spawner: this.spawner, + adapterCommand, + logPrefix: LOG_PREFIX, + }); + this.transport = transport; + this.unsubscribeEvents = transport.onEvent((event) => this.handleEvent(event)); + + try { + const init = await this.request< + { + clientID: string; + clientName: string; + adapterID: string; + linesStartAt1: boolean; + columnsStartAt1: boolean; + pathFormat: string; + supportsVariableType: boolean; + supportsVariablePaging: boolean; + }, + { supportsConfigurationDoneRequest?: boolean } + >('initialize', { + clientID: 'xcodebuildmcp', + clientName: 'XcodeBuildMCP', + adapterID: 'lldb-dap', + linesStartAt1: true, + columnsStartAt1: true, + pathFormat: 'path', + supportsVariableType: true, + supportsVariablePaging: false, + }); + + await this.request('attach', { + pid: opts.pid, + waitFor: opts.waitFor ?? false, + }); + + if (init.supportsConfigurationDoneRequest !== false) { + await this.request('configurationDone', {}); + } + + this.attached = true; + log('info', `${LOG_PREFIX} attached to pid ${opts.pid}`); + } catch (error) { + this.cleanupTransport(); + throw error; + } + }); } async detach(): Promise { - throw new Error(DAP_ERROR_MESSAGE); + return this.enqueue(async () => { + if (!this.transport) return; + try { + await this.request('disconnect', { terminateDebuggee: false }); + } finally { + this.cleanupTransport(); + } + }); } - async runCommand(neverCommand: string, neverOpts?: { timeoutMs?: number }): Promise { - void neverCommand; - void neverOpts; - throw new Error(DAP_ERROR_MESSAGE); + async runCommand(command: string, opts?: { timeoutMs?: number }): Promise { + this.ensureAttached(); + + try { + const body = await this.request< + { expression: string; context: string }, + EvaluateResponseBody + >('evaluate', { expression: command, context: 'repl' }, opts); + return formatEvaluateResult(body); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/evaluate|repl|not supported/i.test(message)) { + throw new Error( + 'DAP backend does not support LLDB command evaluation. Set XCODEBUILDMCP_DEBUGGER_BACKEND=lldb-cli to use the CLI backend.', + ); + } + throw error; + } } - async addBreakpoint(neverSpec: BreakpointSpec): Promise { - void neverSpec; - throw new Error(DAP_ERROR_MESSAGE); + async addBreakpoint( + spec: BreakpointSpec, + opts?: { condition?: string }, + ): Promise { + return this.enqueue(async () => { + this.ensureAttached(); + + if (spec.kind === 'file-line') { + const current = this.fileLineBreakpointsByFile.get(spec.file) ?? []; + const nextBreakpoints = [...current, { line: spec.line, condition: opts?.condition }]; + const updated = await this.setFileBreakpoints(spec.file, nextBreakpoints); + const added = updated[nextBreakpoints.length - 1]; + if (!added?.id) { + throw new Error('DAP breakpoint id missing for file breakpoint.'); + } + return { + id: added.id, + spec, + rawOutput: `Set breakpoint ${added.id} at ${spec.file}:${spec.line}`, + }; + } + + const nextBreakpoints = [ + ...this.functionBreakpoints, + { name: spec.name, condition: opts?.condition }, + ]; + const updated = await this.setFunctionBreakpoints(nextBreakpoints); + const added = updated[nextBreakpoints.length - 1]; + if (!added?.id) { + throw new Error('DAP breakpoint id missing for function breakpoint.'); + } + return { + id: added.id, + spec, + rawOutput: `Set breakpoint ${added.id} on ${spec.name}`, + }; + }); } - async removeBreakpoint(neverId: number): Promise { - void neverId; - throw new Error(DAP_ERROR_MESSAGE); + async removeBreakpoint(id: number): Promise { + return this.enqueue(async () => { + this.ensureAttached(); + + const record = this.breakpointsById.get(id); + if (!record) { + throw new Error(`Breakpoint not found: ${id}`); + } + + if (record.spec.kind === 'file-line') { + const current = this.fileLineBreakpointsByFile.get(record.spec.file) ?? []; + const nextBreakpoints = current.filter((breakpoint) => breakpoint.id !== id); + await this.setFileBreakpoints(record.spec.file, nextBreakpoints); + } else { + const nextBreakpoints = this.functionBreakpoints.filter( + (breakpoint) => breakpoint.id !== id, + ); + await this.setFunctionBreakpoints(nextBreakpoints); + } + + return `Removed breakpoint ${id}.`; + }); } - async getStack(neverOpts?: { threadIndex?: number; maxFrames?: number }): Promise { - void neverOpts; - throw new Error(DAP_ERROR_MESSAGE); + async getStack(opts?: { threadIndex?: number; maxFrames?: number }): Promise { + this.ensureAttached(); + + try { + const thread = await this.resolveThread(opts?.threadIndex); + const stack = await this.request< + { threadId: number; startFrame?: number; levels?: number }, + StackTraceResponseBody + >('stackTrace', { + threadId: thread.id, + startFrame: 0, + levels: opts?.maxFrames, + }); + + if (!stack.stackFrames.length) { + return `Thread ${thread.id}: no stack frames.`; + } + + const threadLabel = thread.name + ? `Thread ${thread.id} (${thread.name})` + : `Thread ${thread.id}`; + const formatted = stack.stackFrames.map((frame, index) => { + const location = frame.source?.path ?? frame.source?.name ?? 'unknown'; + const line = frame.line ?? 0; + return `frame #${index}: ${frame.name} at ${location}:${line}`; + }); + + return [threadLabel, ...formatted].join('\n'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/running|not stopped|no thread|no frames/i.test(message)) { + throw new Error('Process is running; pause or hit a breakpoint to fetch stack.'); + } + throw error; + } } - async getVariables(neverOpts?: { frameIndex?: number }): Promise { - void neverOpts; - throw new Error(DAP_ERROR_MESSAGE); + async getVariables(opts?: { frameIndex?: number }): Promise { + this.ensureAttached(); + + try { + const thread = await this.resolveThread(); + const frameIndex = opts?.frameIndex ?? 0; + const stack = await this.request< + { threadId: number; startFrame?: number; levels?: number }, + StackTraceResponseBody + >('stackTrace', { + threadId: thread.id, + startFrame: 0, + levels: frameIndex + 1, + }); + + if (stack.stackFrames.length <= frameIndex) { + throw new Error(`Frame index ${frameIndex} is out of range.`); + } + + const frame = stack.stackFrames[frameIndex]; + const scopes = await this.request<{ frameId: number }, ScopesResponseBody>('scopes', { + frameId: frame.id, + }); + + if (!scopes.scopes.length) { + return 'No scopes available.'; + } + + const sections: string[] = []; + for (const scope of scopes.scopes) { + if (!scope.variablesReference) { + sections.push(`${scope.name}:\n (no variables)`); + continue; + } + + const vars = await this.request<{ variablesReference: number }, VariablesResponseBody>( + 'variables', + { + variablesReference: scope.variablesReference, + }, + ); + + if (!vars.variables.length) { + sections.push(`${scope.name}:\n (no variables)`); + continue; + } + + const lines = vars.variables.map((variable) => ` ${formatVariable(variable)}`); + sections.push(`${scope.name}:\n${lines.join('\n')}`); + } + + return sections.join('\n\n'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/running|not stopped|no thread/i.test(message)) { + throw new Error('Process is running; pause or hit a breakpoint to fetch variables.'); + } + throw error; + } } async dispose(): Promise { - throw new Error(DAP_ERROR_MESSAGE); + if (this.disposed) return; + this.disposed = true; + try { + this.cleanupTransport(); + } catch (error) { + log('debug', `${LOG_PREFIX} dispose failed: ${String(error)}`); + } } + + private ensureAttached(): void { + if (!this.transport || !this.attached) { + throw new Error('No active DAP session. Attach first.'); + } + } + + private async request( + command: string, + args?: A, + opts?: { timeoutMs?: number }, + ): Promise { + const transport = this.transport; + if (!transport) { + throw new Error('DAP transport not initialized.'); + } + + return transport.sendRequest(command, args, { + timeoutMs: opts?.timeoutMs ?? this.requestTimeoutMs, + }); + } + + private async resolveThread(threadIndex?: number): Promise<{ id: number; name?: string }> { + const body = await this.request('threads'); + const threads = body.threads ?? []; + if (!threads.length) { + throw new Error('No threads available.'); + } + + if (typeof threadIndex === 'number') { + if (threadIndex < 0 || threadIndex >= threads.length) { + throw new Error(`Thread index ${threadIndex} is out of range.`); + } + return threads[threadIndex]; + } + + if (this.lastStoppedThreadId) { + const stopped = threads.find((thread) => thread.id === this.lastStoppedThreadId); + if (stopped) { + return stopped; + } + } + + return threads[0]; + } + + private handleEvent(event: DapEvent): void { + if (this.logEvents) { + log('debug', `${LOG_PREFIX} event: ${JSON.stringify(event)}`); + } + + if (event.event === 'stopped') { + const body = event.body as StoppedEventBody | undefined; + if (body?.threadId) { + this.lastStoppedThreadId = body.threadId; + } + } + } + + private cleanupTransport(): void { + this.attached = false; + this.lastStoppedThreadId = null; + this.unsubscribeEvents?.(); + this.unsubscribeEvents = null; + + if (this.transport) { + this.transport.dispose(); + this.transport = null; + } + } + + private async setFileBreakpoints( + file: string, + breakpoints: FileLineBreakpointRecord[], + ): Promise { + const response = await this.request< + { source: { path: string }; breakpoints: Array<{ line: number; condition?: string }> }, + SetBreakpointsResponseBody + >('setBreakpoints', { + source: { path: file }, + breakpoints: breakpoints.map((bp) => ({ line: bp.line, condition: bp.condition })), + }); + + const updated = breakpoints.map((bp, index) => ({ + ...bp, + id: resolveBreakpointId(response.breakpoints?.[index]?.id, () => this.nextSyntheticId--), + })); + + this.replaceFileBreakpoints(file, updated); + return updated; + } + + private replaceFileBreakpoints(file: string, breakpoints: FileLineBreakpointRecord[]): void { + const existing = this.fileLineBreakpointsByFile.get(file) ?? []; + for (const breakpoint of existing) { + if (breakpoint.id != null) { + this.breakpointsById.delete(breakpoint.id); + } + } + + this.fileLineBreakpointsByFile.set(file, breakpoints); + for (const breakpoint of breakpoints) { + if (breakpoint.id != null) { + this.breakpointsById.set(breakpoint.id, { + spec: { kind: 'file-line', file, line: breakpoint.line }, + condition: breakpoint.condition, + }); + } + } + } + + private async setFunctionBreakpoints( + breakpoints: FunctionBreakpointRecord[], + ): Promise { + const response = await this.request< + { breakpoints: Array<{ name: string; condition?: string }> }, + SetBreakpointsResponseBody + >('setFunctionBreakpoints', { + breakpoints: breakpoints.map((bp) => ({ name: bp.name, condition: bp.condition })), + }); + + const updated = breakpoints.map((bp, index) => ({ + ...bp, + id: resolveBreakpointId(response.breakpoints?.[index]?.id, () => this.nextSyntheticId--), + })); + + this.replaceFunctionBreakpoints(updated); + return updated; + } + + private replaceFunctionBreakpoints(breakpoints: FunctionBreakpointRecord[]): void { + for (const breakpoint of this.functionBreakpoints) { + if (breakpoint.id != null) { + this.breakpointsById.delete(breakpoint.id); + } + } + + this.functionBreakpoints = breakpoints; + for (const breakpoint of breakpoints) { + if (breakpoint.id != null) { + this.breakpointsById.set(breakpoint.id, { + spec: { kind: 'function', name: breakpoint.name }, + condition: breakpoint.condition, + }); + } + } + } + + private enqueue(work: () => Promise): Promise { + const next = this.queue.then(work, work) as Promise; + this.queue = next.then( + () => undefined, + () => undefined, + ); + return next; + } +} + +function resolveBreakpointId(id: number | undefined, fallback: () => number): number { + if (typeof id === 'number' && Number.isFinite(id)) { + return id; + } + return fallback(); +} + +function formatEvaluateResult(body: EvaluateResponseBody): string { + const parts = [body.output, body.result].filter((value) => value && value.trim().length > 0); + return parts.join('\n'); +} + +function formatVariable(variable: { name: string; value: string; type?: string }): string { + const typeSuffix = variable.type ? ` (${variable.type})` : ''; + return `${variable.name}${typeSuffix} = ${variable.value}`; +} + +function parseRequestTimeoutMs(): number { + const raw = process.env.XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS; + if (!raw) return DEFAULT_REQUEST_TIMEOUT_MS; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_REQUEST_TIMEOUT_MS; + } + return parsed; +} + +function parseLogEvents(): boolean { + return process.env.XCODEBUILDMCP_DAP_LOG_EVENTS === 'true'; } -export async function createDapBackend(): Promise { - return new DapBackend(); +export async function createDapBackend(opts?: { + executor?: CommandExecutor; + spawner?: InteractiveSpawner; + requestTimeoutMs?: number; +}): Promise { + const executor = opts?.executor ?? getDefaultCommandExecutor(); + const spawner = opts?.spawner ?? getDefaultInteractiveSpawner(); + const requestTimeoutMs = opts?.requestTimeoutMs ?? parseRequestTimeoutMs(); + const backend = new DapBackend({ + executor, + spawner, + requestTimeoutMs, + logEvents: parseLogEvents(), + }); + return backend; } diff --git a/src/utils/debugger/backends/lldb-cli-backend.ts b/src/utils/debugger/backends/lldb-cli-backend.ts index a84ca592..b5483fc4 100644 --- a/src/utils/debugger/backends/lldb-cli-backend.ts +++ b/src/utils/debugger/backends/lldb-cli-backend.ts @@ -82,7 +82,10 @@ class LldbCliBackend implements DebuggerBackend { }); } - async addBreakpoint(spec: BreakpointSpec): Promise { + async addBreakpoint( + spec: BreakpointSpec, + opts?: { condition?: string }, + ): Promise { const command = spec.kind === 'file-line' ? `breakpoint set --file "${spec.file}" --line ${spec.line}` @@ -95,8 +98,16 @@ class LldbCliBackend implements DebuggerBackend { throw new Error(`Unable to parse breakpoint id from output: ${output}`); } + const id = Number(match[1]); + + if (opts?.condition) { + const condition = formatConditionForLldb(opts.condition); + const modifyOutput = await this.runCommand(`breakpoint modify -c ${condition} ${id}`); + assertNoLldbError('breakpoint modify', modifyOutput); + } + return { - id: Number(match[1]), + id, spec, rawOutput: output, }; @@ -213,6 +224,11 @@ function sanitizeOutput(output: string, prompt: string): string { return filtered.join('\n'); } +function formatConditionForLldb(condition: string): string { + const escaped = condition.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}"`; +} + export async function createLldbCliBackend( spawner: InteractiveSpawner = getDefaultInteractiveSpawner(), ): Promise { diff --git a/src/utils/debugger/dap/__tests__/transport-framing.test.ts b/src/utils/debugger/dap/__tests__/transport-framing.test.ts new file mode 100644 index 00000000..dba9f7c5 --- /dev/null +++ b/src/utils/debugger/dap/__tests__/transport-framing.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; + +import { DapTransport } from '../transport.ts'; +import type { DapEvent, DapResponse } from '../types.ts'; +import { + createMockInteractiveSpawner, + type MockInteractiveSession, +} from '../../../../test-utils/mock-executors.ts'; + +function encodeMessage(message: Record): string { + const payload = JSON.stringify(message); + return `Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`; +} + +function buildResponse( + requestSeq: number, + command: string, + body?: Record, +): DapResponse { + return { + seq: requestSeq + 100, + type: 'response', + request_seq: requestSeq, + success: true, + command, + body, + }; +} + +describe('DapTransport framing', () => { + it('parses responses across chunk boundaries', async () => { + let session: MockInteractiveSession | null = null; + const spawner = createMockInteractiveSpawner({ + onSpawn: (spawned) => { + session = spawned; + }, + }); + + const transport = new DapTransport({ spawner, adapterCommand: ['lldb-dap'] }); + + const responsePromise = transport.sendRequest( + 'initialize', + undefined, + { timeoutMs: 1_000 }, + ); + + const response = encodeMessage(buildResponse(1, 'initialize', { ok: true })); + session?.stdout.write(response.slice(0, 12)); + session?.stdout.write(response.slice(12)); + + await expect(responsePromise).resolves.toEqual({ ok: true }); + transport.dispose(); + }); + + it('handles multiple messages in a single chunk', async () => { + let session: MockInteractiveSession | null = null; + const spawner = createMockInteractiveSpawner({ + onSpawn: (spawned) => { + session = spawned; + }, + }); + + const transport = new DapTransport({ spawner, adapterCommand: ['lldb-dap'] }); + const events: DapEvent[] = []; + transport.onEvent((event) => events.push(event)); + + const responsePromise = transport.sendRequest( + 'threads', + undefined, + { timeoutMs: 1_000 }, + ); + + const eventMessage = encodeMessage({ + seq: 55, + type: 'event', + event: 'output', + body: { output: 'hello' }, + }); + const responseMessage = encodeMessage(buildResponse(1, 'threads', { ok: true })); + + session?.stdout.write(`${eventMessage}${responseMessage}`); + + await expect(responsePromise).resolves.toEqual({ ok: true }); + expect(events).toHaveLength(1); + expect(events[0]?.event).toBe('output'); + transport.dispose(); + }); + + it('continues after invalid headers', async () => { + let session: MockInteractiveSession | null = null; + const spawner = createMockInteractiveSpawner({ + onSpawn: (spawned) => { + session = spawned; + }, + }); + + const transport = new DapTransport({ spawner, adapterCommand: ['lldb-dap'] }); + + const responsePromise = transport.sendRequest( + 'stackTrace', + undefined, + { timeoutMs: 1_000 }, + ); + + session?.stdout.write('Content-Length: nope\r\n\r\n'); + const responseMessage = encodeMessage(buildResponse(1, 'stackTrace', { ok: true })); + session?.stdout.write(responseMessage); + + await expect(responsePromise).resolves.toEqual({ ok: true }); + transport.dispose(); + }); +}); diff --git a/src/utils/debugger/dap/adapter-discovery.ts b/src/utils/debugger/dap/adapter-discovery.ts new file mode 100644 index 00000000..175dd7ca --- /dev/null +++ b/src/utils/debugger/dap/adapter-discovery.ts @@ -0,0 +1,30 @@ +import type { CommandExecutor } from '../../execution/index.ts'; +import { log } from '../../logging/index.ts'; +import { DependencyError } from '../../errors.ts'; + +const LOG_PREFIX = '[DAP Adapter]'; + +export async function resolveLldbDapCommand(opts: { + executor: CommandExecutor; +}): Promise { + try { + const result = await opts.executor(['xcrun', '--find', 'lldb-dap'], LOG_PREFIX); + if (!result.success) { + throw new DependencyError('xcrun returned a non-zero exit code for lldb-dap discovery.'); + } + + const resolved = result.output.trim(); + if (!resolved) { + throw new DependencyError('xcrun did not return a path for lldb-dap.'); + } + + log('debug', `${LOG_PREFIX} resolved lldb-dap: ${resolved}`); + return [resolved]; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new DependencyError( + '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.', + message, + ); + } +} diff --git a/src/utils/debugger/dap/transport.ts b/src/utils/debugger/dap/transport.ts new file mode 100644 index 00000000..636668cf --- /dev/null +++ b/src/utils/debugger/dap/transport.ts @@ -0,0 +1,212 @@ +import { EventEmitter } from 'node:events'; +import type { InteractiveProcess, InteractiveSpawner } from '../../execution/index.ts'; +import { log } from '../../logging/index.ts'; +import type { DapEvent, DapRequest, DapResponse } from './types.ts'; + +const DEFAULT_LOG_PREFIX = '[DAP Transport]'; + +export type DapTransportOptions = { + spawner: InteractiveSpawner; + adapterCommand: string[]; + env?: Record; + cwd?: string; + logPrefix?: string; +}; + +type PendingRequest = { + command: string; + resolve: (body: unknown) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; +}; + +export class DapTransport { + private readonly process: InteractiveProcess; + private readonly logPrefix: string; + private readonly pending = new Map(); + private readonly events = new EventEmitter(); + private nextSeq = 1; + private buffer = Buffer.alloc(0); + private disposed = false; + private exited = false; + + constructor(options: DapTransportOptions) { + this.logPrefix = options.logPrefix ?? DEFAULT_LOG_PREFIX; + this.process = options.spawner(options.adapterCommand, { + env: options.env, + cwd: options.cwd, + }); + + this.process.process.stdout?.on('data', (data: Buffer) => this.handleStdout(data)); + this.process.process.stderr?.on('data', (data: Buffer) => this.handleStderr(data)); + this.process.process.on('exit', (code, signal) => this.handleExit(code, signal)); + this.process.process.on('error', (error) => this.handleError(error)); + } + + sendRequest(command: string, args?: A, opts?: { timeoutMs?: number }): Promise { + if (this.disposed || this.exited) { + return Promise.reject(new Error('DAP transport is not available')); + } + + const seq = this.nextSeq++; + const request: DapRequest = { + seq, + type: 'request', + command, + arguments: args, + }; + + const payload = JSON.stringify(request); + const message = `Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`; + + return new Promise((resolve, reject) => { + const timeoutMs = opts?.timeoutMs ?? 30_000; + const timeout = setTimeout(() => { + this.pending.delete(seq); + reject(new Error(`DAP request timed out after ${timeoutMs}ms (${command})`)); + }, timeoutMs); + + this.pending.set(seq, { + command, + resolve: (body) => resolve(body as B), + reject, + timeout, + }); + + try { + this.process.write(message); + } catch (error) { + clearTimeout(timeout); + this.pending.delete(seq); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + onEvent(handler: (event: DapEvent) => void): () => void { + this.events.on('event', handler); + return () => { + this.events.off('event', handler); + }; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.failAllPending(new Error('DAP transport disposed')); + try { + this.process.dispose(); + } catch (error) { + log('debug', `${this.logPrefix} dispose error: ${String(error)}`); + } + } + + private handleStdout(data: Buffer): void { + if (this.disposed) return; + this.buffer = Buffer.concat([this.buffer, data]); + this.processBuffer(); + } + + private handleStderr(data: Buffer): void { + if (this.disposed) return; + const message = data.toString('utf8').trim(); + if (!message) return; + log('debug', `${this.logPrefix} stderr: ${message}`); + } + + private handleExit(code: number | null, signal: NodeJS.Signals | null): void { + this.exited = true; + const detail = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`; + this.failAllPending(new Error(`DAP adapter exited (${detail})`)); + } + + private handleError(error: Error): void { + this.exited = true; + this.failAllPending(new Error(`DAP adapter error: ${error.message}`)); + } + + private processBuffer(): void { + while (true) { + const headerEnd = this.buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + return; + } + + const header = this.buffer.slice(0, headerEnd).toString('utf8'); + const contentLength = this.parseContentLength(header); + if (contentLength == null) { + log('error', `${this.logPrefix} invalid DAP header: ${header}`); + this.buffer = this.buffer.slice(headerEnd + 4); + continue; + } + + const messageStart = headerEnd + 4; + const messageEnd = messageStart + contentLength; + if (this.buffer.length < messageEnd) { + return; + } + + const bodyBuffer = this.buffer.slice(messageStart, messageEnd); + this.buffer = this.buffer.slice(messageEnd); + + try { + const message = JSON.parse(bodyBuffer.toString('utf8')) as + | DapResponse + | DapEvent + | DapRequest; + this.handleMessage(message); + } catch (error) { + log('error', `${this.logPrefix} failed to parse DAP message: ${String(error)}`); + } + } + } + + private handleMessage(message: DapResponse | DapEvent | DapRequest): void { + if (message.type === 'response') { + const pending = this.pending.get(message.request_seq); + if (!pending) { + log('debug', `${this.logPrefix} received response without pending request`); + return; + } + + this.pending.delete(message.request_seq); + clearTimeout(pending.timeout); + + if (!message.success) { + const detail = message.message ?? 'DAP request failed'; + pending.reject(new Error(`${pending.command} failed: ${detail}`)); + return; + } + + pending.resolve(message.body ?? {}); + return; + } + + if (message.type === 'event') { + this.events.emit('event', message); + return; + } + + log('debug', `${this.logPrefix} ignoring DAP request: ${message.command ?? 'unknown'}`); + } + + private parseContentLength(header: string): number | null { + const lines = header.split(/\r?\n/); + for (const line of lines) { + const match = line.match(/^Content-Length:\s*(\d+)/i); + if (match) { + const length = Number(match[1]); + return Number.isFinite(length) ? length : null; + } + } + return null; + } + + private failAllPending(error: Error): void { + for (const [seq, pending] of this.pending.entries()) { + clearTimeout(pending.timeout); + pending.reject(error); + this.pending.delete(seq); + } + } +} diff --git a/src/utils/debugger/dap/types.ts b/src/utils/debugger/dap/types.ts new file mode 100644 index 00000000..07386bd7 --- /dev/null +++ b/src/utils/debugger/dap/types.ts @@ -0,0 +1,91 @@ +export type DapRequest = { + seq: number; + type: 'request'; + command: string; + arguments?: C; +}; + +export type DapResponse = { + seq: number; + type: 'response'; + request_seq: number; + success: boolean; + command: string; + message?: string; + body?: B; +}; + +export type DapEvent = { + seq: number; + type: 'event'; + event: string; + body?: B; +}; + +export type InitializeResponseBody = { + supportsConfigurationDoneRequest?: boolean; + supportsFunctionBreakpoints?: boolean; + supportsConditionalBreakpoints?: boolean; + supportsEvaluateForHovers?: boolean; + supportsTerminateRequest?: boolean; +}; + +export type ThreadsResponseBody = { + threads: Array<{ id: number; name?: string }>; +}; + +export type StackTraceResponseBody = { + stackFrames: Array<{ + id: number; + name: string; + source?: { path?: string; name?: string }; + line?: number; + column?: number; + }>; + totalFrames?: number; +}; + +export type ScopesResponseBody = { + scopes: Array<{ name: string; variablesReference: number; expensive?: boolean }>; +}; + +export type VariablesResponseBody = { + variables: Array<{ + name: string; + value: string; + type?: string; + variablesReference?: number; + }>; +}; + +export type SetBreakpointsResponseBody = { + breakpoints: Array<{ + id?: number; + verified?: boolean; + message?: string; + source?: { path?: string; name?: string }; + line?: number; + }>; +}; + +export type EvaluateResponseBody = { + result?: string; + output?: string; + type?: string; + variablesReference?: number; +}; + +export type StoppedEventBody = { + reason: string; + threadId?: number; + allThreadsStopped?: boolean; + description?: string; +}; + +export type OutputEventBody = { + category?: string; + output: string; + data?: unknown; +}; + +export type TerminatedEventBody = Record; diff --git a/src/utils/debugger/debugger-manager.ts b/src/utils/debugger/debugger-manager.ts index 6940b2eb..f80fb97c 100644 --- a/src/utils/debugger/debugger-manager.ts +++ b/src/utils/debugger/debugger-manager.ts @@ -35,7 +35,11 @@ export class DebuggerManager { try { await backend.attach({ pid: opts.pid, simulatorId: opts.simulatorId, waitFor: opts.waitFor }); } catch (error) { - await backend.dispose(); + try { + await backend.dispose(); + } catch { + // Best-effort cleanup; keep original attach error. + } throw error; } @@ -97,9 +101,13 @@ export class DebuggerManager { this.currentSessionId = null; } - async addBreakpoint(id: string | undefined, spec: BreakpointSpec): Promise { + async addBreakpoint( + id: string | undefined, + spec: BreakpointSpec, + opts?: { condition?: string }, + ): Promise { const session = this.requireSession(id); - const result = await session.backend.addBreakpoint(spec); + const result = await session.backend.addBreakpoint(spec, opts); this.touch(session.info.id); return result; } From 978956c4124410de7cf3d4412812b0a5f9b2c92c Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 3 Jan 2026 20:33:34 +0000 Subject: [PATCH 03/20] Add guard when debugger is paused --- docs/DEBUGGING_ARCHITECTURE.md | 2 +- .../CalculatorService.swift | 4 + src/mcp/tools/doctor/doctor.ts | 2 +- src/mcp/tools/ui-testing/button.ts | 18 +++- src/mcp/tools/ui-testing/describe_ui.ts | 17 ++- src/mcp/tools/ui-testing/gesture.ts | 16 ++- src/mcp/tools/ui-testing/key_press.ts | 18 +++- src/mcp/tools/ui-testing/key_sequence.ts | 18 +++- src/mcp/tools/ui-testing/long_press.ts | 19 +++- src/mcp/tools/ui-testing/swipe.ts | 18 +++- src/mcp/tools/ui-testing/tap.ts | 18 +++- src/mcp/tools/ui-testing/touch.ts | 18 +++- src/mcp/tools/ui-testing/type_text.ts | 17 ++- .../__tests__/debugger-manager-dap.test.ts | 1 + .../debugger/backends/DebuggerBackend.ts | 3 +- src/utils/debugger/backends/dap-backend.ts | 67 +++++++++++- .../debugger/backends/lldb-cli-backend.ts | 41 ++++++- src/utils/debugger/debugger-manager.ts | 31 +++++- src/utils/debugger/index.ts | 3 + src/utils/debugger/types.ts | 9 ++ src/utils/debugger/ui-automation-guard.ts | 101 ++++++++++++++++++ src/utils/environment.ts | 12 +++ 22 files changed, 429 insertions(+), 24 deletions(-) create mode 100644 src/utils/debugger/ui-automation-guard.ts diff --git a/docs/DEBUGGING_ARCHITECTURE.md b/docs/DEBUGGING_ARCHITECTURE.md index 3cf92b3c..8ff269a2 100644 --- a/docs/DEBUGGING_ARCHITECTURE.md +++ b/docs/DEBUGGING_ARCHITECTURE.md @@ -225,7 +225,7 @@ processes. Tests should inject a mock `InteractiveSpawner` into `createLldbCliBa - Implementation: `src/utils/debugger/backends/dap-backend.ts`, with protocol support in `src/utils/debugger/dap/transport.ts`, `src/utils/debugger/dap/types.ts`, and adapter discovery in `src/utils/debugger/dap/adapter-discovery.ts`. -- Selected via backend selection (explicit `backend`, `XCODEBUILDMCP_DEBUGGER_BACKEND=dap`). +- Selected via backend selection (explicit `backend`, `XCODEBUILDMCP_DEBUGGER_BACKEND=dap`, or default when unset). - Adapter discovery uses `xcrun --find lldb-dap`; missing adapters raise a clear dependency error. - One `lldb-dap` process is spawned per session; DAP framing and request correlation are handled by `DapTransport`. diff --git a/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift index 38c4929d..da0dd405 100644 --- a/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift +++ b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift @@ -94,6 +94,10 @@ public final class CalculatorService { guard let op = operation ?? lastOperation else { return } let operand = (operation != nil) ? currentNumber : lastOperand + if op == .add && previousNumber == 21 && operand == 21 { + fatalError("Intentional crash for debugger smoke test") + } + let result = op.calculate(previousNumber, operand) // Error handling diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index 6e20819c..0ff8cbec 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -90,7 +90,7 @@ export async function runDoctor( debugger: { dap: { available: lldbDapAvailable, - selected: selectedDebuggerBackend ?? '(default lldb-cli)', + selected: selectedDebuggerBackend ?? '(default dap)', }, }, }, diff --git a/src/mcp/tools/ui-testing/button.ts b/src/mcp/tools/ui-testing/button.ts index 1889b048..9e32d48b 100644 --- a/src/mcp/tools/ui-testing/button.ts +++ b/src/mcp/tools/ui-testing/button.ts @@ -4,6 +4,9 @@ import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; +import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; +import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { createAxeNotAvailableResponse, getAxePath, @@ -41,9 +44,18 @@ export async function buttonLogic( getBundledAxeEnvironment, createAxeNotAvailableResponse, }, + debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'button'; const { simulatorId, buttonType, duration } = params; + + const guard = await guardUiAutomationAgainstStoppedDebugger({ + debugger: debuggerManager, + simulatorId, + toolName, + }); + if (guard.blockedResponse) return guard.blockedResponse; + const commandArgs = ['button', buttonType]; if (duration !== undefined) { commandArgs.push('--duration', String(duration)); @@ -54,7 +66,11 @@ export async function buttonLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'button', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - return createTextResponse(`Hardware button '${buttonType}' pressed successfully.`); + const message = `Hardware button '${buttonType}' pressed successfully.`; + if (guard.warningText) { + return createTextResponse(`${message}\n\n${guard.warningText}`); + } + return createTextResponse(message); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { diff --git a/src/mcp/tools/ui-testing/describe_ui.ts b/src/mcp/tools/ui-testing/describe_ui.ts index e24b48e8..be931404 100644 --- a/src/mcp/tools/ui-testing/describe_ui.ts +++ b/src/mcp/tools/ui-testing/describe_ui.ts @@ -5,6 +5,9 @@ import { createErrorResponse } from '../../../utils/responses/index.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; +import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; +import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { createAxeNotAvailableResponse, getAxePath, @@ -52,11 +55,19 @@ export async function describe_uiLogic( getBundledAxeEnvironment, createAxeNotAvailableResponse, }, + debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'describe_ui'; const { simulatorId } = params; const commandArgs = ['describe-ui']; + const guard = await guardUiAutomationAgainstStoppedDebugger({ + debugger: debuggerManager, + simulatorId, + toolName, + }); + if (guard.blockedResponse) return guard.blockedResponse; + log('info', `${LOG_PREFIX}/${toolName}: Starting for ${simulatorId}`); try { @@ -72,7 +83,7 @@ export async function describe_uiLogic( recordDescribeUICall(simulatorId); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - return { + const response: ToolResponse = { content: [ { type: 'text', @@ -89,6 +100,10 @@ export async function describe_uiLogic( }, ], }; + if (guard.warningText) { + response.content.push({ type: 'text', text: guard.warningText }); + } + return response; } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { diff --git a/src/mcp/tools/ui-testing/gesture.ts b/src/mcp/tools/ui-testing/gesture.ts index 0b744599..dd03a3c5 100644 --- a/src/mcp/tools/ui-testing/gesture.ts +++ b/src/mcp/tools/ui-testing/gesture.ts @@ -17,6 +17,9 @@ import { } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; +import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; +import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { createAxeNotAvailableResponse, getAxePath, @@ -101,10 +104,17 @@ export async function gestureLogic( getBundledAxeEnvironment, createAxeNotAvailableResponse, }, + debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'gesture'; const { simulatorId, preset, screenWidth, screenHeight, duration, delta, preDelay, postDelay } = params; + const guard = await guardUiAutomationAgainstStoppedDebugger({ + debugger: debuggerManager, + simulatorId, + toolName, + }); + if (guard.blockedResponse) return guard.blockedResponse; const commandArgs = ['gesture', preset]; if (screenWidth !== undefined) { @@ -131,7 +141,11 @@ export async function gestureLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'gesture', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - return createTextResponse(`Gesture '${preset}' executed successfully.`); + const message = `Gesture '${preset}' executed successfully.`; + if (guard.warningText) { + return createTextResponse(`${message}\n\n${guard.warningText}`); + } + return createTextResponse(message); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { diff --git a/src/mcp/tools/ui-testing/key_press.ts b/src/mcp/tools/ui-testing/key_press.ts index 48d483b3..246bc07e 100644 --- a/src/mcp/tools/ui-testing/key_press.ts +++ b/src/mcp/tools/ui-testing/key_press.ts @@ -10,6 +10,9 @@ import { } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; +import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; +import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { createAxeNotAvailableResponse, getAxePath, @@ -46,9 +49,18 @@ export async function key_pressLogic( getBundledAxeEnvironment, createAxeNotAvailableResponse, }, + debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'key_press'; const { simulatorId, keyCode, duration } = params; + + const guard = await guardUiAutomationAgainstStoppedDebugger({ + debugger: debuggerManager, + simulatorId, + toolName, + }); + if (guard.blockedResponse) return guard.blockedResponse; + const commandArgs = ['key', String(keyCode)]; if (duration !== undefined) { commandArgs.push('--duration', String(duration)); @@ -59,7 +71,11 @@ export async function key_pressLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'key', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - return createTextResponse(`Key press (code: ${keyCode}) simulated successfully.`); + const message = `Key press (code: ${keyCode}) simulated successfully.`; + if (guard.warningText) { + return createTextResponse(`${message}\n\n${guard.warningText}`); + } + return createTextResponse(message); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { diff --git a/src/mcp/tools/ui-testing/key_sequence.ts b/src/mcp/tools/ui-testing/key_sequence.ts index f54886f3..5707c5f1 100644 --- a/src/mcp/tools/ui-testing/key_sequence.ts +++ b/src/mcp/tools/ui-testing/key_sequence.ts @@ -16,6 +16,9 @@ import { } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; +import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; +import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { createAxeNotAvailableResponse, getAxePath, @@ -54,9 +57,18 @@ export async function key_sequenceLogic( getBundledAxeEnvironment, createAxeNotAvailableResponse, }, + debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'key_sequence'; const { simulatorId, keyCodes, delay } = params; + + const guard = await guardUiAutomationAgainstStoppedDebugger({ + debugger: debuggerManager, + simulatorId, + toolName, + }); + if (guard.blockedResponse) return guard.blockedResponse; + const commandArgs = ['key-sequence', '--keycodes', keyCodes.join(',')]; if (delay !== undefined) { commandArgs.push('--delay', String(delay)); @@ -70,7 +82,11 @@ export async function key_sequenceLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'key-sequence', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - return createTextResponse(`Key sequence [${keyCodes.join(',')}] executed successfully.`); + const message = `Key sequence [${keyCodes.join(',')}] executed successfully.`; + if (guard.warningText) { + return createTextResponse(`${message}\n\n${guard.warningText}`); + } + return createTextResponse(message); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { diff --git a/src/mcp/tools/ui-testing/long_press.ts b/src/mcp/tools/ui-testing/long_press.ts index 677b6550..eead5e5c 100644 --- a/src/mcp/tools/ui-testing/long_press.ts +++ b/src/mcp/tools/ui-testing/long_press.ts @@ -17,6 +17,9 @@ import { } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; +import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; +import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { createAxeNotAvailableResponse, getAxePath, @@ -58,9 +61,18 @@ export async function long_pressLogic( getBundledAxeEnvironment, createAxeNotAvailableResponse, }, + debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'long_press'; const { simulatorId, x, y, duration } = params; + + const guard = await guardUiAutomationAgainstStoppedDebugger({ + debugger: debuggerManager, + simulatorId, + toolName, + }); + if (guard.blockedResponse) return guard.blockedResponse; + // AXe uses touch command with --down, --up, and --delay for long press const delayInSeconds = Number(duration) / 1000; // Convert ms to seconds const commandArgs = [ @@ -84,11 +96,12 @@ export async function long_pressLogic( await executeAxeCommand(commandArgs, simulatorId, 'touch', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const warning = getCoordinateWarning(simulatorId); + const coordinateWarning = getCoordinateWarning(simulatorId); const message = `Long press at (${x}, ${y}) for ${duration}ms simulated successfully.`; + const warnings = [guard.warningText, coordinateWarning].filter(Boolean).join('\n\n'); - if (warning) { - return createTextResponse(`${message}\n\n${warning}`); + if (warnings) { + return createTextResponse(`${message}\n\n${warnings}`); } return createTextResponse(message); diff --git a/src/mcp/tools/ui-testing/swipe.ts b/src/mcp/tools/ui-testing/swipe.ts index 57898ff3..30343300 100644 --- a/src/mcp/tools/ui-testing/swipe.ts +++ b/src/mcp/tools/ui-testing/swipe.ts @@ -11,6 +11,9 @@ import { createTextResponse, createErrorResponse } from '../../../utils/response import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; +import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; +import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { createAxeNotAvailableResponse, getAxePath, @@ -58,10 +61,18 @@ export async function swipeLogic( getBundledAxeEnvironment, createAxeNotAvailableResponse, }, + debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'swipe'; const { simulatorId, x1, y1, x2, y2, duration, delta, preDelay, postDelay } = params; + const guard = await guardUiAutomationAgainstStoppedDebugger({ + debugger: debuggerManager, + simulatorId, + toolName, + }); + if (guard.blockedResponse) return guard.blockedResponse; + const commandArgs = [ 'swipe', '--start-x', @@ -96,11 +107,12 @@ export async function swipeLogic( await executeAxeCommand(commandArgs, simulatorId, 'swipe', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const warning = getCoordinateWarning(simulatorId); + const coordinateWarning = getCoordinateWarning(simulatorId); const message = `Swipe from (${x1}, ${y1}) to (${x2}, ${y2})${optionsText} simulated successfully.`; + const warnings = [guard.warningText, coordinateWarning].filter(Boolean).join('\n\n'); - if (warning) { - return createTextResponse(`${message}\n\n${warning}`); + if (warnings) { + return createTextResponse(`${message}\n\n${warnings}`); } return createTextResponse(message); diff --git a/src/mcp/tools/ui-testing/tap.ts b/src/mcp/tools/ui-testing/tap.ts index e55f6acd..82533ae6 100644 --- a/src/mcp/tools/ui-testing/tap.ts +++ b/src/mcp/tools/ui-testing/tap.ts @@ -4,6 +4,9 @@ import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; +import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; +import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { createAxeNotAvailableResponse, getAxePath, @@ -106,10 +109,18 @@ export async function tapLogic( getBundledAxeEnvironment, createAxeNotAvailableResponse, }, + debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'tap'; const { simulatorId, x, y, id, label, preDelay, postDelay } = params; + const guard = await guardUiAutomationAgainstStoppedDebugger({ + debugger: debuggerManager, + simulatorId, + toolName, + }); + if (guard.blockedResponse) return guard.blockedResponse; + let targetDescription = ''; let actionDescription = ''; let usesCoordinates = false; @@ -148,11 +159,12 @@ export async function tapLogic( await executeAxeCommand(commandArgs, simulatorId, 'tap', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const warning = usesCoordinates ? getCoordinateWarning(simulatorId) : null; + const coordinateWarning = usesCoordinates ? getCoordinateWarning(simulatorId) : null; const message = `${actionDescription} simulated successfully.`; + const warnings = [guard.warningText, coordinateWarning].filter(Boolean).join('\n\n'); - if (warning) { - return createTextResponse(`${message}\n\n${warning}`); + if (warnings) { + return createTextResponse(`${message}\n\n${warnings}`); } return createTextResponse(message); diff --git a/src/mcp/tools/ui-testing/touch.ts b/src/mcp/tools/ui-testing/touch.ts index a0334141..e1094e77 100644 --- a/src/mcp/tools/ui-testing/touch.ts +++ b/src/mcp/tools/ui-testing/touch.ts @@ -11,6 +11,9 @@ import { createTextResponse, createErrorResponse } from '../../../utils/response import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; +import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; +import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { createAxeNotAvailableResponse, getAxePath, @@ -48,6 +51,7 @@ export async function touchLogic( params: TouchParams, executor: CommandExecutor, axeHelpers?: AxeHelpers, + debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'touch'; @@ -59,6 +63,13 @@ export async function touchLogic( return createErrorResponse('At least one of "down" or "up" must be true'); } + const guard = await guardUiAutomationAgainstStoppedDebugger({ + debugger: debuggerManager, + simulatorId, + toolName, + }); + if (guard.blockedResponse) return guard.blockedResponse; + const commandArgs = ['touch', '-x', String(x), '-y', String(y)]; if (down) { commandArgs.push('--down'); @@ -80,11 +91,12 @@ export async function touchLogic( await executeAxeCommand(commandArgs, simulatorId, 'touch', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - const warning = getCoordinateWarning(simulatorId); + const coordinateWarning = getCoordinateWarning(simulatorId); const message = `Touch event (${actionText}) at (${x}, ${y}) executed successfully.`; + const warnings = [guard.warningText, coordinateWarning].filter(Boolean).join('\n\n'); - if (warning) { - return createTextResponse(`${message}\n\n${warning}`); + if (warnings) { + return createTextResponse(`${message}\n\n${warnings}`); } return createTextResponse(message); diff --git a/src/mcp/tools/ui-testing/type_text.ts b/src/mcp/tools/ui-testing/type_text.ts index 7ac8727e..71019bf1 100644 --- a/src/mcp/tools/ui-testing/type_text.ts +++ b/src/mcp/tools/ui-testing/type_text.ts @@ -12,6 +12,9 @@ import { createTextResponse, createErrorResponse } from '../../../utils/response import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; +import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts'; +import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts'; import { createAxeNotAvailableResponse, getAxePath, @@ -46,11 +49,19 @@ export async function type_textLogic( params: TypeTextParams, executor: CommandExecutor, axeHelpers?: AxeHelpers, + debuggerManager: DebuggerManager = getDefaultDebuggerManager(), ): Promise { const toolName = 'type_text'; // Params are already validated by the factory, use directly const { simulatorId, text } = params; + const guard = await guardUiAutomationAgainstStoppedDebugger({ + debugger: debuggerManager, + simulatorId, + toolName, + }); + if (guard.blockedResponse) return guard.blockedResponse; + const commandArgs = ['type', text]; log( @@ -61,7 +72,11 @@ export async function type_textLogic( try { await executeAxeCommand(commandArgs, simulatorId, 'type', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); - return createTextResponse('Text typing simulated successfully.'); + const message = 'Text typing simulated successfully.'; + if (guard.warningText) { + return createTextResponse(`${message}\n\n${guard.warningText}`); + } + return createTextResponse(message); } catch (error) { log( 'error', diff --git a/src/utils/debugger/__tests__/debugger-manager-dap.test.ts b/src/utils/debugger/__tests__/debugger-manager-dap.test.ts index f17b5c37..4b0a2ab5 100644 --- a/src/utils/debugger/__tests__/debugger-manager-dap.test.ts +++ b/src/utils/debugger/__tests__/debugger-manager-dap.test.ts @@ -18,6 +18,7 @@ function createBackend(overrides: Partial = {}): DebuggerBacken removeBreakpoint: async () => '', getStack: async () => '', getVariables: async () => '', + getExecutionState: async () => ({ status: 'unknown' }), dispose: async () => {}, }; diff --git a/src/utils/debugger/backends/DebuggerBackend.ts b/src/utils/debugger/backends/DebuggerBackend.ts index 906a29f8..e1fabc36 100644 --- a/src/utils/debugger/backends/DebuggerBackend.ts +++ b/src/utils/debugger/backends/DebuggerBackend.ts @@ -1,4 +1,4 @@ -import type { BreakpointInfo, BreakpointSpec } from '../types.ts'; +import type { BreakpointInfo, BreakpointSpec, DebugExecutionState } from '../types.ts'; export interface DebuggerBackend { readonly kind: 'lldb-cli' | 'dap'; @@ -13,6 +13,7 @@ export interface DebuggerBackend { getStack(opts?: { threadIndex?: number; maxFrames?: number }): Promise; getVariables(opts?: { frameIndex?: number }): Promise; + getExecutionState(opts?: { timeoutMs?: number }): Promise; dispose(): Promise; } diff --git a/src/utils/debugger/backends/dap-backend.ts b/src/utils/debugger/backends/dap-backend.ts index 76dd6dd8..8906db19 100644 --- a/src/utils/debugger/backends/dap-backend.ts +++ b/src/utils/debugger/backends/dap-backend.ts @@ -1,5 +1,5 @@ import type { DebuggerBackend } from './DebuggerBackend.ts'; -import type { BreakpointInfo, BreakpointSpec } from '../types.ts'; +import type { BreakpointInfo, BreakpointSpec, DebugExecutionState } from '../types.ts'; import type { CommandExecutor, InteractiveSpawner } from '../../execution/index.ts'; import { getDefaultCommandExecutor, getDefaultInteractiveSpawner } from '../../execution/index.ts'; import { log } from '../../logging/index.ts'; @@ -42,6 +42,7 @@ class DapBackend implements DebuggerBackend { private queue: Promise = Promise.resolve(); private lastStoppedThreadId: number | null = null; + private executionState: DebugExecutionState = { status: 'unknown' }; private breakpointsById = new Map(); private fileLineBreakpointsByFile = new Map(); private functionBreakpoints: FunctionBreakpointRecord[] = []; @@ -312,6 +313,51 @@ class DapBackend implements DebuggerBackend { } } + async getExecutionState(opts?: { timeoutMs?: number }): Promise { + this.ensureAttached(); + + if (this.executionState.status !== 'unknown') { + return this.executionState; + } + + try { + const body = await this.request('threads', undefined, opts); + const threads = body.threads ?? []; + if (!threads.length) { + return { status: 'unknown' }; + } + + const threadId = threads[0].id; + try { + await this.request< + { threadId: number; startFrame?: number; levels?: number }, + StackTraceResponseBody + >( + 'stackTrace', + { threadId, startFrame: 0, levels: 1 }, + { timeoutMs: opts?.timeoutMs ?? this.requestTimeoutMs }, + ); + const state: DebugExecutionState = { status: 'stopped', threadId }; + this.executionState = state; + return state; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/running|not stopped/i.test(message)) { + const state: DebugExecutionState = { status: 'running', description: message }; + this.executionState = state; + return state; + } + return { status: 'unknown', description: message }; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/running|not stopped/i.test(message)) { + return { status: 'running', description: message }; + } + return { status: 'unknown', description: message }; + } + } + async dispose(): Promise { if (this.disposed) return; this.disposed = true; @@ -374,15 +420,34 @@ class DapBackend implements DebuggerBackend { if (event.event === 'stopped') { const body = event.body as StoppedEventBody | undefined; + this.executionState = { + status: 'stopped', + reason: body?.reason, + description: body?.description, + threadId: body?.threadId, + }; if (body?.threadId) { this.lastStoppedThreadId = body.threadId; } + return; + } + + if (event.event === 'continued') { + this.executionState = { status: 'running' }; + this.lastStoppedThreadId = null; + return; + } + + if (event.event === 'exited' || event.event === 'terminated') { + this.executionState = { status: 'terminated' }; + this.lastStoppedThreadId = null; } } private cleanupTransport(): void { this.attached = false; this.lastStoppedThreadId = null; + this.executionState = { status: 'unknown' }; this.unsubscribeEvents?.(); this.unsubscribeEvents = null; diff --git a/src/utils/debugger/backends/lldb-cli-backend.ts b/src/utils/debugger/backends/lldb-cli-backend.ts index b5483fc4..f42eb48a 100644 --- a/src/utils/debugger/backends/lldb-cli-backend.ts +++ b/src/utils/debugger/backends/lldb-cli-backend.ts @@ -1,7 +1,7 @@ import type { InteractiveSpawner } from '../../execution/index.ts'; import { getDefaultInteractiveSpawner } from '../../execution/index.ts'; import type { DebuggerBackend } from './DebuggerBackend.ts'; -import type { BreakpointInfo, BreakpointSpec } from '../types.ts'; +import type { BreakpointInfo, BreakpointSpec, DebugExecutionState } from '../types.ts'; const DEFAULT_COMMAND_TIMEOUT_MS = 30_000; const DEFAULT_STARTUP_TIMEOUT_MS = 10_000; @@ -137,6 +137,39 @@ class LldbCliBackend implements DebuggerBackend { return this.runCommand('frame variable'); } + async getExecutionState(opts?: { timeoutMs?: number }): Promise { + try { + const output = await this.runCommand('process status', { + timeoutMs: opts?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS, + }); + const normalized = output.toLowerCase(); + + if (/no process|exited|terminated/.test(normalized)) { + return { status: 'terminated', description: output.trim() }; + } + if (/\bstopped\b/.test(normalized)) { + return { + status: 'stopped', + reason: parseStopReason(output), + description: output.trim(), + }; + } + if (/\brunning\b/.test(normalized)) { + return { status: 'running', description: output.trim() }; + } + if (/error:/.test(normalized)) { + return { status: 'unknown', description: output.trim() }; + } + + return { status: 'unknown', description: output.trim() }; + } catch (error) { + return { + status: 'unknown', + description: error instanceof Error ? error.message : String(error), + }; + } + } + async dispose(): Promise { if (this.disposed) return; this.disposed = true; @@ -229,6 +262,12 @@ function formatConditionForLldb(condition: string): string { return `"${escaped}"`; } +function parseStopReason(output: string): string | undefined { + const match = output.match(/stop reason\s*=\s*(.+)/i); + if (!match) return undefined; + return match[1]?.trim() || undefined; +} + export async function createLldbCliBackend( spawner: InteractiveSpawner = getDefaultInteractiveSpawner(), ): Promise { diff --git a/src/utils/debugger/debugger-manager.ts b/src/utils/debugger/debugger-manager.ts index f80fb97c..55ce6053 100644 --- a/src/utils/debugger/debugger-manager.ts +++ b/src/utils/debugger/debugger-manager.ts @@ -5,6 +5,7 @@ import { createLldbCliBackend } from './backends/lldb-cli-backend.ts'; import type { BreakpointInfo, BreakpointSpec, + DebugExecutionState, DebugSessionInfo, DebuggerBackendKind, } from './types.ts'; @@ -74,6 +75,24 @@ export class DebuggerManager { return this.currentSessionId; } + findSessionForSimulator(simulatorId: string): DebugSessionInfo | null { + if (!simulatorId) return null; + if (this.currentSessionId) { + const current = this.sessions.get(this.currentSessionId); + if (current?.info.simulatorId === simulatorId) { + return current.info; + } + } + + for (const session of this.sessions.values()) { + if (session.info.simulatorId === simulatorId) { + return session.info; + } + } + + return null; + } + async detachSession(id?: string): Promise { const session = this.requireSession(id); try { @@ -136,6 +155,16 @@ export class DebuggerManager { return result; } + async getExecutionState( + id: string | undefined, + opts?: { timeoutMs?: number }, + ): Promise { + const session = this.requireSession(id); + const result = await session.backend.getExecutionState(opts); + this.touch(session.info.id); + return result; + } + async runCommand( id: string | undefined, command: string, @@ -165,7 +194,7 @@ export class DebuggerManager { function resolveBackendKind(explicit?: DebuggerBackendKind): DebuggerBackendKind { if (explicit) return explicit; const envValue = process.env.XCODEBUILDMCP_DEBUGGER_BACKEND; - if (!envValue) return 'lldb-cli'; + if (!envValue) return 'dap'; const normalized = envValue.trim().toLowerCase(); if (normalized === 'lldb-cli' || normalized === 'lldb') return 'lldb-cli'; if (normalized === 'dap') return 'dap'; diff --git a/src/utils/debugger/index.ts b/src/utils/debugger/index.ts index 285061b4..839b9b4e 100644 --- a/src/utils/debugger/index.ts +++ b/src/utils/debugger/index.ts @@ -10,9 +10,12 @@ export function getDefaultDebuggerManager(): DebuggerManager { export { DebuggerManager } from './debugger-manager.ts'; export { getDefaultDebuggerToolContext } from './tool-context.ts'; export { resolveSimulatorAppPid } from './simctl.ts'; +export { guardUiAutomationAgainstStoppedDebugger } from './ui-automation-guard.ts'; export type { BreakpointInfo, BreakpointSpec, + DebugExecutionState, + DebugExecutionStatus, DebugSessionInfo, DebuggerBackendKind, } from './types.ts'; diff --git a/src/utils/debugger/types.ts b/src/utils/debugger/types.ts index e839da4c..3dbb7728 100644 --- a/src/utils/debugger/types.ts +++ b/src/utils/debugger/types.ts @@ -18,3 +18,12 @@ export interface BreakpointInfo { spec: BreakpointSpec; rawOutput: string; } + +export type DebugExecutionStatus = 'running' | 'stopped' | 'unknown' | 'terminated'; + +export type DebugExecutionState = { + status: DebugExecutionStatus; + reason?: string; + description?: string; + threadId?: number; +}; diff --git a/src/utils/debugger/ui-automation-guard.ts b/src/utils/debugger/ui-automation-guard.ts new file mode 100644 index 00000000..bfb2a4f7 --- /dev/null +++ b/src/utils/debugger/ui-automation-guard.ts @@ -0,0 +1,101 @@ +import type { ToolResponse } from '../../types/common.ts'; +import { createErrorResponse } from '../responses/index.ts'; +import { log } from '../logging/index.ts'; +import { getUiDebuggerGuardMode } from '../environment.ts'; +import type { DebugExecutionState } from './types.ts'; +import type { DebuggerManager } from './debugger-manager.ts'; + +type GuardResult = { + blockedResponse?: ToolResponse; + warningText?: string; +}; + +const LOG_PREFIX = '[UI Automation Guard]'; + +export async function guardUiAutomationAgainstStoppedDebugger(opts: { + debugger: DebuggerManager; + simulatorId: string; + toolName: string; + mode?: 'error' | 'warn' | 'off'; +}): Promise { + const mode = opts.mode ?? getUiDebuggerGuardMode(); + if (mode === 'off') return {}; + + const session = opts.debugger.findSessionForSimulator(opts.simulatorId); + if (!session) return {}; + + let state: DebugExecutionState; + try { + state = await opts.debugger.getExecutionState(session.id); + } catch (error) { + log( + 'debug', + `${LOG_PREFIX} ${opts.toolName}: unable to read execution state for ${session.id}: ${String(error)}`, + ); + return {}; + } + + if (state.status !== 'stopped') return {}; + + const details = buildGuardDetails({ + toolName: opts.toolName, + simulatorId: opts.simulatorId, + sessionId: session.id, + backend: session.backend, + pid: session.pid, + state, + }); + + if (mode === 'warn') { + return { warningText: buildGuardWarning(details) }; + } + + return { + blockedResponse: createErrorResponse( + 'UI automation blocked: app is paused in debugger', + details, + ), + }; +} + +function buildGuardDetails(params: { + toolName: string; + simulatorId: string; + sessionId: string; + backend: string; + pid: number; + state: DebugExecutionState; +}): string { + const stateLabel = formatStateLabel(params.state); + const lines = [ + `tool=${params.toolName}`, + `simulatorId=${params.simulatorId}`, + `debugSessionId=${params.sessionId}`, + `backend=${params.backend}`, + `pid=${params.pid}`, + `state=${stateLabel}`, + ]; + + if (params.state.description) { + lines.push(`stateDetails=${params.state.description}`); + } + + lines.push( + '', + 'Resume execution (continue), remove breakpoints, or detach via debug_detach before using UI tools.', + ); + + return lines.join('\n'); +} + +function formatStateLabel(state: DebugExecutionState): string { + if (!state.reason) return state.status; + return `${state.status} (${state.reason})`; +} + +function buildGuardWarning(details: string): string { + return [ + 'Warning: debugger reports the app is paused; UI automation may return empty results.', + details, + ].join('\n'); +} diff --git a/src/utils/environment.ts b/src/utils/environment.ts index fac49e9a..8f43ac8f 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -79,6 +79,18 @@ export function isSessionDefaultsSchemaOptOutEnabled(): boolean { return ['1', 'true', 'yes', 'on'].includes(normalized); } +export type UiDebuggerGuardMode = 'error' | 'warn' | 'off'; + +export function getUiDebuggerGuardMode(): UiDebuggerGuardMode { + const raw = process.env.XCODEBUILDMCP_UI_DEBUGGER_GUARD_MODE; + if (!raw) return 'error'; + + const normalized = raw.trim().toLowerCase(); + if (['off', '0', 'false', 'no'].includes(normalized)) return 'off'; + if (['warn', 'warning'].includes(normalized)) return 'warn'; + return 'error'; +} + /** * Normalizes a set of user-provided environment variables by ensuring they are * prefixed with TEST_RUNNER_. Variables already prefixed are preserved. From a8051fc4f11faf39913c87e3e4cb8d2abda2df43 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 3 Jan 2026 21:33:33 +0000 Subject: [PATCH 04/20] Add session identifiers to new resource --- src/core/generated-resources.ts | 4 ++ .../__tests__/session-status.test.ts | 53 +++++++++++++++++++ src/mcp/resources/session-status.ts | 44 +++++++++++++++ .../__tests__/start_device_log_cap.test.ts | 6 +-- .../__tests__/stop_device_log_cap.test.ts | 5 +- src/mcp/tools/logging/start_device_log_cap.ts | 15 ++---- src/mcp/tools/logging/stop_device_log_cap.ts | 5 +- src/utils/debugger/debugger-manager.ts | 4 ++ src/utils/log-capture/device-log-sessions.ts | 13 +++++ src/utils/log-capture/index.ts | 8 ++- src/utils/session-status.ts | 37 +++++++++++++ 11 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 src/mcp/resources/__tests__/session-status.test.ts create mode 100644 src/mcp/resources/session-status.ts create mode 100644 src/utils/log-capture/device-log-sessions.ts create mode 100644 src/utils/session-status.ts diff --git a/src/core/generated-resources.ts b/src/core/generated-resources.ts index 8a1f914c..4e2785df 100644 --- a/src/core/generated-resources.ts +++ b/src/core/generated-resources.ts @@ -10,6 +10,10 @@ export const RESOURCE_LOADERS = { const module = await import('../mcp/resources/doctor.js'); return module.default; }, + 'session-status': async () => { + const module = await import('../mcp/resources/session-status.js'); + return module.default; + }, simulators: async () => { const module = await import('../mcp/resources/simulators.js'); return module.default; diff --git a/src/mcp/resources/__tests__/session-status.test.ts b/src/mcp/resources/__tests__/session-status.test.ts new file mode 100644 index 00000000..fb9a4b30 --- /dev/null +++ b/src/mcp/resources/__tests__/session-status.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; +import { activeLogSessions } from '../../../utils/log_capture.ts'; +import { activeDeviceLogSessions } from '../../../utils/log-capture/device-log-sessions.ts'; +import sessionStatusResource, { sessionStatusResourceLogic } from '../session-status.ts'; + +describe('session-status resource', () => { + beforeEach(async () => { + activeLogSessions.clear(); + activeDeviceLogSessions.clear(); + await getDefaultDebuggerManager().disposeAll(); + }); + + afterEach(async () => { + activeLogSessions.clear(); + activeDeviceLogSessions.clear(); + await getDefaultDebuggerManager().disposeAll(); + }); + + describe('Export Field Validation', () => { + it('should export correct uri', () => { + expect(sessionStatusResource.uri).toBe('xcodebuildmcp://session-status'); + }); + + it('should export correct description', () => { + expect(sessionStatusResource.description).toBe( + 'Runtime session state for log capture and debugging', + ); + }); + + it('should export correct mimeType', () => { + expect(sessionStatusResource.mimeType).toBe('text/plain'); + }); + + it('should export handler function', () => { + expect(typeof sessionStatusResource.handler).toBe('function'); + }); + }); + + describe('Handler Functionality', () => { + it('should return empty status when no sessions exist', async () => { + const result = await sessionStatusResourceLogic(); + + expect(result.contents).toHaveLength(1); + const parsed = JSON.parse(result.contents[0].text); + + expect(parsed.logging.simulator.activeSessionIds).toEqual([]); + expect(parsed.logging.device.activeSessionIds).toEqual([]); + expect(parsed.debug.currentSessionId).toBe(null); + expect(parsed.debug.sessionIds).toEqual([]); + }); + }); +}); diff --git a/src/mcp/resources/session-status.ts b/src/mcp/resources/session-status.ts new file mode 100644 index 00000000..0ffb6c22 --- /dev/null +++ b/src/mcp/resources/session-status.ts @@ -0,0 +1,44 @@ +/** + * Session Status Resource Plugin + * + * Provides read-only runtime session state for log capture and debugging. + */ + +import { log } from '../../utils/logging/index.ts'; +import { getSessionRuntimeStatusSnapshot } from '../../utils/session-status.ts'; + +export async function sessionStatusResourceLogic(): Promise<{ contents: Array<{ text: string }> }> { + try { + log('info', 'Processing session status resource request'); + const status = getSessionRuntimeStatusSnapshot(); + + return { + contents: [ + { + text: JSON.stringify(status, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in session status resource handler: ${errorMessage}`); + + return { + contents: [ + { + text: `Error retrieving session status: ${errorMessage}`, + }, + ], + }; + } +} + +export default { + uri: 'xcodebuildmcp://session-status', + name: 'session-status', + description: 'Runtime session state for log capture and debugging', + mimeType: 'text/plain', + async handler(): Promise<{ contents: Array<{ text: string }> }> { + return sessionStatusResourceLogic(); + }, +}; diff --git a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts index 133062ec..ad81a609 100644 --- a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts @@ -11,10 +11,8 @@ import { createMockExecutor, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; -import plugin, { - start_device_log_capLogic, - activeDeviceLogSessions, -} from '../start_device_log_cap.ts'; +import plugin, { start_device_log_capLogic } from '../start_device_log_cap.ts'; +import { activeDeviceLogSessions } from '../../../../utils/log-capture/device-log-sessions.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; type Mutable = { diff --git a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts index 48078135..d8574ad0 100644 --- a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts @@ -5,7 +5,10 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { EventEmitter } from 'events'; import * as z from 'zod'; import plugin, { stop_device_log_capLogic } from '../stop_device_log_cap.ts'; -import { activeDeviceLogSessions, type DeviceLogSession } from '../start_device_log_cap.ts'; +import { + activeDeviceLogSessions, + type DeviceLogSession, +} from '../../../../utils/log-capture/device-log-sessions.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; // Note: Logger is allowed to execute normally (integration testing pattern) diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts index 39e27fe0..8ebda090 100644 --- a/src/mcp/tools/logging/start_device_log_cap.ts +++ b/src/mcp/tools/logging/start_device_log_cap.ts @@ -19,6 +19,10 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { + activeDeviceLogSessions, + type DeviceLogSession, +} from '../../../utils/log-capture/device-log-sessions.ts'; import type { WriteStream } from 'fs'; /** @@ -34,17 +38,6 @@ const DEVICE_LOG_FILE_PREFIX = 'xcodemcp_device_log_'; // - Devices use 'xcrun devicectl' with console output only (no OSLog streaming) // The different command structures and output formats make sharing infrastructure complex. // However, both follow similar patterns for session management and log retention. -export interface DeviceLogSession { - process: ChildProcess; - logFilePath: string; - deviceUuid: string; - bundleId: string; - logStream?: WriteStream; - hasEnded: boolean; -} - -export const activeDeviceLogSessions = new Map(); - const EARLY_FAILURE_WINDOW_MS = 5000; const INITIAL_OUTPUT_LIMIT = 8_192; const DEFAULT_JSON_RESULT_WAIT_MS = 8000; diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts index 8bc716b9..575c32e9 100644 --- a/src/mcp/tools/logging/stop_device_log_cap.ts +++ b/src/mcp/tools/logging/stop_device_log_cap.ts @@ -7,7 +7,10 @@ import * as fs from 'fs'; import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import { activeDeviceLogSessions, type DeviceLogSession } from './start_device_log_cap.ts'; +import { + activeDeviceLogSessions, + type DeviceLogSession, +} from '../../../utils/log-capture/device-log-sessions.ts'; import { ToolResponse } from '../../../types/common.ts'; import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; diff --git a/src/utils/debugger/debugger-manager.ts b/src/utils/debugger/debugger-manager.ts index 55ce6053..84aabbc4 100644 --- a/src/utils/debugger/debugger-manager.ts +++ b/src/utils/debugger/debugger-manager.ts @@ -75,6 +75,10 @@ export class DebuggerManager { return this.currentSessionId; } + listSessions(): DebugSessionInfo[] { + return Array.from(this.sessions.values()).map((session) => ({ ...session.info })); + } + findSessionForSimulator(simulatorId: string): DebugSessionInfo | null { if (!simulatorId) return null; if (this.currentSessionId) { diff --git a/src/utils/log-capture/device-log-sessions.ts b/src/utils/log-capture/device-log-sessions.ts new file mode 100644 index 00000000..48b7e927 --- /dev/null +++ b/src/utils/log-capture/device-log-sessions.ts @@ -0,0 +1,13 @@ +import type { ChildProcess } from 'child_process'; +import type * as fs from 'fs'; + +export interface DeviceLogSession { + process: ChildProcess; + logFilePath: string; + deviceUuid: string; + bundleId: string; + logStream?: fs.WriteStream; + hasEnded: boolean; +} + +export const activeDeviceLogSessions = new Map(); diff --git a/src/utils/log-capture/index.ts b/src/utils/log-capture/index.ts index 986c525a..06cde410 100644 --- a/src/utils/log-capture/index.ts +++ b/src/utils/log-capture/index.ts @@ -1 +1,7 @@ -export { startLogCapture, stopLogCapture } from '../log_capture.ts'; +import { activeLogSessions, startLogCapture, stopLogCapture } from '../log_capture.ts'; + +export function listActiveSimulatorLogSessionIds(): string[] { + return Array.from(activeLogSessions.keys()).sort(); +} + +export { startLogCapture, stopLogCapture }; diff --git a/src/utils/session-status.ts b/src/utils/session-status.ts new file mode 100644 index 00000000..55bd2ab4 --- /dev/null +++ b/src/utils/session-status.ts @@ -0,0 +1,37 @@ +import { getDefaultDebuggerManager } from './debugger/index.ts'; +import { listActiveSimulatorLogSessionIds } from './log-capture/index.ts'; +import { activeDeviceLogSessions } from './log-capture/device-log-sessions.ts'; + +export type SessionRuntimeStatusSnapshot = { + logging: { + simulator: { activeSessionIds: string[] }; + device: { activeSessionIds: string[] }; + }; + debug: { + currentSessionId: string | null; + sessionIds: string[]; + }; +}; + +export function getSessionRuntimeStatusSnapshot(): SessionRuntimeStatusSnapshot { + const debuggerManager = getDefaultDebuggerManager(); + const sessionIds = debuggerManager + .listSessions() + .map((session) => session.id) + .sort(); + + return { + logging: { + simulator: { + activeSessionIds: listActiveSimulatorLogSessionIds(), + }, + device: { + activeSessionIds: Array.from(activeDeviceLogSessions.keys()).sort(), + }, + }, + debug: { + currentSessionId: debuggerManager.getCurrentSessionId(), + sessionIds, + }, + }; +} From 63ee0e56ad06f7d8c74286908881fc35c6cbaa3c Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 3 Jan 2026 22:15:53 +0000 Subject: [PATCH 05/20] Add better session default documentation and auto-reconsile xor conflicts gracefully --- docs/TOOLS.md | 2 +- .../device/__tests__/list_devices.test.ts | 2 +- src/mcp/tools/device/list_devices.ts | 2 + .../__tests__/discover_projs.test.ts | 4 + .../__tests__/list_schemes.test.ts | 8 ++ .../tools/project-discovery/discover_projs.ts | 8 ++ .../tools/project-discovery/list_schemes.ts | 31 +++-- .../__tests__/session_set_defaults.test.ts | 46 +++++-- .../session_set_defaults.ts | 125 ++++++++++++++---- .../simulator/__tests__/list_sims.test.ts | 12 +- src/mcp/tools/simulator/list_sims.ts | 4 +- 11 files changed, 184 insertions(+), 60 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 42fbcff6..825d4328 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -121,4 +121,4 @@ XcodeBuildMCP provides 70 tools organized into 13 workflow groups for comprehens --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-02* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-03* diff --git a/src/mcp/tools/device/__tests__/list_devices.test.ts b/src/mcp/tools/device/__tests__/list_devices.test.ts index 991afb3c..cbe576d6 100644 --- a/src/mcp/tools/device/__tests__/list_devices.test.ts +++ b/src/mcp/tools/device/__tests__/list_devices.test.ts @@ -231,7 +231,7 @@ describe('list_devices plugin (device-shared)', () => { content: [ { type: 'text', - text: "Connected Devices:\n\n✅ Available Devices:\n\n📱 Test iPhone\n UDID: test-device-123\n Model: iPhone15,2\n Product Type: iPhone15,2\n Platform: iOS 17.0\n Connection: USB\n\nNext Steps:\n1. Build for device: build_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n2. Run tests: test_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n3. Get app path: get_device_app_path({ scheme: 'SCHEME' })\n\nNote: Use the device ID/UDID from above when required by other tools.\n", + text: "Connected Devices:\n\n✅ Available Devices:\n\n📱 Test iPhone\n UDID: test-device-123\n Model: iPhone15,2\n Product Type: iPhone15,2\n Platform: iOS 17.0\n Connection: USB\n\nNext Steps:\n1. Build for device: build_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n2. Run tests: test_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n3. Get app path: get_device_app_path({ scheme: 'SCHEME' })\n\nNote: Use the device ID/UDID from above when required by other tools.\nHint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n", }, ], }); diff --git a/src/mcp/tools/device/list_devices.ts b/src/mcp/tools/device/list_devices.ts index f37731dd..8ca67d6d 100644 --- a/src/mcp/tools/device/list_devices.ts +++ b/src/mcp/tools/device/list_devices.ts @@ -395,6 +395,8 @@ export async function list_devicesLogic( responseText += "2. Run tests: test_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n"; responseText += "3. Get app path: get_device_app_path({ scheme: 'SCHEME' })\n\n"; responseText += 'Note: Use the device ID/UDID from above when required by other tools.\n'; + responseText += + "Hint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n"; } else if (uniqueDevices.length > 0) { responseText += 'Note: No devices are currently available for testing. Make sure devices are:\n'; diff --git a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts index 82926ed5..3d88b1d7 100644 --- a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts @@ -164,6 +164,10 @@ describe('discover_projs plugin', () => { { type: 'text', text: 'Discovery finished. Found 1 projects and 1 workspaces.' }, { type: 'text', text: 'Projects found:\n - /workspace/MyApp.xcodeproj' }, { type: 'text', text: 'Workspaces found:\n - /workspace/MyWorkspace.xcworkspace' }, + { + type: 'text', + text: "Hint: Save a default with session-set-defaults { projectPath: '...' } or { workspacePath: '...' }.", + }, ], isError: false, }); diff --git a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts index 45717b1d..d15cbf59 100644 --- a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts @@ -79,6 +79,10 @@ describe('list_schemes plugin', () => { or for iOS: build_sim({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject", simulatorName: "iPhone 16" }) 2. Show build settings: show_build_settings({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject" })`, }, + { + type: 'text', + text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyProject" } to avoid repeating it.', + }, ], isError: false, }); @@ -299,6 +303,10 @@ describe('list_schemes plugin', () => { or for iOS: build_sim({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp", simulatorName: "iPhone 16" }) 2. Show build settings: show_build_settings({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp" })`, }, + { + type: 'text', + text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyApp" } to avoid repeating it.', + }, ], isError: false, }); diff --git a/src/mcp/tools/project-discovery/discover_projs.ts b/src/mcp/tools/project-discovery/discover_projs.ts index 187eac05..5959844f 100644 --- a/src/mcp/tools/project-discovery/discover_projs.ts +++ b/src/mcp/tools/project-discovery/discover_projs.ts @@ -263,6 +263,14 @@ export async function discover_projsLogic( ); } + if (results.projects.length > 0 || results.workspaces.length > 0) { + responseContent.push( + createTextContent( + "Hint: Save a default with session-set-defaults { projectPath: '...' } or { workspacePath: '...' }.", + ), + ); + } + return { content: responseContent, isError: false, diff --git a/src/mcp/tools/project-discovery/list_schemes.ts b/src/mcp/tools/project-discovery/list_schemes.ts index 7acb970e..b14e925b 100644 --- a/src/mcp/tools/project-discovery/list_schemes.ts +++ b/src/mcp/tools/project-discovery/list_schemes.ts @@ -36,6 +36,8 @@ const listSchemesSchema = z.preprocess( export type ListSchemesParams = z.infer; +const createTextBlock = (text: string) => ({ type: 'text', text }) as const; + /** * Business logic for listing schemes in a project or workspace. * Exported for direct testing and reuse. @@ -79,6 +81,7 @@ export async function listSchemesLogic( // Prepare next steps with the first scheme if available let nextStepsText = ''; + let hintText = ''; if (schemes.length > 0) { const firstScheme = schemes[0]; @@ -87,23 +90,23 @@ export async function listSchemesLogic( 1. Build the app: build_macos({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}" }) or for iOS: build_sim({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}", simulatorName: "iPhone 16" }) 2. Show build settings: show_build_settings({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}" })`; + + hintText = + `Hint: Consider saving a default scheme with session-set-defaults ` + + `{ scheme: "${firstScheme}" } to avoid repeating it.`; + } + + const content = [ + createTextBlock('✅ Available schemes:'), + createTextBlock(schemes.join('\n')), + createTextBlock(nextStepsText), + ]; + if (hintText.length > 0) { + content.push(createTextBlock(hintText)); } return { - content: [ - { - type: 'text', - text: `✅ Available schemes:`, - }, - { - type: 'text', - text: schemes.join('\n'), - }, - { - type: 'text', - text: nextStepsText, - }, - ], + content, isError: false, }; } catch (error) { diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index df9ef581..f7cb9b9b 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -59,54 +59,72 @@ describe('session-set-defaults tool', () => { it('should clear workspacePath when projectPath is set', async () => { sessionStore.setDefaults({ workspacePath: '/old/App.xcworkspace' }); - await sessionSetDefaultsLogic({ projectPath: '/new/App.xcodeproj' }); + const result = await sessionSetDefaultsLogic({ projectPath: '/new/App.xcodeproj' }); const current = sessionStore.getAll(); expect(current.projectPath).toBe('/new/App.xcodeproj'); expect(current.workspacePath).toBeUndefined(); + expect(result.content[0].text).toContain( + 'Cleared workspacePath because projectPath was set.', + ); }); it('should clear projectPath when workspacePath is set', async () => { sessionStore.setDefaults({ projectPath: '/old/App.xcodeproj' }); - await sessionSetDefaultsLogic({ workspacePath: '/new/App.xcworkspace' }); + const result = await sessionSetDefaultsLogic({ workspacePath: '/new/App.xcworkspace' }); const current = sessionStore.getAll(); expect(current.workspacePath).toBe('/new/App.xcworkspace'); expect(current.projectPath).toBeUndefined(); + expect(result.content[0].text).toContain( + 'Cleared projectPath because workspacePath was set.', + ); }); it('should clear simulatorName when simulatorId is set', async () => { sessionStore.setDefaults({ simulatorName: 'iPhone 16' }); - await sessionSetDefaultsLogic({ simulatorId: 'SIM-UUID' }); + const result = await sessionSetDefaultsLogic({ simulatorId: 'SIM-UUID' }); const current = sessionStore.getAll(); expect(current.simulatorId).toBe('SIM-UUID'); expect(current.simulatorName).toBeUndefined(); + expect(result.content[0].text).toContain( + 'Cleared simulatorName because simulatorId was set.', + ); }); it('should clear simulatorId when simulatorName is set', async () => { sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); - await sessionSetDefaultsLogic({ simulatorName: 'iPhone 16' }); + const result = await sessionSetDefaultsLogic({ simulatorName: 'iPhone 16' }); const current = sessionStore.getAll(); expect(current.simulatorName).toBe('iPhone 16'); expect(current.simulatorId).toBeUndefined(); + expect(result.content[0].text).toContain( + 'Cleared simulatorId because simulatorName was set.', + ); }); - it('should reject when both projectPath and workspacePath are provided', async () => { - const res = await plugin.handler({ + it('should prefer workspacePath when both projectPath and workspacePath are provided', async () => { + const res = await sessionSetDefaultsLogic({ projectPath: '/app/App.xcodeproj', workspacePath: '/app/App.xcworkspace', }); - expect(res.isError).toBe(true); - expect(res.content[0].text).toContain('Parameter validation failed'); - expect(res.content[0].text).toContain('projectPath and workspacePath are mutually exclusive'); + const current = sessionStore.getAll(); + expect(current.workspacePath).toBe('/app/App.xcworkspace'); + expect(current.projectPath).toBeUndefined(); + expect(res.content[0].text).toContain( + 'Both projectPath and workspacePath were provided; keeping workspacePath and ignoring projectPath.', + ); }); - it('should reject when both simulatorId and simulatorName are provided', async () => { - const res = await plugin.handler({ + it('should prefer simulatorId when both simulatorId and simulatorName are provided', async () => { + const res = await sessionSetDefaultsLogic({ simulatorId: 'SIM-1', simulatorName: 'iPhone 16', }); - expect(res.isError).toBe(true); - expect(res.content[0].text).toContain('Parameter validation failed'); - expect(res.content[0].text).toContain('simulatorId and simulatorName are mutually exclusive'); + const current = sessionStore.getAll(); + expect(current.simulatorId).toBe('SIM-1'); + expect(current.simulatorName).toBeUndefined(); + expect(res.content[0].text).toContain( + 'Both simulatorId and simulatorName were provided; keeping simulatorId and ignoring simulatorName.', + ); }); }); }); diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts index d1e80c1d..9f8912ce 100644 --- a/src/mcp/tools/session-management/session_set_defaults.ts +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -5,49 +5,124 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; const baseSchema = z.object({ - projectPath: z.string().optional(), - workspacePath: z.string().optional(), - scheme: z.string().optional(), - configuration: z.string().optional(), - simulatorName: z.string().optional(), - simulatorId: z.string().optional(), - deviceId: z.string().optional(), - useLatestOS: z.boolean().optional(), - arch: z.enum(['arm64', 'x86_64']).optional(), + projectPath: z + .string() + .optional() + .describe( + 'Xcode project (.xcodeproj) path. Mutually exclusive with workspacePath. Required for most build/test tools when workspacePath is not set.', + ), + workspacePath: z + .string() + .optional() + .describe( + 'Xcode workspace (.xcworkspace) path. Mutually exclusive with projectPath. Required for most build/test tools when projectPath is not set.', + ), + scheme: z + .string() + .optional() + .describe( + 'Xcode scheme. Required by most build/test tools. Use list_schemes to discover available schemes before setting.', + ), + configuration: z.string().optional().describe('Build configuration, e.g. Debug or Release.'), + simulatorName: z + .string() + .optional() + .describe( + 'Simulator device name for simulator workflows. If simulatorId is also provided, simulatorId is preferred and simulatorName is ignored.', + ), + simulatorId: z + .string() + .optional() + .describe( + 'Simulator UUID for simulator workflows. Preferred over simulatorName when both are provided.', + ), + deviceId: z.string().optional().describe('Physical device ID for device workflows.'), + useLatestOS: z + .boolean() + .optional() + .describe('When true, prefer the latest available OS for simulatorName lookups.'), + arch: z.enum(['arm64', 'x86_64']).optional().describe('Target architecture for macOS builds.'), suppressWarnings: z .boolean() .optional() .describe('When true, warning messages are filtered from build output to conserve context'), }); -const schemaObj = baseSchema - .refine((v) => !(v.projectPath && v.workspacePath), { - message: 'projectPath and workspacePath are mutually exclusive', - path: ['projectPath'], - }) - .refine((v) => !(v.simulatorId && v.simulatorName), { - message: 'simulatorId and simulatorName are mutually exclusive', - path: ['simulatorId'], - }); +const schemaObj = baseSchema; type Params = z.infer; export async function sessionSetDefaultsLogic(params: Params): Promise { + const notices: string[] = []; + const current = sessionStore.getAll(); + const nextParams: Partial = { ...params }; + + const hasProjectPath = + Object.prototype.hasOwnProperty.call(params, 'projectPath') && params.projectPath !== undefined; + const hasWorkspacePath = + Object.prototype.hasOwnProperty.call(params, 'workspacePath') && + params.workspacePath !== undefined; + const hasSimulatorId = + Object.prototype.hasOwnProperty.call(params, 'simulatorId') && params.simulatorId !== undefined; + const hasSimulatorName = + Object.prototype.hasOwnProperty.call(params, 'simulatorName') && + params.simulatorName !== undefined; + + if (hasProjectPath && hasWorkspacePath) { + delete nextParams.projectPath; + notices.push( + 'Both projectPath and workspacePath were provided; keeping workspacePath and ignoring projectPath.', + ); + } + + if (hasSimulatorId && hasSimulatorName) { + delete nextParams.simulatorName; + notices.push( + 'Both simulatorId and simulatorName were provided; keeping simulatorId and ignoring simulatorName.', + ); + } + // Clear mutually exclusive counterparts before merging new defaults const toClear = new Set(); - if (Object.prototype.hasOwnProperty.call(params, 'projectPath')) toClear.add('workspacePath'); - if (Object.prototype.hasOwnProperty.call(params, 'workspacePath')) toClear.add('projectPath'); - if (Object.prototype.hasOwnProperty.call(params, 'simulatorId')) toClear.add('simulatorName'); - if (Object.prototype.hasOwnProperty.call(params, 'simulatorName')) toClear.add('simulatorId'); + if (Object.prototype.hasOwnProperty.call(nextParams, 'projectPath')) { + toClear.add('workspacePath'); + if (current.workspacePath !== undefined) { + notices.push('Cleared workspacePath because projectPath was set.'); + } + } + if (Object.prototype.hasOwnProperty.call(nextParams, 'workspacePath')) { + toClear.add('projectPath'); + if (current.projectPath !== undefined) { + notices.push('Cleared projectPath because workspacePath was set.'); + } + } + if (Object.prototype.hasOwnProperty.call(nextParams, 'simulatorId')) { + toClear.add('simulatorName'); + if (current.simulatorName !== undefined) { + notices.push('Cleared simulatorName because simulatorId was set.'); + } + } + if (Object.prototype.hasOwnProperty.call(nextParams, 'simulatorName')) { + toClear.add('simulatorId'); + if (current.simulatorId !== undefined) { + notices.push('Cleared simulatorId because simulatorName was set.'); + } + } if (toClear.size > 0) { sessionStore.clear(Array.from(toClear)); } - sessionStore.setDefaults(params as Partial); - const current = sessionStore.getAll(); + sessionStore.setDefaults(nextParams as Partial); + const updated = sessionStore.getAll(); + const noticeText = notices.length > 0 ? `\nNotices:\n- ${notices.join('\n- ')}` : ''; return { - content: [{ type: 'text', text: `Defaults updated:\n${JSON.stringify(current, null, 2)}` }], + content: [ + { + type: 'text', + text: `Defaults updated:\n${JSON.stringify(updated, null, 2)}${noticeText}`, + }, + ], isError: false, }; } diff --git a/src/mcp/tools/simulator/__tests__/list_sims.test.ts b/src/mcp/tools/simulator/__tests__/list_sims.test.ts index cebcf4aa..04c61512 100644 --- a/src/mcp/tools/simulator/__tests__/list_sims.test.ts +++ b/src/mcp/tools/simulator/__tests__/list_sims.test.ts @@ -124,7 +124,8 @@ Next Steps: 1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' }) 2. Open the simulator UI: open_sim({}) 3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }) -4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`, +4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' }) +Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`, }, ], }); @@ -178,7 +179,8 @@ Next Steps: 1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' }) 2. Open the simulator UI: open_sim({}) 3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }) -4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`, +4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' }) +Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`, }, ], }); @@ -238,7 +240,8 @@ Next Steps: 1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' }) 2. Open the simulator UI: open_sim({}) 3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }) -4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`, +4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' }) +Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`, }, ], }); @@ -303,7 +306,8 @@ Next Steps: 1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' }) 2. Open the simulator UI: open_sim({}) 3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }) -4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`, +4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' }) +Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`, }, ], }); diff --git a/src/mcp/tools/simulator/list_sims.ts b/src/mcp/tools/simulator/list_sims.ts index ac2b1282..e24edfda 100644 --- a/src/mcp/tools/simulator/list_sims.ts +++ b/src/mcp/tools/simulator/list_sims.ts @@ -189,7 +189,9 @@ export async function list_simsLogic( responseText += "3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })\n"; responseText += - "4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })"; + "4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })\n"; + responseText += + "Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName)."; return { content: [ From c85ca019b0bebe49df721f65b49312bea241117b Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 4 Jan 2026 16:59:26 +0000 Subject: [PATCH 06/20] Guard debugger smoke-test crash in Calculator example --- .../Sources/CalculatorAppFeature/CalculatorService.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift index da0dd405..91b505e1 100644 --- a/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift +++ b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift @@ -94,9 +94,11 @@ public final class CalculatorService { guard let op = operation ?? lastOperation else { return } let operand = (operation != nil) ? currentNumber : lastOperand + #if DEBUG if op == .add && previousNumber == 21 && operand == 21 { fatalError("Intentional crash for debugger smoke test") } + #endif let result = op.calculate(previousNumber, operand) From 90c2c023ebeb7b8b4491a4c85407588c148cc5fc Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 4 Jan 2026 17:03:14 +0000 Subject: [PATCH 07/20] Harden debugger backend lifecycle and messaging --- src/mcp/tools/debugging/debug_attach_sim.ts | 4 +- src/mcp/tools/doctor/doctor.ts | 2 +- src/utils/debugger/backends/dap-backend.ts | 66 ++++++++++--------- .../debugger/backends/lldb-cli-backend.ts | 26 +++++--- src/utils/debugger/dap/adapter-discovery.ts | 3 + src/utils/debugger/debugger-manager.ts | 20 +++--- src/utils/execution/interactive-process.ts | 3 + 7 files changed, 73 insertions(+), 51 deletions(-) diff --git a/src/mcp/tools/debugging/debug_attach_sim.ts b/src/mcp/tools/debugging/debug_attach_sim.ts index b749d19a..bcc0a2f4 100644 --- a/src/mcp/tools/debugging/debug_attach_sim.ts +++ b/src/mcp/tools/debugging/debug_attach_sim.ts @@ -114,8 +114,10 @@ export async function debug_attach_simLogic( ? 'This session is now the current debug session.' : 'This session is not set as the current session.'; + const backendLabel = session.backend === 'dap' ? 'DAP debugger' : 'LLDB'; + return createTextResponse( - `${warningText}✅ Attached LLDB to simulator process ${pid} (${simulatorId}).\n\n` + + `${warningText}✅ Attached ${backendLabel} to simulator process ${pid} (${simulatorId}).\n\n` + `Debug session ID: ${session.id}\n` + `${currentText}\n\n` + `Next steps:\n` + diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index 0ff8cbec..52ea979b 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -63,7 +63,7 @@ export async function runDoctor( const makefileExists = deps.features.doesMakefileExist('./'); const lldbDapAvailable = await checkLldbDapAvailability(deps.commandExecutor); const selectedDebuggerBackend = process.env.XCODEBUILDMCP_DEBUGGER_BACKEND?.trim(); - const dapSelected = selectedDebuggerBackend?.toLowerCase() === 'dap'; + const dapSelected = !selectedDebuggerBackend || selectedDebuggerBackend.toLowerCase() === 'dap'; const doctorInfo = { serverVersion: version, diff --git a/src/utils/debugger/backends/dap-backend.ts b/src/utils/debugger/backends/dap-backend.ts index 8906db19..8de743b2 100644 --- a/src/utils/debugger/backends/dap-backend.ts +++ b/src/utils/debugger/backends/dap-backend.ts @@ -314,48 +314,50 @@ class DapBackend implements DebuggerBackend { } async getExecutionState(opts?: { timeoutMs?: number }): Promise { - this.ensureAttached(); - - if (this.executionState.status !== 'unknown') { - return this.executionState; - } + return this.enqueue(async () => { + this.ensureAttached(); - try { - const body = await this.request('threads', undefined, opts); - const threads = body.threads ?? []; - if (!threads.length) { - return { status: 'unknown' }; + if (this.executionState.status !== 'unknown') { + return this.executionState; } - const threadId = threads[0].id; try { - await this.request< - { threadId: number; startFrame?: number; levels?: number }, - StackTraceResponseBody - >( - 'stackTrace', - { threadId, startFrame: 0, levels: 1 }, - { timeoutMs: opts?.timeoutMs ?? this.requestTimeoutMs }, - ); - const state: DebugExecutionState = { status: 'stopped', threadId }; - this.executionState = state; - return state; + const body = await this.request('threads', undefined, opts); + const threads = body.threads ?? []; + if (!threads.length) { + return { status: 'unknown' }; + } + + const threadId = threads[0].id; + try { + await this.request< + { threadId: number; startFrame?: number; levels?: number }, + StackTraceResponseBody + >( + 'stackTrace', + { threadId, startFrame: 0, levels: 1 }, + { timeoutMs: opts?.timeoutMs ?? this.requestTimeoutMs }, + ); + const state: DebugExecutionState = { status: 'stopped', threadId }; + this.executionState = state; + return state; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/running|not stopped/i.test(message)) { + const state: DebugExecutionState = { status: 'running', description: message }; + this.executionState = state; + return state; + } + return { status: 'unknown', description: message }; + } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/running|not stopped/i.test(message)) { - const state: DebugExecutionState = { status: 'running', description: message }; - this.executionState = state; - return state; + return { status: 'running', description: message }; } return { status: 'unknown', description: message }; } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (/running|not stopped/i.test(message)) { - return { status: 'running', description: message }; - } - return { status: 'unknown', description: message }; - } + }); } async dispose(): Promise { diff --git a/src/utils/debugger/backends/lldb-cli-backend.ts b/src/utils/debugger/backends/lldb-cli-backend.ts index f42eb48a..4a91cc39 100644 --- a/src/utils/debugger/backends/lldb-cli-backend.ts +++ b/src/utils/debugger/backends/lldb-cli-backend.ts @@ -1,4 +1,4 @@ -import type { InteractiveSpawner } from '../../execution/index.ts'; +import type { InteractiveProcess, InteractiveSpawner } from '../../execution/index.ts'; import { getDefaultInteractiveSpawner } from '../../execution/index.ts'; import type { DebuggerBackend } from './DebuggerBackend.ts'; import type { BreakpointInfo, BreakpointSpec, DebugExecutionState } from '../types.ts'; @@ -7,13 +7,14 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 30_000; const DEFAULT_STARTUP_TIMEOUT_MS = 10_000; const LLDB_PROMPT = 'XCODEBUILDMCP_LLDB> '; const COMMAND_SENTINEL = '__XCODEBUILDMCP_DONE__'; +const COMMAND_SENTINEL_REGEX = new RegExp(`(^|\\r?\\n)${COMMAND_SENTINEL}(\\r?\\n)`); class LldbCliBackend implements DebuggerBackend { readonly kind = 'lldb-cli' as const; private readonly spawner: InteractiveSpawner; private readonly prompt = LLDB_PROMPT; - private readonly process; + private readonly process: InteractiveProcess; private buffer = ''; private pending: { resolve: (output: string) => void; @@ -121,11 +122,11 @@ class LldbCliBackend implements DebuggerBackend { async getStack(opts?: { threadIndex?: number; maxFrames?: number }): Promise { let command = 'thread backtrace'; - if (typeof opts?.threadIndex === 'number') { - command += ` -t ${opts.threadIndex}`; - } if (typeof opts?.maxFrames === 'number') { - command += ` -m ${opts.maxFrames}`; + command += ` -c ${opts.maxFrames}`; + } + if (typeof opts?.threadIndex === 'number') { + command += ` ${opts.threadIndex}`; } return this.runCommand(command); } @@ -209,7 +210,7 @@ class LldbCliBackend implements DebuggerBackend { private checkPending(): void { if (!this.pending) return; - const sentinelMatch = this.buffer.match(/(^|\r?\n)__XCODEBUILDMCP_DONE__(\r?\n)/); + const sentinelMatch = this.buffer.match(COMMAND_SENTINEL_REGEX); const sentinelIndex = sentinelMatch?.index; const sentinelLength = sentinelMatch?.[0].length; if (sentinelIndex == null || sentinelLength == null) return; @@ -272,6 +273,15 @@ export async function createLldbCliBackend( spawner: InteractiveSpawner = getDefaultInteractiveSpawner(), ): Promise { const backend = new LldbCliBackend(spawner); - await backend.waitUntilReady(); + try { + await backend.waitUntilReady(); + } catch (error) { + try { + await backend.dispose(); + } catch { + // Best-effort cleanup; keep original error. + } + throw error; + } return backend; } diff --git a/src/utils/debugger/dap/adapter-discovery.ts b/src/utils/debugger/dap/adapter-discovery.ts index 175dd7ca..5325d374 100644 --- a/src/utils/debugger/dap/adapter-discovery.ts +++ b/src/utils/debugger/dap/adapter-discovery.ts @@ -21,6 +21,9 @@ export async function resolveLldbDapCommand(opts: { log('debug', `${LOG_PREFIX} resolved lldb-dap: ${resolved}`); return [resolved]; } catch (error) { + if (error instanceof DependencyError) { + throw error; + } const message = error instanceof Error ? error.message : String(error); throw new DependencyError( '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.', diff --git a/src/utils/debugger/debugger-manager.ts b/src/utils/debugger/debugger-manager.ts index 84aabbc4..c56ccf54 100644 --- a/src/utils/debugger/debugger-manager.ts +++ b/src/utils/debugger/debugger-manager.ts @@ -111,15 +111,17 @@ export class DebuggerManager { } async disposeAll(): Promise { - for (const session of this.sessions.values()) { - try { - await session.backend.detach(); - } catch { - // Best-effort cleanup; detach can fail if the process exited. - } finally { - await session.backend.dispose(); - } - } + await Promise.allSettled( + Array.from(this.sessions.values()).map(async (session) => { + try { + await session.backend.detach(); + } catch { + // Best-effort cleanup; detach can fail if the process exited. + } finally { + await session.backend.dispose(); + } + }), + ); this.sessions.clear(); this.currentSessionId = null; } diff --git a/src/utils/execution/interactive-process.ts b/src/utils/execution/interactive-process.ts index 73699b1d..baf796b8 100644 --- a/src/utils/execution/interactive-process.ts +++ b/src/utils/execution/interactive-process.ts @@ -57,6 +57,9 @@ function createInteractiveProcess( command: string[], opts?: SpawnInteractiveOptions, ): InteractiveProcess { + if (command.length === 0) { + throw new Error('Command array must not be empty'); + } const [executable, ...args] = command; const childProcess = spawn(executable, args, { stdio: ['pipe', 'pipe', 'pipe'], From a79a9a0dd7e6dab2dfafc6a0ccec33dc3685ecaf Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 4 Jan 2026 17:04:27 +0000 Subject: [PATCH 08/20] Make session default clearing ignore explicit undefined --- .../session_set_defaults.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts index 9f8912ce..1d761cd5 100644 --- a/src/mcp/tools/session-management/session_set_defaults.ts +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -84,25 +84,37 @@ export async function sessionSetDefaultsLogic(params: Params): Promise(); - if (Object.prototype.hasOwnProperty.call(nextParams, 'projectPath')) { + if ( + Object.prototype.hasOwnProperty.call(nextParams, 'projectPath') && + nextParams.projectPath !== undefined + ) { toClear.add('workspacePath'); if (current.workspacePath !== undefined) { notices.push('Cleared workspacePath because projectPath was set.'); } } - if (Object.prototype.hasOwnProperty.call(nextParams, 'workspacePath')) { + if ( + Object.prototype.hasOwnProperty.call(nextParams, 'workspacePath') && + nextParams.workspacePath !== undefined + ) { toClear.add('projectPath'); if (current.projectPath !== undefined) { notices.push('Cleared projectPath because workspacePath was set.'); } } - if (Object.prototype.hasOwnProperty.call(nextParams, 'simulatorId')) { + if ( + Object.prototype.hasOwnProperty.call(nextParams, 'simulatorId') && + nextParams.simulatorId !== undefined + ) { toClear.add('simulatorName'); if (current.simulatorName !== undefined) { notices.push('Cleared simulatorName because simulatorId was set.'); } } - if (Object.prototype.hasOwnProperty.call(nextParams, 'simulatorName')) { + if ( + Object.prototype.hasOwnProperty.call(nextParams, 'simulatorName') && + nextParams.simulatorName !== undefined + ) { toClear.add('simulatorId'); if (current.simulatorId !== undefined) { notices.push('Cleared simulatorId because simulatorName was set.'); From c67c0f2e4e2766f9780ac3740a734a226e78d323 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 4 Jan 2026 17:31:07 +0000 Subject: [PATCH 09/20] Generate loaders with .ts internal imports --- build-plugins/plugin-discovery.ts | 6 +- src/core/generated-plugins.ts | 210 +++++++++++++++--------------- src/core/generated-resources.ts | 8 +- 3 files changed, 112 insertions(+), 112 deletions(-) diff --git a/build-plugins/plugin-discovery.ts b/build-plugins/plugin-discovery.ts index 4e507f21..d2a1d971 100644 --- a/build-plugins/plugin-discovery.ts +++ b/build-plugins/plugin-discovery.ts @@ -101,7 +101,7 @@ function generateWorkflowLoader(workflowName: string, toolFiles: string[]): stri const toolImports = toolFiles .map((file, index) => { const toolName = file.replace(/\.(ts|js)$/, ''); - return `const tool_${index} = await import('../mcp/tools/${workflowName}/${toolName}.js').then(m => m.default)`; + return `const tool_${index} = await import('../mcp/tools/${workflowName}/${toolName}.ts').then(m => m.default)`; }) .join(';\n '); @@ -113,7 +113,7 @@ function generateWorkflowLoader(workflowName: string, toolFiles: string[]): stri .join(',\n '); return `async () => { - const { workflow } = await import('../mcp/tools/${workflowName}/index.js'); + const { workflow } = await import('../mcp/tools/${workflowName}/index.ts'); ${toolImports ? toolImports + ';\n ' : ''} return { workflow, @@ -217,7 +217,7 @@ export async function generateResourceLoaders(): Promise { for (const fileName of resourceFiles) { const resourceName = fileName.replace(/\.(ts|js)$/, ''); resourceLoaders[resourceName] = `async () => { - const module = await import('../mcp/resources/${resourceName}.js'); + const module = await import('../mcp/resources/${resourceName}.ts'); return module.default; }`; diff --git a/src/core/generated-plugins.ts b/src/core/generated-plugins.ts index 7c4daf98..256f94d8 100644 --- a/src/core/generated-plugins.ts +++ b/src/core/generated-plugins.ts @@ -4,22 +4,22 @@ // Generated based on filesystem scan export const WORKFLOW_LOADERS = { debugging: async () => { - const { workflow } = await import('../mcp/tools/debugging/index.js'); - const tool_0 = await import('../mcp/tools/debugging/debug_attach_sim.js').then( + const { workflow } = await import('../mcp/tools/debugging/index.ts'); + const tool_0 = await import('../mcp/tools/debugging/debug_attach_sim.ts').then( (m) => m.default, ); - const tool_1 = await import('../mcp/tools/debugging/debug_breakpoint_add.js').then( + const tool_1 = await import('../mcp/tools/debugging/debug_breakpoint_add.ts').then( (m) => m.default, ); - const tool_2 = await import('../mcp/tools/debugging/debug_breakpoint_remove.js').then( + const tool_2 = await import('../mcp/tools/debugging/debug_breakpoint_remove.ts').then( (m) => m.default, ); - const tool_3 = await import('../mcp/tools/debugging/debug_detach.js').then((m) => m.default); - const tool_4 = await import('../mcp/tools/debugging/debug_lldb_command.js').then( + const tool_3 = await import('../mcp/tools/debugging/debug_detach.ts').then((m) => m.default); + const tool_4 = await import('../mcp/tools/debugging/debug_lldb_command.ts').then( (m) => m.default, ); - const tool_5 = await import('../mcp/tools/debugging/debug_stack.js').then((m) => m.default); - const tool_6 = await import('../mcp/tools/debugging/debug_variables.js').then((m) => m.default); + const tool_5 = await import('../mcp/tools/debugging/debug_stack.ts').then((m) => m.default); + const tool_6 = await import('../mcp/tools/debugging/debug_variables.ts').then((m) => m.default); return { workflow, @@ -33,29 +33,29 @@ export const WORKFLOW_LOADERS = { }; }, device: async () => { - const { workflow } = await import('../mcp/tools/device/index.js'); - const tool_0 = await import('../mcp/tools/device/build_device.js').then((m) => m.default); - const tool_1 = await import('../mcp/tools/device/clean.js').then((m) => m.default); - const tool_2 = await import('../mcp/tools/device/discover_projs.js').then((m) => m.default); - const tool_3 = await import('../mcp/tools/device/get_app_bundle_id.js').then((m) => m.default); - const tool_4 = await import('../mcp/tools/device/get_device_app_path.js').then( + const { workflow } = await import('../mcp/tools/device/index.ts'); + const tool_0 = await import('../mcp/tools/device/build_device.ts').then((m) => m.default); + const tool_1 = await import('../mcp/tools/device/clean.ts').then((m) => m.default); + const tool_2 = await import('../mcp/tools/device/discover_projs.ts').then((m) => m.default); + const tool_3 = await import('../mcp/tools/device/get_app_bundle_id.ts').then((m) => m.default); + const tool_4 = await import('../mcp/tools/device/get_device_app_path.ts').then( (m) => m.default, ); - const tool_5 = await import('../mcp/tools/device/install_app_device.js').then((m) => m.default); - const tool_6 = await import('../mcp/tools/device/launch_app_device.js').then((m) => m.default); - const tool_7 = await import('../mcp/tools/device/list_devices.js').then((m) => m.default); - const tool_8 = await import('../mcp/tools/device/list_schemes.js').then((m) => m.default); - const tool_9 = await import('../mcp/tools/device/show_build_settings.js').then( + const tool_5 = await import('../mcp/tools/device/install_app_device.ts').then((m) => m.default); + const tool_6 = await import('../mcp/tools/device/launch_app_device.ts').then((m) => m.default); + const tool_7 = await import('../mcp/tools/device/list_devices.ts').then((m) => m.default); + const tool_8 = await import('../mcp/tools/device/list_schemes.ts').then((m) => m.default); + const tool_9 = await import('../mcp/tools/device/show_build_settings.ts').then( (m) => m.default, ); - const tool_10 = await import('../mcp/tools/device/start_device_log_cap.js').then( + const tool_10 = await import('../mcp/tools/device/start_device_log_cap.ts').then( (m) => m.default, ); - const tool_11 = await import('../mcp/tools/device/stop_app_device.js').then((m) => m.default); - const tool_12 = await import('../mcp/tools/device/stop_device_log_cap.js').then( + const tool_11 = await import('../mcp/tools/device/stop_app_device.ts').then((m) => m.default); + const tool_12 = await import('../mcp/tools/device/stop_device_log_cap.ts').then( (m) => m.default, ); - const tool_13 = await import('../mcp/tools/device/test_device.js').then((m) => m.default); + const tool_13 = await import('../mcp/tools/device/test_device.ts').then((m) => m.default); return { workflow, @@ -76,8 +76,8 @@ export const WORKFLOW_LOADERS = { }; }, doctor: async () => { - const { workflow } = await import('../mcp/tools/doctor/index.js'); - const tool_0 = await import('../mcp/tools/doctor/doctor.js').then((m) => m.default); + const { workflow } = await import('../mcp/tools/doctor/index.ts'); + const tool_0 = await import('../mcp/tools/doctor/doctor.ts').then((m) => m.default); return { workflow, @@ -85,15 +85,15 @@ export const WORKFLOW_LOADERS = { }; }, logging: async () => { - const { workflow } = await import('../mcp/tools/logging/index.js'); - const tool_0 = await import('../mcp/tools/logging/start_device_log_cap.js').then( + const { workflow } = await import('../mcp/tools/logging/index.ts'); + const tool_0 = await import('../mcp/tools/logging/start_device_log_cap.ts').then( (m) => m.default, ); - const tool_1 = await import('../mcp/tools/logging/start_sim_log_cap.js').then((m) => m.default); - const tool_2 = await import('../mcp/tools/logging/stop_device_log_cap.js').then( + const tool_1 = await import('../mcp/tools/logging/start_sim_log_cap.ts').then((m) => m.default); + const tool_2 = await import('../mcp/tools/logging/stop_device_log_cap.ts').then( (m) => m.default, ); - const tool_3 = await import('../mcp/tools/logging/stop_sim_log_cap.js').then((m) => m.default); + const tool_3 = await import('../mcp/tools/logging/stop_sim_log_cap.ts').then((m) => m.default); return { workflow, @@ -104,18 +104,18 @@ export const WORKFLOW_LOADERS = { }; }, macos: async () => { - const { workflow } = await import('../mcp/tools/macos/index.js'); - const tool_0 = await import('../mcp/tools/macos/build_macos.js').then((m) => m.default); - const tool_1 = await import('../mcp/tools/macos/build_run_macos.js').then((m) => m.default); - const tool_2 = await import('../mcp/tools/macos/clean.js').then((m) => m.default); - const tool_3 = await import('../mcp/tools/macos/discover_projs.js').then((m) => m.default); - const tool_4 = await import('../mcp/tools/macos/get_mac_app_path.js').then((m) => m.default); - const tool_5 = await import('../mcp/tools/macos/get_mac_bundle_id.js').then((m) => m.default); - const tool_6 = await import('../mcp/tools/macos/launch_mac_app.js').then((m) => m.default); - const tool_7 = await import('../mcp/tools/macos/list_schemes.js').then((m) => m.default); - const tool_8 = await import('../mcp/tools/macos/show_build_settings.js').then((m) => m.default); - const tool_9 = await import('../mcp/tools/macos/stop_mac_app.js').then((m) => m.default); - const tool_10 = await import('../mcp/tools/macos/test_macos.js').then((m) => m.default); + const { workflow } = await import('../mcp/tools/macos/index.ts'); + const tool_0 = await import('../mcp/tools/macos/build_macos.ts').then((m) => m.default); + const tool_1 = await import('../mcp/tools/macos/build_run_macos.ts').then((m) => m.default); + const tool_2 = await import('../mcp/tools/macos/clean.ts').then((m) => m.default); + const tool_3 = await import('../mcp/tools/macos/discover_projs.ts').then((m) => m.default); + const tool_4 = await import('../mcp/tools/macos/get_mac_app_path.ts').then((m) => m.default); + const tool_5 = await import('../mcp/tools/macos/get_mac_bundle_id.ts').then((m) => m.default); + const tool_6 = await import('../mcp/tools/macos/launch_mac_app.ts').then((m) => m.default); + const tool_7 = await import('../mcp/tools/macos/list_schemes.ts').then((m) => m.default); + const tool_8 = await import('../mcp/tools/macos/show_build_settings.ts').then((m) => m.default); + const tool_9 = await import('../mcp/tools/macos/stop_mac_app.ts').then((m) => m.default); + const tool_10 = await import('../mcp/tools/macos/test_macos.ts').then((m) => m.default); return { workflow, @@ -133,20 +133,20 @@ export const WORKFLOW_LOADERS = { }; }, 'project-discovery': async () => { - const { workflow } = await import('../mcp/tools/project-discovery/index.js'); - const tool_0 = await import('../mcp/tools/project-discovery/discover_projs.js').then( + const { workflow } = await import('../mcp/tools/project-discovery/index.ts'); + const tool_0 = await import('../mcp/tools/project-discovery/discover_projs.ts').then( (m) => m.default, ); - const tool_1 = await import('../mcp/tools/project-discovery/get_app_bundle_id.js').then( + const tool_1 = await import('../mcp/tools/project-discovery/get_app_bundle_id.ts').then( (m) => m.default, ); - const tool_2 = await import('../mcp/tools/project-discovery/get_mac_bundle_id.js').then( + const tool_2 = await import('../mcp/tools/project-discovery/get_mac_bundle_id.ts').then( (m) => m.default, ); - const tool_3 = await import('../mcp/tools/project-discovery/list_schemes.js').then( + const tool_3 = await import('../mcp/tools/project-discovery/list_schemes.ts').then( (m) => m.default, ); - const tool_4 = await import('../mcp/tools/project-discovery/show_build_settings.js').then( + const tool_4 = await import('../mcp/tools/project-discovery/show_build_settings.ts').then( (m) => m.default, ); @@ -160,11 +160,11 @@ export const WORKFLOW_LOADERS = { }; }, 'project-scaffolding': async () => { - const { workflow } = await import('../mcp/tools/project-scaffolding/index.js'); - const tool_0 = await import('../mcp/tools/project-scaffolding/scaffold_ios_project.js').then( + const { workflow } = await import('../mcp/tools/project-scaffolding/index.ts'); + const tool_0 = await import('../mcp/tools/project-scaffolding/scaffold_ios_project.ts').then( (m) => m.default, ); - const tool_1 = await import('../mcp/tools/project-scaffolding/scaffold_macos_project.js').then( + const tool_1 = await import('../mcp/tools/project-scaffolding/scaffold_macos_project.ts').then( (m) => m.default, ); @@ -175,14 +175,14 @@ export const WORKFLOW_LOADERS = { }; }, 'session-management': async () => { - const { workflow } = await import('../mcp/tools/session-management/index.js'); - const tool_0 = await import('../mcp/tools/session-management/session_clear_defaults.js').then( + const { workflow } = await import('../mcp/tools/session-management/index.ts'); + const tool_0 = await import('../mcp/tools/session-management/session_clear_defaults.ts').then( (m) => m.default, ); - const tool_1 = await import('../mcp/tools/session-management/session_set_defaults.js').then( + const tool_1 = await import('../mcp/tools/session-management/session_set_defaults.ts').then( (m) => m.default, ); - const tool_2 = await import('../mcp/tools/session-management/session_show_defaults.js').then( + const tool_2 = await import('../mcp/tools/session-management/session_show_defaults.ts').then( (m) => m.default, ); @@ -194,36 +194,36 @@ export const WORKFLOW_LOADERS = { }; }, simulator: async () => { - const { workflow } = await import('../mcp/tools/simulator/index.js'); - const tool_0 = await import('../mcp/tools/simulator/boot_sim.js').then((m) => m.default); - const tool_1 = await import('../mcp/tools/simulator/build_run_sim.js').then((m) => m.default); - const tool_2 = await import('../mcp/tools/simulator/build_sim.js').then((m) => m.default); - const tool_3 = await import('../mcp/tools/simulator/clean.js').then((m) => m.default); - const tool_4 = await import('../mcp/tools/simulator/describe_ui.js').then((m) => m.default); - const tool_5 = await import('../mcp/tools/simulator/discover_projs.js').then((m) => m.default); - const tool_6 = await import('../mcp/tools/simulator/get_app_bundle_id.js').then( + const { workflow } = await import('../mcp/tools/simulator/index.ts'); + const tool_0 = await import('../mcp/tools/simulator/boot_sim.ts').then((m) => m.default); + const tool_1 = await import('../mcp/tools/simulator/build_run_sim.ts').then((m) => m.default); + const tool_2 = await import('../mcp/tools/simulator/build_sim.ts').then((m) => m.default); + const tool_3 = await import('../mcp/tools/simulator/clean.ts').then((m) => m.default); + const tool_4 = await import('../mcp/tools/simulator/describe_ui.ts').then((m) => m.default); + const tool_5 = await import('../mcp/tools/simulator/discover_projs.ts').then((m) => m.default); + const tool_6 = await import('../mcp/tools/simulator/get_app_bundle_id.ts').then( (m) => m.default, ); - const tool_7 = await import('../mcp/tools/simulator/get_sim_app_path.js').then( + const tool_7 = await import('../mcp/tools/simulator/get_sim_app_path.ts').then( (m) => m.default, ); - const tool_8 = await import('../mcp/tools/simulator/install_app_sim.js').then((m) => m.default); - const tool_9 = await import('../mcp/tools/simulator/launch_app_logs_sim.js').then( + const tool_8 = await import('../mcp/tools/simulator/install_app_sim.ts').then((m) => m.default); + const tool_9 = await import('../mcp/tools/simulator/launch_app_logs_sim.ts').then( (m) => m.default, ); - const tool_10 = await import('../mcp/tools/simulator/launch_app_sim.js').then((m) => m.default); - const tool_11 = await import('../mcp/tools/simulator/list_schemes.js').then((m) => m.default); - const tool_12 = await import('../mcp/tools/simulator/list_sims.js').then((m) => m.default); - const tool_13 = await import('../mcp/tools/simulator/open_sim.js').then((m) => m.default); - const tool_14 = await import('../mcp/tools/simulator/record_sim_video.js').then( + const tool_10 = await import('../mcp/tools/simulator/launch_app_sim.ts').then((m) => m.default); + const tool_11 = await import('../mcp/tools/simulator/list_schemes.ts').then((m) => m.default); + const tool_12 = await import('../mcp/tools/simulator/list_sims.ts').then((m) => m.default); + const tool_13 = await import('../mcp/tools/simulator/open_sim.ts').then((m) => m.default); + const tool_14 = await import('../mcp/tools/simulator/record_sim_video.ts').then( (m) => m.default, ); - const tool_15 = await import('../mcp/tools/simulator/screenshot.js').then((m) => m.default); - const tool_16 = await import('../mcp/tools/simulator/show_build_settings.js').then( + const tool_15 = await import('../mcp/tools/simulator/screenshot.ts').then((m) => m.default); + const tool_16 = await import('../mcp/tools/simulator/show_build_settings.ts').then( (m) => m.default, ); - const tool_17 = await import('../mcp/tools/simulator/stop_app_sim.js').then((m) => m.default); - const tool_18 = await import('../mcp/tools/simulator/test_sim.js').then((m) => m.default); + const tool_17 = await import('../mcp/tools/simulator/stop_app_sim.ts').then((m) => m.default); + const tool_18 = await import('../mcp/tools/simulator/test_sim.ts').then((m) => m.default); return { workflow, @@ -249,29 +249,29 @@ export const WORKFLOW_LOADERS = { }; }, 'simulator-management': async () => { - const { workflow } = await import('../mcp/tools/simulator-management/index.js'); - const tool_0 = await import('../mcp/tools/simulator-management/boot_sim.js').then( + const { workflow } = await import('../mcp/tools/simulator-management/index.ts'); + const tool_0 = await import('../mcp/tools/simulator-management/boot_sim.ts').then( (m) => m.default, ); - const tool_1 = await import('../mcp/tools/simulator-management/erase_sims.js').then( + const tool_1 = await import('../mcp/tools/simulator-management/erase_sims.ts').then( (m) => m.default, ); - const tool_2 = await import('../mcp/tools/simulator-management/list_sims.js').then( + const tool_2 = await import('../mcp/tools/simulator-management/list_sims.ts').then( (m) => m.default, ); - const tool_3 = await import('../mcp/tools/simulator-management/open_sim.js').then( + const tool_3 = await import('../mcp/tools/simulator-management/open_sim.ts').then( (m) => m.default, ); - const tool_4 = await import('../mcp/tools/simulator-management/reset_sim_location.js').then( + const tool_4 = await import('../mcp/tools/simulator-management/reset_sim_location.ts').then( (m) => m.default, ); - const tool_5 = await import('../mcp/tools/simulator-management/set_sim_appearance.js').then( + const tool_5 = await import('../mcp/tools/simulator-management/set_sim_appearance.ts').then( (m) => m.default, ); - const tool_6 = await import('../mcp/tools/simulator-management/set_sim_location.js').then( + const tool_6 = await import('../mcp/tools/simulator-management/set_sim_location.ts').then( (m) => m.default, ); - const tool_7 = await import('../mcp/tools/simulator-management/sim_statusbar.js').then( + const tool_7 = await import('../mcp/tools/simulator-management/sim_statusbar.ts').then( (m) => m.default, ); @@ -288,23 +288,23 @@ export const WORKFLOW_LOADERS = { }; }, 'swift-package': async () => { - const { workflow } = await import('../mcp/tools/swift-package/index.js'); - const tool_0 = await import('../mcp/tools/swift-package/swift_package_build.js').then( + const { workflow } = await import('../mcp/tools/swift-package/index.ts'); + const tool_0 = await import('../mcp/tools/swift-package/swift_package_build.ts').then( (m) => m.default, ); - const tool_1 = await import('../mcp/tools/swift-package/swift_package_clean.js').then( + const tool_1 = await import('../mcp/tools/swift-package/swift_package_clean.ts').then( (m) => m.default, ); - const tool_2 = await import('../mcp/tools/swift-package/swift_package_list.js').then( + const tool_2 = await import('../mcp/tools/swift-package/swift_package_list.ts').then( (m) => m.default, ); - const tool_3 = await import('../mcp/tools/swift-package/swift_package_run.js').then( + const tool_3 = await import('../mcp/tools/swift-package/swift_package_run.ts').then( (m) => m.default, ); - const tool_4 = await import('../mcp/tools/swift-package/swift_package_stop.js').then( + const tool_4 = await import('../mcp/tools/swift-package/swift_package_stop.ts').then( (m) => m.default, ); - const tool_5 = await import('../mcp/tools/swift-package/swift_package_test.js').then( + const tool_5 = await import('../mcp/tools/swift-package/swift_package_test.ts').then( (m) => m.default, ); @@ -319,18 +319,18 @@ export const WORKFLOW_LOADERS = { }; }, 'ui-testing': async () => { - const { workflow } = await import('../mcp/tools/ui-testing/index.js'); - const tool_0 = await import('../mcp/tools/ui-testing/button.js').then((m) => m.default); - const tool_1 = await import('../mcp/tools/ui-testing/describe_ui.js').then((m) => m.default); - const tool_2 = await import('../mcp/tools/ui-testing/gesture.js').then((m) => m.default); - const tool_3 = await import('../mcp/tools/ui-testing/key_press.js').then((m) => m.default); - const tool_4 = await import('../mcp/tools/ui-testing/key_sequence.js').then((m) => m.default); - const tool_5 = await import('../mcp/tools/ui-testing/long_press.js').then((m) => m.default); - const tool_6 = await import('../mcp/tools/ui-testing/screenshot.js').then((m) => m.default); - const tool_7 = await import('../mcp/tools/ui-testing/swipe.js').then((m) => m.default); - const tool_8 = await import('../mcp/tools/ui-testing/tap.js').then((m) => m.default); - const tool_9 = await import('../mcp/tools/ui-testing/touch.js').then((m) => m.default); - const tool_10 = await import('../mcp/tools/ui-testing/type_text.js').then((m) => m.default); + const { workflow } = await import('../mcp/tools/ui-testing/index.ts'); + const tool_0 = await import('../mcp/tools/ui-testing/button.ts').then((m) => m.default); + const tool_1 = await import('../mcp/tools/ui-testing/describe_ui.ts').then((m) => m.default); + const tool_2 = await import('../mcp/tools/ui-testing/gesture.ts').then((m) => m.default); + const tool_3 = await import('../mcp/tools/ui-testing/key_press.ts').then((m) => m.default); + const tool_4 = await import('../mcp/tools/ui-testing/key_sequence.ts').then((m) => m.default); + const tool_5 = await import('../mcp/tools/ui-testing/long_press.ts').then((m) => m.default); + const tool_6 = await import('../mcp/tools/ui-testing/screenshot.ts').then((m) => m.default); + const tool_7 = await import('../mcp/tools/ui-testing/swipe.ts').then((m) => m.default); + const tool_8 = await import('../mcp/tools/ui-testing/tap.ts').then((m) => m.default); + const tool_9 = await import('../mcp/tools/ui-testing/touch.ts').then((m) => m.default); + const tool_10 = await import('../mcp/tools/ui-testing/type_text.ts').then((m) => m.default); return { workflow, @@ -348,8 +348,8 @@ export const WORKFLOW_LOADERS = { }; }, utilities: async () => { - const { workflow } = await import('../mcp/tools/utilities/index.js'); - const tool_0 = await import('../mcp/tools/utilities/clean.js').then((m) => m.default); + const { workflow } = await import('../mcp/tools/utilities/index.ts'); + const tool_0 = await import('../mcp/tools/utilities/clean.ts').then((m) => m.default); return { workflow, diff --git a/src/core/generated-resources.ts b/src/core/generated-resources.ts index 4e2785df..f479e102 100644 --- a/src/core/generated-resources.ts +++ b/src/core/generated-resources.ts @@ -3,19 +3,19 @@ export const RESOURCE_LOADERS = { devices: async () => { - const module = await import('../mcp/resources/devices.js'); + const module = await import('../mcp/resources/devices.ts'); return module.default; }, doctor: async () => { - const module = await import('../mcp/resources/doctor.js'); + const module = await import('../mcp/resources/doctor.ts'); return module.default; }, 'session-status': async () => { - const module = await import('../mcp/resources/session-status.js'); + const module = await import('../mcp/resources/session-status.ts'); return module.default; }, simulators: async () => { - const module = await import('../mcp/resources/simulators.js'); + const module = await import('../mcp/resources/simulators.ts'); return module.default; }, }; From b0b9a449c81a5d0eaeddb0c950fc744f184cf812 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 4 Jan 2026 17:34:52 +0000 Subject: [PATCH 10/20] Clarify debugger tool responses and session status MIME --- src/mcp/resources/__tests__/session-status.test.ts | 2 +- src/mcp/resources/session-status.ts | 2 +- src/mcp/tools/debugging/debug_breakpoint_add.ts | 6 +----- src/mcp/tools/debugging/debug_detach.ts | 2 +- src/mcp/tools/ui-testing/describe_ui.ts | 2 +- src/utils/debugger/ui-automation-guard.ts | 2 +- 6 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/mcp/resources/__tests__/session-status.test.ts b/src/mcp/resources/__tests__/session-status.test.ts index fb9a4b30..433305df 100644 --- a/src/mcp/resources/__tests__/session-status.test.ts +++ b/src/mcp/resources/__tests__/session-status.test.ts @@ -29,7 +29,7 @@ describe('session-status resource', () => { }); it('should export correct mimeType', () => { - expect(sessionStatusResource.mimeType).toBe('text/plain'); + expect(sessionStatusResource.mimeType).toBe('application/json'); }); it('should export handler function', () => { diff --git a/src/mcp/resources/session-status.ts b/src/mcp/resources/session-status.ts index 0ffb6c22..dbe46c78 100644 --- a/src/mcp/resources/session-status.ts +++ b/src/mcp/resources/session-status.ts @@ -37,7 +37,7 @@ export default { uri: 'xcodebuildmcp://session-status', name: 'session-status', description: 'Runtime session state for log capture and debugging', - mimeType: 'text/plain', + mimeType: 'application/json', async handler(): Promise<{ contents: Array<{ text: string }> }> { return sessionStatusResourceLogic(); }, diff --git a/src/mcp/tools/debugging/debug_breakpoint_add.ts b/src/mcp/tools/debugging/debug_breakpoint_add.ts index 02118e0a..0c39adeb 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_add.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_add.ts @@ -43,11 +43,7 @@ export async function debug_breakpoint_addLogic( try { const spec: BreakpointSpec = params.function ? { kind: 'function', name: params.function } - : { kind: 'file-line', file: params.file ?? '', line: params.line ?? 0 }; - - if (spec.kind === 'file-line' && (!spec.file || !spec.line)) { - return createErrorResponse('Invalid breakpoint', 'file and line are required.'); - } + : { kind: 'file-line', file: params.file!, line: params.line! }; const result = await ctx.debugger.addBreakpoint(params.debugSessionId, spec, { condition: params.condition, diff --git a/src/mcp/tools/debugging/debug_detach.ts b/src/mcp/tools/debugging/debug_detach.ts index ce22f85a..4e868383 100644 --- a/src/mcp/tools/debugging/debug_detach.ts +++ b/src/mcp/tools/debugging/debug_detach.ts @@ -22,7 +22,7 @@ export async function debug_detachLogic( ): Promise { try { const targetId = params.debugSessionId ?? ctx.debugger.getCurrentSessionId(); - await ctx.debugger.detachSession(params.debugSessionId); + await ctx.debugger.detachSession(targetId ?? undefined); return createTextResponse(`✅ Detached debugger session${targetId ? ` ${targetId}` : ''}.`); } catch (error) { diff --git a/src/mcp/tools/ui-testing/describe_ui.ts b/src/mcp/tools/ui-testing/describe_ui.ts index be931404..3141fad7 100644 --- a/src/mcp/tools/ui-testing/describe_ui.ts +++ b/src/mcp/tools/ui-testing/describe_ui.ts @@ -35,7 +35,7 @@ export interface AxeHelpers { const LOG_PREFIX = '[AXe]'; // Session tracking for describe_ui warnings (shared across UI tools) -const describeUITimestamps = new Map(); +const describeUITimestamps = new Map(); function recordDescribeUICall(simulatorId: string): void { describeUITimestamps.set(simulatorId, { diff --git a/src/utils/debugger/ui-automation-guard.ts b/src/utils/debugger/ui-automation-guard.ts index bfb2a4f7..d64906ef 100644 --- a/src/utils/debugger/ui-automation-guard.ts +++ b/src/utils/debugger/ui-automation-guard.ts @@ -29,7 +29,7 @@ export async function guardUiAutomationAgainstStoppedDebugger(opts: { state = await opts.debugger.getExecutionState(session.id); } catch (error) { log( - 'debug', + 'warn', `${LOG_PREFIX} ${opts.toolName}: unable to read execution state for ${session.id}: ${String(error)}`, ); return {}; From c35eb5600c601b83cd02498cb9ec39059e024405 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 4 Jan 2026 17:37:25 +0000 Subject: [PATCH 11/20] Align plugin-discovery JS loader imports with TS --- build-plugins/plugin-discovery.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build-plugins/plugin-discovery.js b/build-plugins/plugin-discovery.js index c420f264..4f5f1a5d 100644 --- a/build-plugins/plugin-discovery.js +++ b/build-plugins/plugin-discovery.js @@ -96,7 +96,7 @@ function generateWorkflowLoader(workflowName, toolFiles) { const toolImports = toolFiles .map((file, index) => { const toolName = file.replace(/\.(ts|js)$/, ''); - return `const tool_${index} = await import('../mcp/tools/${workflowName}/${toolName}.js').then(m => m.default)`; + return `const tool_${index} = await import('../mcp/tools/${workflowName}/${toolName}.ts').then(m => m.default)`; }) .join(';\n '); @@ -108,7 +108,7 @@ function generateWorkflowLoader(workflowName, toolFiles) { .join(',\n '); return `async () => { - const { workflow } = await import('../mcp/tools/${workflowName}/index.js'); + const { workflow } = await import('../mcp/tools/${workflowName}/index.ts'); ${toolImports ? toolImports + ';\n ' : ''} return { workflow, @@ -215,7 +215,7 @@ async function generateResourceLoaders() { // Generate dynamic loader for this resource resourceLoaders[resourceName] = `async () => { - const module = await import('../mcp/resources/${resourceName}.js'); + const module = await import('../mcp/resources/${resourceName}.ts'); return module.default; }`; From 3ee1fa4a3eac4c5c484b1c340cba3cb715f3ad63 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 4 Jan 2026 17:39:31 +0000 Subject: [PATCH 12/20] Clean up debugger-related test hygiene --- .../__tests__/start_device_log_cap.test.ts | 5 ---- .../ui-testing/__tests__/describe_ui.test.ts | 4 ---- .../__tests__/debugger-manager-dap.test.ts | 23 +++++++++++-------- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts index ad81a609..7f5ccb78 100644 --- a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts @@ -35,11 +35,6 @@ describe('start_device_log_cap plugin', () => { let mkdirCalls: string[] = []; let writeFileCalls: Array<{ path: string; content: string }> = []; - // Reset state - commandCalls = []; - mkdirCalls = []; - writeFileCalls = []; - const originalJsonWaitEnv = process.env.XBMCP_LAUNCH_JSON_WAIT_MS; beforeEach(() => { diff --git a/src/mcp/tools/ui-testing/__tests__/describe_ui.test.ts b/src/mcp/tools/ui-testing/__tests__/describe_ui.test.ts index e19193ca..aa89e0f1 100644 --- a/src/mcp/tools/ui-testing/__tests__/describe_ui.test.ts +++ b/src/mcp/tools/ui-testing/__tests__/describe_ui.test.ts @@ -9,10 +9,6 @@ import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import describeUIPlugin, { describe_uiLogic } from '../describe_ui.ts'; describe('Describe UI Plugin', () => { - let mockCalls: any[] = []; - - mockCalls = []; - describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(describeUIPlugin.name).toBe('describe_ui'); diff --git a/src/utils/debugger/__tests__/debugger-manager-dap.test.ts b/src/utils/debugger/__tests__/debugger-manager-dap.test.ts index 4b0a2ab5..0f6c6d66 100644 --- a/src/utils/debugger/__tests__/debugger-manager-dap.test.ts +++ b/src/utils/debugger/__tests__/debugger-manager-dap.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import type { BreakpointInfo, BreakpointSpec } from '../types.ts'; import type { DebuggerBackend } from '../backends/DebuggerBackend.ts'; @@ -26,9 +26,20 @@ function createBackend(overrides: Partial = {}): DebuggerBacken } describe('DebuggerManager DAP selection', () => { + const envKey = 'XCODEBUILDMCP_DEBUGGER_BACKEND'; + let prevEnv: string | undefined; + + afterEach(() => { + if (prevEnv === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = prevEnv; + } + }); + it('selects dap backend when env is set', async () => { - const prevEnv = process.env.XCODEBUILDMCP_DEBUGGER_BACKEND; - process.env.XCODEBUILDMCP_DEBUGGER_BACKEND = 'dap'; + prevEnv = process.env[envKey]; + process.env[envKey] = 'dap'; let selected: string | null = null; const backend = createBackend({ kind: 'dap' }); @@ -42,12 +53,6 @@ describe('DebuggerManager DAP selection', () => { await manager.createSession({ simulatorId: 'sim-1', pid: 1000 }); expect(selected).toBe('dap'); - - if (prevEnv === undefined) { - delete process.env.XCODEBUILDMCP_DEBUGGER_BACKEND; - } else { - process.env.XCODEBUILDMCP_DEBUGGER_BACKEND = prevEnv; - } }); it('disposes backend when attach fails without masking error', async () => { From e6c93c5595b62c303bd1f12fff81f421810b3fd7 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 4 Jan 2026 17:44:07 +0000 Subject: [PATCH 13/20] Clean up debugging docs and investigation logs --- docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md | 20 +++++++++---------- docs/DEBUGGING_ARCHITECTURE.md | 6 +++--- .../issue-154-screenshot-downscaling.md | 8 ++++---- ...describe-ui-empty-after-debugger-resume.md | 2 +- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md b/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md index ed8c63a3..19553cc2 100644 --- a/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md +++ b/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md @@ -1,5 +1,3 @@ - - ## Goal & constraints (grounded in current code) 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`). @@ -185,7 +183,7 @@ export class DapTransport { - Backend may still serialize higher-level operations if stateful. **Side effects** -- Adds a long-lived child process per session. +- Add a long-lived child process per session. - Requires careful memory management in the framing buffer (ensure you slice consumed bytes). --- @@ -273,12 +271,12 @@ export async function createDapBackend(opts?: { 4) `configurationDone` if required by lldb-dap behavior (plan for it even if no-op) 5) mark attached -- `detach()`: +- `detach()` - send `disconnect` with `terminateDebuggee: false` (do not kill app) - dispose transport / kill process -- `dispose()`: - - best-effort cleanup; **must not throw** (important because `DebuggerManager.createSession` calls dispose on attach failure) +- `dispose()` + - best-effort cleanup; **must not throw** (important because `DebuggerManager.createSession` calls dispose to clean up on attach failure) **Method mappings (MCP tools → DebuggerManager → DapBackend)** @@ -439,8 +437,8 @@ private enqueue(work: () => Promise): Promise { ... } ``` **Reasoning** -- Prevent races like: - - addBreakpoint + removeBreakpoint in parallel reissuing `setBreakpoints` inconsistently. +- Prevent races such as: + - addBreakpoint + removeBreakpoint in parallel, reissuing `setBreakpoints` inconsistently. --- @@ -453,7 +451,7 @@ private enqueue(work: () => Promise): Promise { ... } ### Where to log - `DapTransport`: - `log('debug', ...)` for raw events (optionally gated by env) - - `log('error', ...)` on process exit while requests pending + - `log('error', ...)` on process exit while requests are pending - `DapBackend`: - minimal `info` logs on attach/detach - `debug` logs for request mapping (command names, not full payloads unless opted in) @@ -514,7 +512,7 @@ New: `src/utils/debugger/__tests__/debugger-manager-dap.test.ts` ## Docs updates (grounded in existing docs) ### 1) Update `docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md` -Replace/extend the existing outline with: +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 @@ -568,6 +566,6 @@ Add a section “DAP Backend (lldb-dap)”: --- ## Critical “don’t miss” requirements -- `dispose()` in DAP backend and transport must be **best-effort and never throw**, because `DebuggerManager.createSession()` will call dispose on attach failure. +- `dispose()` in DAP backend and transport must be **best-effort and never throw** because `DebuggerManager.createSession()` will call dispose on attach failure. - Avoid any use of default executors/spawners in tests; ensure `createDapBackend()` accepts injected `executor` + `spawner`. - Breakpoint removal requires stateful re-application with `setBreakpoints` / `setFunctionBreakpoints`; plan for breakpoint registries from day one. diff --git a/docs/DEBUGGING_ARCHITECTURE.md b/docs/DEBUGGING_ARCHITECTURE.md index 8ff269a2..88abb1fb 100644 --- a/docs/DEBUGGING_ARCHITECTURE.md +++ b/docs/DEBUGGING_ARCHITECTURE.md @@ -190,13 +190,13 @@ Annotated example (simplified): - `XCODEBUILDMCP_LLDB> ` 3. Parser behavior: - Sentinel marks the end of the command output payload. - - Prompt is used to trim the buffer so the next command starts cleanly. + - Prompt is used to trim the buffer, so the next command starts cleanly. ### Output parsing and sanitization - `handleData()` appends to an internal buffer, and `checkPending()` scans for the sentinel regex `/(^|\\r?\\n)__XCODEBUILDMCP_DONE__(\\r?\\n)/`. -- Output is the buffer up to the sentinel. The remainder is trimmed to the next prompt if present. +- Output is the buffer up to the sentinel. The remainder is trimmed to the next prompt, if present. - `sanitizeOutput()` removes prompt echoes, sentinel lines, the `script print(...)` lines, and empty lines, then `runCommand()` returns `trimEnd()` output. @@ -253,7 +253,7 @@ processes. Tests should inject a mock `InteractiveSpawner` into `createLldbCliBa ### xcodebuild (Build/Launch Context) - Debugging assumes a running simulator app. -- The typical flow is build and launch via simulator tools (for example `build_sim`), +- The typical flow is to build and launch via simulator tools (for example `build_sim`), which uses `executeXcodeBuildCommand` to invoke `xcodebuild` (or `xcodemake` when enabled). - After launch, `debug_attach_sim` attaches LLDB to the simulator process. diff --git a/docs/investigations/issue-154-screenshot-downscaling.md b/docs/investigations/issue-154-screenshot-downscaling.md index b7fe4ee9..c38e4464 100644 --- a/docs/investigations/issue-154-screenshot-downscaling.md +++ b/docs/investigations/issue-154-screenshot-downscaling.md @@ -9,25 +9,25 @@ Investigation started; initial context gathered from the issue description. Cont ## Investigation Log -### 2025-01-XX - Initial assessment +### 2026-01-04 - Initial assessment **Hypothesis:** Screenshot pipeline always emits full-resolution images and lacks an opt-in scaling path. **Findings:** Issue describes full-res screenshots and requests optional downscaling. No code inspected yet. **Evidence:** GitHub issue #154 body. **Conclusion:** Needs codebase investigation. -### 2025-01-XX - Context builder attempt +### 2026-01-04 - Context builder attempt **Hypothesis:** Use automated context discovery to map screenshot capture flow. **Findings:** `context_builder` failed due to Gemini CLI usage error in this environment. **Evidence:** Tool error output in session (Gemini CLI usage/help text). **Conclusion:** Proceeding with manual code inspection. -### 2025-01-XX - Screenshot capture implementation +### 2026-01-04 - Screenshot capture implementation **Hypothesis:** Screenshot tool stores and returns full-resolution PNGs. **Findings:** The `screenshot` tool captures a PNG, then immediately downscales/optimizes via `sips` to max 800px width, JPEG format, quality 75%, and returns the JPEG. Optimization is always attempted; on failure it falls back to original PNG. **Evidence:** `src/mcp/tools/ui-testing/screenshot.ts` (sips `-Z 800`, `format jpeg`, `formatOptions 75`). **Conclusion:** The current implementation already downscales by default; the gap is configurability (opt-in/out, size/quality controls) and documentation. -### 2025-01-XX - Git history check +### 2026-01-04 - Git history check **Hypothesis:** Recent commits might have added/changed screenshot optimization behavior. **Findings:** Recent history shows tool annotations and session-awareness changes, but no indication of configurable screenshot scaling. **Evidence:** `git log -n 5 -- src/mcp/tools/ui-testing/screenshot.ts`. diff --git a/docs/investigations/issue-describe-ui-empty-after-debugger-resume.md b/docs/investigations/issue-describe-ui-empty-after-debugger-resume.md index 5baf4ee6..c67ad5ee 100644 --- a/docs/investigations/issue-describe-ui-empty-after-debugger-resume.md +++ b/docs/investigations/issue-describe-ui-empty-after-debugger-resume.md @@ -1,7 +1,7 @@ # RCA: describe_ui returns empty tree after debugger resume ## Summary -When the app is stopped under LLDB (breakpoints hit), the `describe_ui` tool frequently returns an empty accessibility tree (0x0 frame, no children). This is not caused by a short timing gap after resume. The root cause is that the process is still stopped (or immediately re-stopped) due to active breakpoints, so AX snapshotting cannot retrieve a live hierarchy. +When the app is stopped under LLDB (breakpoints hit), the `describe_ui` tool frequently returns an empty accessibility tree (0x0 frame, no children). This is not because of a short timing gap after resume. The root cause is that the process is still stopped (or immediately re-stopped) due to active breakpoints, so AX snapshotting cannot retrieve a live hierarchy. ## Impact - UI automation appears "broken" after resuming from breakpoints. From fd76155db58a3f201a3cbc2d9dca3fa253ac5570 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 4 Jan 2026 20:00:04 +0000 Subject: [PATCH 14/20] Update agents and scripts --- AGENTS.md | 263 +++++++++++---------------------------------------- package.json | 5 +- 2 files changed, 55 insertions(+), 213 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 133c4664..8bc172d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,210 +1,53 @@ -This file provides guidance to AI assisants (Claude Code, Cursor etc) when working with code in this repository. - -## Project Overview - -XcodeBuildMCP is a Model Context Protocol (MCP) server providing standardized tools for AI assistants to interact with Xcode projects, iOS simulators, devices, and Apple development workflows. It's a TypeScript/Node.js project that runs as a stdio-based MCP server. - -## Common Commands - -### Build & Development -```bash -npm run build # Compile TypeScript with tsup, generates version info -npm run dev # Watch mode development -npm run bundle:axe # Bundle axe CLI tool for simulator automation (needed when using local MCP server) -npm run test # Run complete Vitest test suite -npm run test:watch # Watch mode testing -npm run lint # ESLint code checking -npm run lint:fix # ESLint code checking and fixing -npm run format:check # Prettier code checking -npm run format # Prettier code formatting -npm run typecheck # TypeScript type checking -npm run inspect # Run interactive MCP protocol inspector -npm run doctor # Doctor CLI -``` - -### Development with Reloaderoo - -**Reloaderoo** (v1.1.2+) provides CLI-based testing and hot-reload capabilities for XcodeBuildMCP without requiring MCP client configuration. - -#### Quick Start - -**CLI Mode (Testing & Development):** -```bash -# List all tools -npx reloaderoo inspect list-tools -- node build/index.js - -# Call any tool -npx reloaderoo inspect call-tool list_devices --params '{}' -- node build/index.js - -# Get server information -npx reloaderoo inspect server-info -- node build/index.js - -# List and read resources -npx reloaderoo inspect list-resources -- node build/index.js -npx reloaderoo inspect read-resource "xcodebuildmcp://devices" -- node build/index.js -``` - -**Proxy Mode (MCP Client Integration):** -```bash -# Start persistent server for MCP clients -npx reloaderoo proxy -- node build/index.js - -# With debug logging -npx reloaderoo proxy --log-level debug -- node build/index.js - -# Then ask AI: "Please restart the MCP server to load my changes" -``` - -#### All CLI Inspect Commands - -Reloaderoo provides 8 inspect subcommands for comprehensive MCP server testing: - -```bash -# Server capabilities and information -npx reloaderoo inspect server-info -- node build/index.js - -# Tool management -npx reloaderoo inspect list-tools -- node build/index.js -npx reloaderoo inspect call-tool --params '' -- node build/index.js - -# Resource access -npx reloaderoo inspect list-resources -- node build/index.js -npx reloaderoo inspect read-resource "" -- node build/index.js - -# Prompt management -npx reloaderoo inspect list-prompts -- node build/index.js -npx reloaderoo inspect get-prompt --args '' -- node build/index.js - -# Connectivity testing -npx reloaderoo inspect ping -- node build/index.js -``` - -#### Advanced Options - -```bash -# Custom working directory -npx reloaderoo inspect list-tools --working-dir /custom/path -- node build/index.js - -# Timeout configuration -npx reloaderoo inspect call-tool slow_tool --timeout 60000 --params '{}' -- node build/index.js - -# Use timeout configuration if needed -npx reloaderoo inspect server-info --timeout 60000 -- node build/index.js - -# Debug logging (use proxy mode for detailed logging) -npx reloaderoo proxy --log-level debug -- node build/index.js -``` - -#### Key Benefits - -- ✅ **No MCP Client Setup**: Direct CLI access to all tools -- ✅ **Raw JSON Output**: Perfect for AI agents and programmatic use -- ✅ **Hot-Reload Support**: `restart_server` tool for MCP client development -- ✅ **Claude Code Compatible**: Automatic content block consolidation -- ✅ **8 Inspect Commands**: Complete MCP protocol testing capabilities -- ✅ **Universal Compatibility**: Works on any system via npx - -For complete documentation, examples, and troubleshooting, see @docs/dev/RELOADEROO.md - -## Architecture Overview - -### Plugin-Based MCP architecture - -XcodeBuildMCP uses the concept of configuration by convention for MCP exposing and running MCP capabilities like tools and resources. This means to add a new tool or resource, you simply create a new file in the appropriate directory and it will be automatically loaded and exposed to MCP clients. - -#### Tools - -Tools are the core of the MCP server and are the primary way to interact with the server. They are organized into directories by their functionality and are automatically loaded and exposed to MCP clients. - -For more information see @docs/dev/PLUGIN_DEVELOPMENT.md - -#### Resources - -Resources are the secondary way to interact with the server. They are used to provide data to tools and are organized into directories by their functionality and are automatically loaded and exposed to MCP clients. - -For more information see @docs/dev/PLUGIN_DEVELOPMENT.md - -### Tool Registration - -XcodeBuildMCP loads tools at startup. To limit the toolset, set `XCODEBUILDMCP_ENABLED_WORKFLOWS` to a comma-separated list of workflow directory names (for example: `simulator,project-discovery`). The `session-management` workflow is always auto-included since other tools depend on it. - -#### Claude Code Compatibility Workaround -- **Detection**: Automatic detection when running under Claude Code. -- **Purpose**: Workaround for Claude Code's MCP specification violation where it only displays the first content block in tool responses. -- **Behavior**: When Claude Code is detected, multiple content blocks are automatically consolidated into a single text response, separated by `---` dividers. This ensures all information (including test results and stderr warnings) is visible to Claude Code users. - -### Core Architecture Layers -1. **MCP Transport**: stdio protocol communication -2. **Plugin Discovery**: Automatic tool AND resource registration system -3. **MCP Resources**: URI-based data access (e.g., `xcodebuildmcp://simulators`) -4. **Tool Implementation**: Self-contained workflow modules -5. **Shared Utilities**: Command execution, build management, validation -6. **Types**: Shared interfaces and Zod schemas - -For more information see @docs/dev/ARCHITECTURE.md - -## Testing - -The project enforces a strict **Dependency Injection (DI)** testing philosophy. - -- **NO Vitest Mocking**: The use of `vi.mock()`, `vi.fn()`, `vi.spyOn()`, etc., is **completely banned**. -- **Executors**: All external interactions (like running commands or accessing the file system) are handled through injectable "executors". - - `CommandExecutor`: For running shell commands. - - `FileSystemExecutor`: For file system operations. -- **Testing Logic**: Tests import the core `...Logic` function from a tool file and pass in a mock executor (`createMockExecutor` or `createMockFileSystemExecutor`) to simulate different outcomes. - -This approach ensures that tests are robust, easy to maintain, and verify the actual integration between components without being tightly coupled to implementation details. - -For complete guidelines, refer to @docs/dev/TESTING.md. - -## TypeScript Import Standards - -This project uses **TypeScript file extensions** (`.ts`) for all relative imports to ensure compatibility with native TypeScript runtimes. - -### Import Rules - -- ✅ **Use `.ts` extensions**: `import { tool } from './tool.ts'` -- ✅ **Use `.ts` for re-exports**: `export { default } from '../shared/tool.ts'` -- ✅ **External packages use `.js`**: `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'` -- ❌ **Never use `.js` for internal files**: `import { tool } from './tool.js'` ← ESLint error - -### Benefits - -1. **Future-proof**: Compatible with native TypeScript runtimes (Bun, Deno, Node.js --loader) -2. **IDE Experience**: Direct navigation to source TypeScript files -3. **Consistency**: Import path matches the actual file you're editing -4. **Modern Standard**: Aligns with TypeScript 4.7+ `allowImportingTsExtensions` - -### ESLint Enforcement - -The project automatically enforces this standard: - -```bash -npm run lint # Will catch .js imports for internal files -``` - -This ensures all new code follows the `.ts` import pattern and maintains compatibility with both current and future TypeScript execution environments. - -## Release Process - -Follow standardized development workflow with feature branches, structured pull requests, and linear commit history. **Never push to main directly or force push without permission.** - -For complete guidelines, refer to @docs/dev/RELEASE_PROCESS.md - -## Useful external resources - -### Model Context Protocol - -https://modelcontextprotocol.io/llms-full.txt - -### MCP Specification - -https://modelcontextprotocol.io/specification - -### MCP Inspector - -https://github.com/modelcontextprotocol/inspector - -### MCP Client SDKs - -https://github.com/modelcontextprotocol/typescript-sdk +# Development Rules + +## Code Quality +- No `any` types unless absolutely necessary +- Check node_modules for external API type definitions instead of guessing +- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports. +- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead +- Always ask before removing functionality or code that appears to be intentional +- Follow TypeScript best practices + +## Commands +- NEVER commit unless user asks + +## GitHub +When reading issues: +- Always read all comments on the issue +- +## Tools +- GitHub CLI for issues/PRs +- +## Style +- Keep answers short and concise +- No emojis in commits, issues, PR comments, or code +- No fluff or cheerful filler text +- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!") + +## Docs +- If modifying or adding/removing tools run `npm run docs:update` to update the TOOLS.md file, never edit this file directly. +- +### Changelog +Location: `CHANGELOG.md` + +#### Format +Use these sections under `## [Unreleased]`: +- `### Added` - New features +- `### Changed` - Changes to existing functionality +- `### Fixed` - Bug fixes +- `### Removed` - Removed features +- +#### Rules +- Before adding entries, read the full `[Unreleased]` section to see which subsections already exist +- New entries ALWAYS go under `## [Unreleased]` section +- Append to existing subsections (e.g., `### Fixed`), do not create duplicates +- NEVER modify already-released version sections (e.g., `## [0.12.2]`) +- Each version section is immutable once released +- +#### Attribution +- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/cameroncook/XcodeBuildMCP/issues/123))` +- **External contributions**: `Added feature X ([#456](https://github.com/cameroncook/XcodeBuildMCP/pull/456) by [@username](https://github.com/username))` + +## **CRITICAL** Tool Usage Rules **CRITICAL** +- NEVER use sed/cat to read a file or a range of a file. Always use the native read tool. +- You MUST read every file you modify in full before editing. diff --git a/package.json b/package.json index 62068c26..0d44a015 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,7 @@ "xcodebuildmcp-doctor": "build/doctor-cli.js" }, "scripts": { - "build": "npm run build:tsup && npm run build:smithery", - "build:smithery": "npx smithery build src/smithery.ts", + "build": "npm run build:tsup && npx smithery build", "dev": "npm run generate:version && npm run generate:loaders && npx smithery dev", "build:tsup": "npm run generate:version && npm run generate:loaders && tsup", "dev:tsup": "npm run build:tsup && tsup --watch", @@ -99,4 +98,4 @@ "vitest": "^3.2.4", "xcode": "^3.0.1" } -} +} \ No newline at end of file From 29323d4d650324894d6ad800132312ebf79b1997 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 4 Jan 2026 21:06:49 +0000 Subject: [PATCH 15/20] Add debug continue and auto-resume on attach --- CHANGELOG.md | 15 +++++++ docs/TOOLS.md | 9 ++-- .../issue-debugger-attach-stopped.md | 38 ++++++++++++++++ src/core/generated-plugins.ts | 18 ++++---- src/mcp/tools/debugging/debug_attach_sim.ts | 28 +++++++++++- src/mcp/tools/debugging/debug_continue.ts | 43 +++++++++++++++++++ .../__tests__/debugger-manager-dap.test.ts | 1 + .../debugger/backends/DebuggerBackend.ts | 1 + src/utils/debugger/backends/dap-backend.ts | 16 +++++++ .../debugger/backends/lldb-cli-backend.ts | 10 +++++ src/utils/debugger/debugger-manager.ts | 6 +++ src/utils/debugger/ui-automation-guard.ts | 2 +- 12 files changed, 172 insertions(+), 15 deletions(-) create mode 100644 docs/investigations/issue-debugger-attach-stopped.md create mode 100644 src/mcp/tools/debugging/debug_continue.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b0a3f59..19cf8bc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [Unreleased] +### Added +- Add Smithery support for packaging/distribution. +- Add DAP-based debugger backend and simulator debugging toolset (attach, breakpoints, stack, variables, LLDB command). +- Add session-status MCP resource with session identifiers. +- Add UI automation guard that blocks UI tools when the debugger is paused. + +### Changed +- Migrate to Zod v4. +- Improve session default handling (reconcile mutual exclusivity and ignore explicit undefined clears). + +### Fixed +- Update UI automation guard guidance to point at `debug_continue` when paused. +- Fix tool loading bugs in static tool registration. + ## [1.16.0] - 2025-12-30 - Remove dynamic tool discovery (`discover_tools`) and `XCODEBUILDMCP_DYNAMIC_TOOLS`. Use `XCODEBUILDMCP_ENABLED_WORKFLOWS` to limit startup tool registration. - Add MCP tool annotations to all tools. diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 825d4328..cf08a84a 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,6 +1,6 @@ # XcodeBuildMCP Tools Reference -XcodeBuildMCP provides 70 tools organized into 13 workflow groups for comprehensive Apple development workflows. +XcodeBuildMCP provides 71 tools organized into 13 workflow groups for comprehensive Apple development workflows. ## Workflow Groups @@ -69,11 +69,12 @@ XcodeBuildMCP provides 70 tools organized into 13 workflow groups for comprehens - `session_set_defaults` - Set the session defaults needed by many tools. Most tools require one or more session defaults to be set before they can be used. Agents should set all relevant defaults up front in a single call (e.g., project/workspace, scheme, simulator or device ID, useLatestOS) to avoid iterative prompts; only set the keys your workflow needs. - `session_show_defaults` - Show current session defaults. ### Simulator Debugging (`debugging`) -**Purpose**: Interactive iOS Simulator debugging tools: attach LLDB, manage breakpoints, inspect stack/variables, and run LLDB commands. (7 tools) +**Purpose**: Interactive iOS Simulator debugging tools: attach LLDB, manage breakpoints, inspect stack/variables, and run LLDB commands. (8 tools) - `debug_attach_sim` - Attach LLDB to a running iOS simulator app. Provide bundleId or pid, plus simulator defaults. - `debug_breakpoint_add` - Add a breakpoint by file/line or function name for the active debug session. - `debug_breakpoint_remove` - Remove a breakpoint by id for the active debug session. +- `debug_continue` - Resume execution in the active debug session or a specific debugSessionId. - `debug_detach` - Detach the current debugger session or a specific debugSessionId. - `debug_lldb_command` - Run an arbitrary LLDB command within the active debug session. - `debug_stack` - Return a thread backtrace from the active debug session. @@ -116,9 +117,9 @@ XcodeBuildMCP provides 70 tools organized into 13 workflow groups for comprehens ## Summary Statistics -- **Total Tools**: 70 canonical tools + 22 re-exports = 92 total +- **Total Tools**: 71 canonical tools + 22 re-exports = 93 total - **Workflow Groups**: 13 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-03* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-04* diff --git a/docs/investigations/issue-debugger-attach-stopped.md b/docs/investigations/issue-debugger-attach-stopped.md new file mode 100644 index 00000000..df3e31ac --- /dev/null +++ b/docs/investigations/issue-debugger-attach-stopped.md @@ -0,0 +1,38 @@ +# Investigation: Debugger attaches in stopped state after launch + +## Summary +Reproduced: attaching the debugger leaves the simulator app in a stopped state. UI automation is blocked by the guard because the debugger reports `state=stopped`. The attach flow does not issue any resume/continue, so the process remains paused after attach. + +## Symptoms +- After attaching debugger to Calculator, UI automation taps fail because the app is paused. +- UI guard blocks with `state=stopped` immediately after attach. + +## Investigation Log + +### 2025-02-14 - Repro (CalculatorApp on iPhone 17 simulator) +**Hypothesis:** Attach leaves the process stopped, which triggers the UI automation guard. +**Findings:** `debug_attach_sim` attached to a running CalculatorApp (DAP backend), then `tap` was blocked with `state=stopped`. +**Evidence:** `tap` returned "UI automation blocked: app is paused in debugger" with `state=stopped` and the current debug session ID. +**Conclusion:** Confirmed. + +### 2025-02-14 - Code Review (attach flow) +**Hypothesis:** The attach implementation does not resume the process. +**Findings:** The attach flow never calls any resume/continue primitive. +- `debug_attach_sim` creates a session and returns without resuming. +- DAP backend attach flow (`initialize -> attach -> configurationDone`) has no `continue`. +- LLDB CLI backend uses `process attach --pid` and never `process continue`. +- UI automation guard blocks when state is `stopped`. +**Evidence:** `src/mcp/tools/debugging/debug_attach_sim.ts`, `src/utils/debugger/backends/dap-backend.ts`, `src/utils/debugger/backends/lldb-cli-backend.ts`, `src/utils/debugger/ui-automation-guard.ts`. +**Conclusion:** Confirmed. Stopped state originates from debugger attach semantics, and the tool never resumes. + +## Root Cause +The debugger attach path halts the target process (standard debugger behavior) and there is no subsequent resume/continue step. This leaves the process in `stopped` state, which causes `guardUiAutomationAgainstStoppedDebugger` to block UI tools like `tap`. + +## Recommendations +1. Add a first-class `debug_continue` tool backed by a backend-level `continue()` API to resume without relying on LLDB command evaluation. +2. Add an optional `continueOnAttach` (or `stopOnAttach`) parameter to `debug_attach_sim`, with a default suited for UI automation workflows. +3. Update guard messaging to recommend `debug_continue` (not `debug_lldb_command continue`, which is unreliable on DAP). + +## Preventive Measures +- Document that UI tools require the target process to be running, and that debugger attach may pause execution by default. +- Add a state check or auto-resume option when attaching in automation contexts. diff --git a/src/core/generated-plugins.ts b/src/core/generated-plugins.ts index 256f94d8..b22e2d80 100644 --- a/src/core/generated-plugins.ts +++ b/src/core/generated-plugins.ts @@ -14,22 +14,24 @@ export const WORKFLOW_LOADERS = { const tool_2 = await import('../mcp/tools/debugging/debug_breakpoint_remove.ts').then( (m) => m.default, ); - const tool_3 = await import('../mcp/tools/debugging/debug_detach.ts').then((m) => m.default); - const tool_4 = await import('../mcp/tools/debugging/debug_lldb_command.ts').then( + const tool_3 = await import('../mcp/tools/debugging/debug_continue.ts').then((m) => m.default); + const tool_4 = await import('../mcp/tools/debugging/debug_detach.ts').then((m) => m.default); + const tool_5 = await import('../mcp/tools/debugging/debug_lldb_command.ts').then( (m) => m.default, ); - const tool_5 = await import('../mcp/tools/debugging/debug_stack.ts').then((m) => m.default); - const tool_6 = await import('../mcp/tools/debugging/debug_variables.ts').then((m) => m.default); + const tool_6 = await import('../mcp/tools/debugging/debug_stack.ts').then((m) => m.default); + const tool_7 = await import('../mcp/tools/debugging/debug_variables.ts').then((m) => m.default); return { workflow, debug_attach_sim: tool_0, debug_breakpoint_add: tool_1, debug_breakpoint_remove: tool_2, - debug_detach: tool_3, - debug_lldb_command: tool_4, - debug_stack: tool_5, - debug_variables: tool_6, + debug_continue: tool_3, + debug_detach: tool_4, + debug_lldb_command: tool_5, + debug_stack: tool_6, + debug_variables: tool_7, }; }, device: async () => { diff --git a/src/mcp/tools/debugging/debug_attach_sim.ts b/src/mcp/tools/debugging/debug_attach_sim.ts index bcc0a2f4..77c6f712 100644 --- a/src/mcp/tools/debugging/debug_attach_sim.ts +++ b/src/mcp/tools/debugging/debug_attach_sim.ts @@ -33,6 +33,11 @@ const baseSchemaObject = z.object({ .describe("Bundle identifier of the app to attach (e.g., 'com.example.MyApp')"), pid: z.number().int().positive().optional().describe('Process ID to attach directly'), waitFor: z.boolean().optional().describe('Wait for the process to appear when attaching'), + continueOnAttach: z + .boolean() + .optional() + .default(true) + .describe('Resume execution automatically after attaching (default: true)'), makeCurrent: z .boolean() .optional() @@ -109,20 +114,39 @@ export async function debug_attach_simLogic( debuggerManager.setCurrentSession(session.id); } + const shouldContinue = params.continueOnAttach ?? true; + if (shouldContinue) { + try { + await debuggerManager.resumeSession(session.id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + try { + await debuggerManager.detachSession(session.id); + } catch { + // Best-effort cleanup; keep original resume error. + } + return createErrorResponse('Failed to resume debugger after attach', message); + } + } + const warningText = simResult.warning ? `⚠️ ${simResult.warning}\n\n` : ''; const currentText = isCurrent ? 'This session is now the current debug session.' : 'This session is not set as the current session.'; + const resumeText = shouldContinue + ? 'Execution resumed after attach.' + : 'Execution is paused. Use debug_continue to resume before UI automation.'; const backendLabel = session.backend === 'dap' ? 'DAP debugger' : 'LLDB'; return createTextResponse( `${warningText}✅ Attached ${backendLabel} to simulator process ${pid} (${simulatorId}).\n\n` + `Debug session ID: ${session.id}\n` + - `${currentText}\n\n` + + `${currentText}\n` + + `${resumeText}\n\n` + `Next steps:\n` + `1. debug_breakpoint_add({ debugSessionId: "${session.id}", file: "...", line: 123 })\n` + - `2. debug_lldb_command({ debugSessionId: "${session.id}", command: "continue" })\n` + + `2. debug_continue({ debugSessionId: "${session.id}" })\n` + `3. debug_stack({ debugSessionId: "${session.id}" })`, ); } catch (error) { diff --git a/src/mcp/tools/debugging/debug_continue.ts b/src/mcp/tools/debugging/debug_continue.ts new file mode 100644 index 00000000..c63092ea --- /dev/null +++ b/src/mcp/tools/debugging/debug_continue.ts @@ -0,0 +1,43 @@ +import * as z from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { + getDefaultDebuggerToolContext, + type DebuggerToolContext, +} from '../../../utils/debugger/index.ts'; + +const debugContinueSchema = z.object({ + debugSessionId: z + .string() + .optional() + .describe('Debug session ID to resume (defaults to current session)'), +}); + +export type DebugContinueParams = z.infer; + +export async function debug_continueLogic( + params: DebugContinueParams, + ctx: DebuggerToolContext, +): Promise { + try { + const targetId = params.debugSessionId ?? ctx.debugger.getCurrentSessionId(); + await ctx.debugger.resumeSession(targetId ?? undefined); + + return createTextResponse(`✅ Resumed debugger session${targetId ? ` ${targetId}` : ''}.`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createErrorResponse('Failed to resume debugger', message); + } +} + +export default { + name: 'debug_continue', + description: 'Resume execution in the active debug session or a specific debugSessionId.', + schema: debugContinueSchema.shape, + handler: createTypedToolWithContext( + debugContinueSchema, + debug_continueLogic, + getDefaultDebuggerToolContext, + ), +}; diff --git a/src/utils/debugger/__tests__/debugger-manager-dap.test.ts b/src/utils/debugger/__tests__/debugger-manager-dap.test.ts index 0f6c6d66..f6425290 100644 --- a/src/utils/debugger/__tests__/debugger-manager-dap.test.ts +++ b/src/utils/debugger/__tests__/debugger-manager-dap.test.ts @@ -10,6 +10,7 @@ function createBackend(overrides: Partial = {}): DebuggerBacken attach: async () => {}, detach: async () => {}, runCommand: async () => '', + resume: async () => {}, addBreakpoint: async (spec: BreakpointSpec): Promise => ({ id: 1, spec, diff --git a/src/utils/debugger/backends/DebuggerBackend.ts b/src/utils/debugger/backends/DebuggerBackend.ts index e1fabc36..5e817daa 100644 --- a/src/utils/debugger/backends/DebuggerBackend.ts +++ b/src/utils/debugger/backends/DebuggerBackend.ts @@ -7,6 +7,7 @@ export interface DebuggerBackend { detach(): Promise; runCommand(command: string, opts?: { timeoutMs?: number }): Promise; + resume(opts?: { threadId?: number }): Promise; addBreakpoint(spec: BreakpointSpec, opts?: { condition?: string }): Promise; removeBreakpoint(id: number): Promise; diff --git a/src/utils/debugger/backends/dap-backend.ts b/src/utils/debugger/backends/dap-backend.ts index 8de743b2..74accc2b 100644 --- a/src/utils/debugger/backends/dap-backend.ts +++ b/src/utils/debugger/backends/dap-backend.ts @@ -152,6 +152,22 @@ class DapBackend implements DebuggerBackend { } } + async resume(opts?: { threadId?: number }): Promise { + return this.enqueue(async () => { + this.ensureAttached(); + + let threadId = opts?.threadId; + if (!threadId) { + const thread = await this.resolveThread(); + threadId = thread.id; + } + + await this.request('continue', { threadId }); + this.executionState = { status: 'running' }; + this.lastStoppedThreadId = null; + }); + } + async addBreakpoint( spec: BreakpointSpec, opts?: { condition?: string }, diff --git a/src/utils/debugger/backends/lldb-cli-backend.ts b/src/utils/debugger/backends/lldb-cli-backend.ts index 4a91cc39..c297054a 100644 --- a/src/utils/debugger/backends/lldb-cli-backend.ts +++ b/src/utils/debugger/backends/lldb-cli-backend.ts @@ -83,6 +83,16 @@ class LldbCliBackend implements DebuggerBackend { }); } + async resume(): Promise { + return this.enqueue(async () => { + if (this.disposed) { + throw new Error('LLDB backend disposed'); + } + await this.ready; + this.process.write('process continue\n'); + }); + } + async addBreakpoint( spec: BreakpointSpec, opts?: { condition?: string }, diff --git a/src/utils/debugger/debugger-manager.ts b/src/utils/debugger/debugger-manager.ts index c56ccf54..b9df0451 100644 --- a/src/utils/debugger/debugger-manager.ts +++ b/src/utils/debugger/debugger-manager.ts @@ -182,6 +182,12 @@ export class DebuggerManager { return result; } + async resumeSession(id?: string, opts?: { threadId?: number }): Promise { + const session = this.requireSession(id); + await session.backend.resume(opts); + this.touch(session.info.id); + } + private requireSession(id?: string): { info: DebugSessionInfo; backend: DebuggerBackend } { const session = this.getSession(id); if (!session) { diff --git a/src/utils/debugger/ui-automation-guard.ts b/src/utils/debugger/ui-automation-guard.ts index d64906ef..f6f469af 100644 --- a/src/utils/debugger/ui-automation-guard.ts +++ b/src/utils/debugger/ui-automation-guard.ts @@ -82,7 +82,7 @@ function buildGuardDetails(params: { lines.push( '', - 'Resume execution (continue), remove breakpoints, or detach via debug_detach before using UI tools.', + 'Resume execution via debug_continue, remove breakpoints, or detach via debug_detach before using UI tools.', ); return lines.join('\n'); From 328fabe6c4dda01f619d820e1000b45389e34eb5 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 5 Jan 2026 20:28:41 +0000 Subject: [PATCH 16/20] Add debugging workflow to README --- docs/OVERVIEW.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index b32646e4..2e0262fe 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -13,6 +13,7 @@ XcodeBuildMCP is a Model Context Protocol (MCP) server that exposes Xcode operat - Swift Package Manager build, test, and run. - UI automation, screenshots, and video capture. - Log capture and system diagnostics. +- Debugger attach, breakpoints, stack, variables, and LLDB command execution. See the full tool catalog in [TOOLS.md](TOOLS.md). From 290bd529ccf39480df96c6d424a90471690bd701 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 8 Jan 2026 10:40:54 +0000 Subject: [PATCH 17/20] Remove redundant attach cleanup comment --- src/mcp/tools/debugging/debug_attach_sim.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/mcp/tools/debugging/debug_attach_sim.ts b/src/mcp/tools/debugging/debug_attach_sim.ts index 77c6f712..bda3ec15 100644 --- a/src/mcp/tools/debugging/debug_attach_sim.ts +++ b/src/mcp/tools/debugging/debug_attach_sim.ts @@ -122,8 +122,10 @@ export async function debug_attach_simLogic( const message = error instanceof Error ? error.message : String(error); try { await debuggerManager.detachSession(session.id); - } catch { - // Best-effort cleanup; keep original resume error. + } catch (detachError) { + const detachMessage = + detachError instanceof Error ? detachError.message : String(detachError); + log('warn', `Failed to detach debugger session after resume failure: ${detachMessage}`); } return createErrorResponse('Failed to resume debugger after attach', message); } From a9a04e97a97354bc010b9e64b4a2a515693f76a4 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 8 Jan 2026 13:09:32 +0000 Subject: [PATCH 18/20] Fix doctor and DAP tests --- docs/TOOLS.md | 2 +- src/mcp/tools/doctor/__tests__/doctor.test.ts | 2 + .../dap/__tests__/transport-framing.test.ts | 110 +++++++++++++----- 3 files changed, 86 insertions(+), 28 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index cf08a84a..f3f91f0a 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -122,4 +122,4 @@ XcodeBuildMCP provides 71 tools organized into 13 workflow groups for comprehens --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-04* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-08* diff --git a/src/mcp/tools/doctor/__tests__/doctor.test.ts b/src/mcp/tools/doctor/__tests__/doctor.test.ts index 70f746b5..c7e38625 100644 --- a/src/mcp/tools/doctor/__tests__/doctor.test.ts +++ b/src/mcp/tools/doctor/__tests__/doctor.test.ts @@ -7,9 +7,11 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import doctor, { runDoctor, type DoctorDependencies } from '../doctor.ts'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; function createDeps(overrides?: Partial): DoctorDependencies { const base: DoctorDependencies = { + commandExecutor: createMockExecutor({ output: 'lldb-dap' }), binaryChecker: { async checkBinaryAvailability(binary: string) { // default: all available with generic version diff --git a/src/utils/debugger/dap/__tests__/transport-framing.test.ts b/src/utils/debugger/dap/__tests__/transport-framing.test.ts index dba9f7c5..c779cbb9 100644 --- a/src/utils/debugger/dap/__tests__/transport-framing.test.ts +++ b/src/utils/debugger/dap/__tests__/transport-framing.test.ts @@ -1,11 +1,18 @@ +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import type { InteractiveProcess, InteractiveSpawner } from '../../../execution/index.ts'; import { describe, expect, it } from 'vitest'; import { DapTransport } from '../transport.ts'; import type { DapEvent, DapResponse } from '../types.ts'; -import { - createMockInteractiveSpawner, - type MockInteractiveSession, -} from '../../../../test-utils/mock-executors.ts'; +type TestSession = { + stdout: PassThrough; + stderr: PassThrough; + stdin: PassThrough; + emitExit: (code?: number | null, signal?: NodeJS.Signals | null) => void; + emitError: (error: Error) => void; +}; function encodeMessage(message: Record): string { const payload = JSON.stringify(message); @@ -27,14 +34,73 @@ function buildResponse( }; } +function createTestSpawner(): { spawner: InteractiveSpawner; session: TestSession } { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const stdin = new PassThrough(); + const emitter = new EventEmitter(); + const mockProcess = emitter as unknown as ChildProcess; + const mutableProcess = mockProcess as unknown as { + stdout: PassThrough | null; + stderr: PassThrough | null; + stdin: PassThrough | null; + killed: boolean; + exitCode: number | null; + signalCode: NodeJS.Signals | null; + spawnargs: string[]; + spawnfile: string; + pid: number; + }; + + mutableProcess.stdout = stdout; + mutableProcess.stderr = stderr; + mutableProcess.stdin = stdin; + mutableProcess.killed = false; + mutableProcess.exitCode = null; + mutableProcess.signalCode = null; + mutableProcess.spawnargs = []; + mutableProcess.spawnfile = 'mock'; + mutableProcess.pid = 12345; + mockProcess.kill = ((signal?: NodeJS.Signals): boolean => { + mutableProcess.killed = true; + emitter.emit('exit', 0, signal ?? null); + return true; + }) as ChildProcess['kill']; + + const session: TestSession = { + stdout, + stderr, + stdin, + emitExit: (code = 0, signal = null) => { + emitter.emit('exit', code, signal); + }, + emitError: (error) => { + emitter.emit('error', error); + }, + }; + + const spawner: InteractiveSpawner = (): InteractiveProcess => ({ + process: mockProcess, + write(data: string): void { + stdin.write(data); + }, + kill(signal?: NodeJS.Signals): void { + mockProcess.kill?.(signal); + }, + dispose(): void { + stdout.end(); + stderr.end(); + stdin.end(); + emitter.removeAllListeners(); + }, + }); + + return { spawner, session }; +} + describe('DapTransport framing', () => { it('parses responses across chunk boundaries', async () => { - let session: MockInteractiveSession | null = null; - const spawner = createMockInteractiveSpawner({ - onSpawn: (spawned) => { - session = spawned; - }, - }); + const { spawner, session } = createTestSpawner(); const transport = new DapTransport({ spawner, adapterCommand: ['lldb-dap'] }); @@ -45,20 +111,15 @@ describe('DapTransport framing', () => { ); const response = encodeMessage(buildResponse(1, 'initialize', { ok: true })); - session?.stdout.write(response.slice(0, 12)); - session?.stdout.write(response.slice(12)); + session.stdout.write(response.slice(0, 12)); + session.stdout.write(response.slice(12)); await expect(responsePromise).resolves.toEqual({ ok: true }); transport.dispose(); }); it('handles multiple messages in a single chunk', async () => { - let session: MockInteractiveSession | null = null; - const spawner = createMockInteractiveSpawner({ - onSpawn: (spawned) => { - session = spawned; - }, - }); + const { spawner, session } = createTestSpawner(); const transport = new DapTransport({ spawner, adapterCommand: ['lldb-dap'] }); const events: DapEvent[] = []; @@ -78,7 +139,7 @@ describe('DapTransport framing', () => { }); const responseMessage = encodeMessage(buildResponse(1, 'threads', { ok: true })); - session?.stdout.write(`${eventMessage}${responseMessage}`); + session.stdout.write(`${eventMessage}${responseMessage}`); await expect(responsePromise).resolves.toEqual({ ok: true }); expect(events).toHaveLength(1); @@ -87,12 +148,7 @@ describe('DapTransport framing', () => { }); it('continues after invalid headers', async () => { - let session: MockInteractiveSession | null = null; - const spawner = createMockInteractiveSpawner({ - onSpawn: (spawned) => { - session = spawned; - }, - }); + const { spawner, session } = createTestSpawner(); const transport = new DapTransport({ spawner, adapterCommand: ['lldb-dap'] }); @@ -102,9 +158,9 @@ describe('DapTransport framing', () => { { timeoutMs: 1_000 }, ); - session?.stdout.write('Content-Length: nope\r\n\r\n'); + session.stdout.write('Content-Length: nope\r\n\r\n'); const responseMessage = encodeMessage(buildResponse(1, 'stackTrace', { ok: true })); - session?.stdout.write(responseMessage); + session.stdout.write(responseMessage); await expect(responsePromise).resolves.toEqual({ ok: true }); transport.dispose(); From 44bc90cd73b584f7002c581a932926346a73622a Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 8 Jan 2026 13:22:55 +0000 Subject: [PATCH 19/20] Update configuration workflow list --- docs/CONFIGURATION.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3bb7b93d..a8216adc 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -20,17 +20,19 @@ XcodeBuildMCP is configured through environment variables provided by your MCP c By default, XcodeBuildMCP loads all tools at startup. If you want a smaller tool surface for a specific workflow, set `XCODEBUILDMCP_ENABLED_WORKFLOWS` to a comma-separated list of workflow directory names. The `session-management` workflow is always auto-included since other tools depend on it. **Available workflows:** -- `device` (7 tools) - iOS Device Development -- `simulator` (12 tools) - iOS Simulator Development -- `simulator-management` (5 tools) - Simulator Management -- `swift-package` (6 tools) - Swift Package Manager -- `project-discovery` (5 tools) - Project Discovery -- `macos` (6 tools) - macOS Development -- `ui-testing` (11 tools) - UI Testing & Automation +- `device` (14 tools) - iOS Device Development +- `simulator` (19 tools) - iOS Simulator Development - `logging` (4 tools) - Log Capture & Management +- `macos` (11 tools) - macOS Development +- `project-discovery` (5 tools) - Project Discovery - `project-scaffolding` (2 tools) - Project Scaffolding - `utilities` (1 tool) - Project Utilities +- `session-management` (3 tools) - session-management +- `debugging` (8 tools) - Simulator Debugging +- `simulator-management` (8 tools) - Simulator Management +- `swift-package` (6 tools) - Swift Package Manager - `doctor` (1 tool) - System Doctor +- `ui-testing` (11 tools) - UI Testing & Automation ## Incremental build support From 9bc63acfd9cb95bc2a9e850121e271ad53cfcc8b Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 8 Jan 2026 13:37:05 +0000 Subject: [PATCH 20/20] Remove slop --- src/utils/execution/interactive-process.ts | 4 +--- src/utils/typed-tool-factory.ts | 6 ------ 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/utils/execution/interactive-process.ts b/src/utils/execution/interactive-process.ts index baf796b8..888d551b 100644 --- a/src/utils/execution/interactive-process.ts +++ b/src/utils/execution/interactive-process.ts @@ -73,9 +73,7 @@ function createInteractiveProcess( export function getDefaultInteractiveSpawner(): InteractiveSpawner { if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') { throw new Error( - `🚨 REAL INTERACTIVE SPAWNER DETECTED IN TEST! 🚨\n` + - `This test is trying to spawn a real interactive process.\n` + - `Fix: Inject a mock InteractiveSpawner in your test setup.`, + 'Interactive process spawn blocked in tests. Inject a mock InteractiveSpawner.', ); } diff --git a/src/utils/typed-tool-factory.ts b/src/utils/typed-tool-factory.ts index a3212575..150a9db6 100644 --- a/src/utils/typed-tool-factory.ts +++ b/src/utils/typed-tool-factory.ts @@ -23,11 +23,8 @@ function createValidatedHandler( ): (args: Record) => Promise { return async (args: Record): Promise => { try { - // Runtime validation - the ONLY safe way to cross the type boundary - // This provides both compile-time and runtime type safety const validatedParams = schema.parse(args); - // Now we have guaranteed type safety - no assertions needed! return await logicFunction(validatedParams, getContext()); } catch (error) { if (error instanceof z.ZodError) { @@ -45,9 +42,6 @@ function createValidatedHandler( * Creates a type-safe tool handler that validates parameters at runtime * before passing them to the typed logic function. * - * This is the ONLY safe way to cross the type boundary from the generic - * MCP handler signature to our typed domain logic. - * * @param schema - Zod schema for parameter validation * @param logicFunction - The typed logic function to execute * @param getExecutor - Function to get the command executor (must be provided)