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/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/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; }`; 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/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 diff --git a/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md b/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..19553cc2 --- /dev/null +++ b/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md @@ -0,0 +1,571 @@ +## 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** +- Add 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 to clean up 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 such as: + - 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 are 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 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 + +### 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 new file mode 100644 index 00000000..88abb1fb --- /dev/null +++ b/docs/DEBUGGING_ARCHITECTURE.md @@ -0,0 +1,276 @@ +# 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 (lldb-dap) + +- 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`, 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`. +- 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 + +### 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 internally by the LLDB CLI backend using + `breakpoint modify -c "" ` after creation. + +### xcodebuild (Build/Launch Context) + +- Debugging assumes a running simulator app. +- 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. + +## 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` +- Interactive execution: `src/utils/execution/interactive-process.ts` (used by LLDB CLI backend) +- External commands: `xcrun simctl`, `xcrun lldb`, `xcodebuild` 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). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 962e3cff..f3f91f0a 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 71 tools organized into 13 workflow groups for comprehensive Apple development workflows. ## Workflow Groups @@ -68,6 +68,17 @@ 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. (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. +- `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 +104,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 +117,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**: 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: 2025-12-30* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-08* 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..c38e4464 --- /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 + +### 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. + +### 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. + +### 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. + +### 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`. +**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-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/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..c67ad5ee --- /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 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. +- 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/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift index 38c4929d..91b505e1 100644 --- a/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift +++ b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift @@ -94,6 +94,12 @@ 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) // Error handling 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 diff --git a/src/core/generated-plugins.ts b/src/core/generated-plugins.ts index 0ca52998..b22e2d80 100644 --- a/src/core/generated-plugins.ts +++ b/src/core/generated-plugins.ts @@ -3,30 +3,61 @@ // Generated based on filesystem scan export const WORKFLOW_LOADERS = { + debugging: async () => { + 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.ts').then( + (m) => m.default, + ); + 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_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_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_continue: tool_3, + debug_detach: tool_4, + debug_lldb_command: tool_5, + debug_stack: tool_6, + debug_variables: tool_7, + }; + }, 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, @@ -47,8 +78,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, @@ -56,15 +87,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, @@ -75,18 +106,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, @@ -104,20 +135,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, ); @@ -131,11 +162,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, ); @@ -146,14 +177,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, ); @@ -165,36 +196,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, @@ -220,29 +251,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, ); @@ -259,23 +290,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, ); @@ -290,18 +321,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, @@ -319,8 +350,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, @@ -333,6 +364,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/core/generated-resources.ts b/src/core/generated-resources.ts index 8a1f914c..f479e102 100644 --- a/src/core/generated-resources.ts +++ b/src/core/generated-resources.ts @@ -3,15 +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.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; }, }; 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/resources/__tests__/session-status.test.ts b/src/mcp/resources/__tests__/session-status.test.ts new file mode 100644 index 00000000..433305df --- /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('application/json'); + }); + + 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..dbe46c78 --- /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: 'application/json', + async handler(): Promise<{ contents: Array<{ text: string }> }> { + return sessionStatusResourceLogic(); + }, +}; 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..bda3ec15 --- /dev/null +++ b/src/mcp/tools/debugging/debug_attach_sim.ts @@ -0,0 +1,185 @@ +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'), + continueOnAttach: z + .boolean() + .optional() + .default(true) + .describe('Resume execution automatically after attaching (default: true)'), + 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 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 (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); + } + } + + 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` + + `${resumeText}\n\n` + + `Next steps:\n` + + `1. debug_breakpoint_add({ debugSessionId: "${session.id}", file: "...", line: 123 })\n` + + `2. debug_continue({ debugSessionId: "${session.id}" })\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..0c39adeb --- /dev/null +++ b/src/mcp/tools/debugging/debug_breakpoint_add.ts @@ -0,0 +1,68 @@ +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; + +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! }; + + 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) { + 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_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/mcp/tools/debugging/debug_detach.ts b/src/mcp/tools/debugging/debug_detach.ts new file mode 100644 index 00000000..4e868383 --- /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(targetId ?? undefined); + + 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/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/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/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index d775bf8d..52ea979b 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 || 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 dap)', + }, + }, }, 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/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts index 133062ec..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 @@ -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 = { @@ -37,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/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/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..1d761cd5 100644 --- a/src/mcp/tools/session-management/session_set_defaults.ts +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -5,49 +5,136 @@ 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') && + 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') && + 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') && + 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') && + nextParams.simulatorName !== undefined + ) { + 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: [ 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..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'); @@ -20,7 +16,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 +109,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/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 30a6b491..3141fad7 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, @@ -32,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, { @@ -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', @@ -84,10 +95,15 @@ 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`, }, ], }; + 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) { @@ -116,7 +132,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/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/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/__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/__tests__/debugger-manager-dap.test.ts b/src/utils/debugger/__tests__/debugger-manager-dap.test.ts new file mode 100644 index 00000000..f6425290 --- /dev/null +++ b/src/utils/debugger/__tests__/debugger-manager-dap.test.ts @@ -0,0 +1,82 @@ +import { afterEach, 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 () => '', + resume: async () => {}, + addBreakpoint: async (spec: BreakpointSpec): Promise => ({ + id: 1, + spec, + rawOutput: '', + }), + removeBreakpoint: async () => '', + getStack: async () => '', + getVariables: async () => '', + getExecutionState: async () => ({ status: 'unknown' }), + dispose: async () => {}, + }; + + return { ...base, ...overrides }; +} + +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 () => { + prevEnv = process.env[envKey]; + process.env[envKey] = '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'); + }); + + 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 new file mode 100644 index 00000000..5e817daa --- /dev/null +++ b/src/utils/debugger/backends/DebuggerBackend.ts @@ -0,0 +1,20 @@ +import type { BreakpointInfo, BreakpointSpec, DebugExecutionState } 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; + resume(opts?: { threadId?: number }): Promise; + + addBreakpoint(spec: BreakpointSpec, opts?: { condition?: string }): Promise; + removeBreakpoint(id: number): Promise; + + 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/__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 new file mode 100644 index 00000000..74accc2b --- /dev/null +++ b/src/utils/debugger/backends/dap-backend.ts @@ -0,0 +1,611 @@ +import type { DebuggerBackend } from './DebuggerBackend.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'; +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 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; + + 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 executionState: DebugExecutionState = { status: 'unknown' }; + 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 { + return this.enqueue(async () => { + if (!this.transport) return; + try { + await this.request('disconnect', { terminateDebuggee: false }); + } finally { + this.cleanupTransport(); + } + }); + } + + 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 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 }, + ): 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(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(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(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 getExecutionState(opts?: { timeoutMs?: number }): Promise { + return this.enqueue(async () => { + 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; + 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; + 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; + + 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(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 new file mode 100644 index 00000000..c297054a --- /dev/null +++ b/src/utils/debugger/backends/lldb-cli-backend.ts @@ -0,0 +1,297 @@ +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'; + +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: InteractiveProcess; + 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 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 }, + ): 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}`); + } + + 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, + 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?.maxFrames === 'number') { + command += ` -c ${opts.maxFrames}`; + } + if (typeof opts?.threadIndex === 'number') { + command += ` ${opts.threadIndex}`; + } + 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 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; + 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(COMMAND_SENTINEL_REGEX); + 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'); +} + +function formatConditionForLldb(condition: string): string { + const escaped = condition.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + 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 { + const backend = new LldbCliBackend(spawner); + 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/__tests__/transport-framing.test.ts b/src/utils/debugger/dap/__tests__/transport-framing.test.ts new file mode 100644 index 00000000..c779cbb9 --- /dev/null +++ b/src/utils/debugger/dap/__tests__/transport-framing.test.ts @@ -0,0 +1,168 @@ +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'; +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); + 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, + }; +} + +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 () => { + const { spawner, session } = createTestSpawner(); + + 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 () => { + const { spawner, session } = createTestSpawner(); + + 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 () => { + const { spawner, session } = createTestSpawner(); + + 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..5325d374 --- /dev/null +++ b/src/utils/debugger/dap/adapter-discovery.ts @@ -0,0 +1,33 @@ +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) { + 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.', + 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 new file mode 100644 index 00000000..b9df0451 --- /dev/null +++ b/src/utils/debugger/debugger-manager.ts @@ -0,0 +1,225 @@ +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, + DebugExecutionState, + 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) { + try { + await backend.dispose(); + } catch { + // Best-effort cleanup; keep original attach error. + } + 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; + } + + listSessions(): DebugSessionInfo[] { + return Array.from(this.sessions.values()).map((session) => ({ ...session.info })); + } + + 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 { + 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 { + 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; + } + + async addBreakpoint( + id: string | undefined, + spec: BreakpointSpec, + opts?: { condition?: string }, + ): Promise { + const session = this.requireSession(id); + const result = await session.backend.addBreakpoint(spec, opts); + 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 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, + opts?: { timeoutMs?: number }, + ): Promise { + const session = this.requireSession(id); + const result = await session.backend.runCommand(command, opts); + this.touch(session.info.id); + 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) { + 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 'dap'; + 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..839b9b4e --- /dev/null +++ b/src/utils/debugger/index.ts @@ -0,0 +1,22 @@ +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 { guardUiAutomationAgainstStoppedDebugger } from './ui-automation-guard.ts'; +export type { + BreakpointInfo, + BreakpointSpec, + DebugExecutionState, + DebugExecutionStatus, + 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..3dbb7728 --- /dev/null +++ b/src/utils/debugger/types.ts @@ -0,0 +1,29 @@ +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; +} + +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..f6f469af --- /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( + 'warn', + `${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 via debug_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. 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..888d551b --- /dev/null +++ b/src/utils/execution/interactive-process.ts @@ -0,0 +1,81 @@ +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 { + 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'], + 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( + 'Interactive process spawn blocked in tests. Inject a mock InteractiveSpawner.', + ); + } + + return createInteractiveProcess; +} 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, + }, + }; +} diff --git a/src/utils/typed-tool-factory.ts b/src/utils/typed-tool-factory.ts index 37a72f44..150a9db6 100644 --- a/src/utils/typed-tool-factory.ts +++ b/src/utils/typed-tool-factory.ts @@ -16,31 +16,16 @@ 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 - // 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, getExecutor()); + return await logicFunction(validatedParams, getContext()); } catch (error) { if (error instanceof z.ZodError) { const details = `Invalid parameters:\n${formatZodIssues(error)}`; @@ -53,6 +38,31 @@ export function createTypedTool( }; } +/** + * Creates a type-safe tool handler that validates parameters at runtime + * before passing them to the typed logic function. + * + * @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 +103,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 +212,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)}`;