#
tokens: 48585/50000 19/337 files (page 5/14)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 5 of 14. Use http://codebase.md/cameroncooke/xcodebuildmcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .axe-version
├── .claude
│   └── agents
│       └── xcodebuild-mcp-qa-tester.md
├── .cursor
│   ├── BUGBOT.md
│   └── environment.json
├── .cursorrules
├── .github
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE
│   │   ├── bug_report.yml
│   │   ├── config.yml
│   │   └── feature_request.yml
│   └── workflows
│       ├── ci.yml
│       ├── claude-code-review.yml
│       ├── claude-dispatch.yml
│       ├── claude.yml
│       ├── droid-code-review.yml
│       ├── README.md
│       ├── release.yml
│       └── sentry.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.js
├── .vscode
│   ├── extensions.json
│   ├── launch.json
│   ├── mcp.json
│   ├── settings.json
│   └── tasks.json
├── AGENTS.md
├── banner.png
├── build-plugins
│   ├── plugin-discovery.js
│   ├── plugin-discovery.ts
│   └── tsconfig.json
├── CHANGELOG.md
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── Dockerfile
├── docs
│   ├── ARCHITECTURE.md
│   ├── CODE_QUALITY.md
│   ├── CONTRIBUTING.md
│   ├── ESLINT_TYPE_SAFETY.md
│   ├── MANUAL_TESTING.md
│   ├── NODEJS_2025.md
│   ├── PLUGIN_DEVELOPMENT.md
│   ├── RELEASE_PROCESS.md
│   ├── RELOADEROO_FOR_XCODEBUILDMCP.md
│   ├── RELOADEROO_XCODEBUILDMCP_PRIMER.md
│   ├── RELOADEROO.md
│   ├── session_management_plan.md
│   ├── session-aware-migration-todo.md
│   ├── TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md
│   ├── TESTING.md
│   └── TOOLS.md
├── eslint.config.js
├── example_projects
│   ├── .vscode
│   │   └── launch.json
│   ├── iOS
│   │   ├── .cursor
│   │   │   └── rules
│   │   │       └── errors.mdc
│   │   ├── .vscode
│   │   │   └── settings.json
│   │   ├── Makefile
│   │   ├── MCPTest
│   │   │   ├── Assets.xcassets
│   │   │   │   ├── AccentColor.colorset
│   │   │   │   │   └── Contents.json
│   │   │   │   ├── AppIcon.appiconset
│   │   │   │   │   └── Contents.json
│   │   │   │   └── Contents.json
│   │   │   ├── ContentView.swift
│   │   │   ├── MCPTestApp.swift
│   │   │   └── Preview Content
│   │   │       └── Preview Assets.xcassets
│   │   │           └── Contents.json
│   │   ├── MCPTest.xcodeproj
│   │   │   ├── project.pbxproj
│   │   │   └── xcshareddata
│   │   │       └── xcschemes
│   │   │           └── MCPTest.xcscheme
│   │   └── MCPTestUITests
│   │       └── MCPTestUITests.swift
│   ├── iOS_Calculator
│   │   ├── CalculatorApp
│   │   │   ├── Assets.xcassets
│   │   │   │   ├── AccentColor.colorset
│   │   │   │   │   └── Contents.json
│   │   │   │   ├── AppIcon.appiconset
│   │   │   │   │   └── Contents.json
│   │   │   │   └── Contents.json
│   │   │   ├── CalculatorApp.swift
│   │   │   └── CalculatorApp.xctestplan
│   │   ├── CalculatorApp.xcodeproj
│   │   │   ├── project.pbxproj
│   │   │   └── xcshareddata
│   │   │       └── xcschemes
│   │   │           └── CalculatorApp.xcscheme
│   │   ├── CalculatorApp.xcworkspace
│   │   │   └── contents.xcworkspacedata
│   │   ├── CalculatorAppPackage
│   │   │   ├── .gitignore
│   │   │   ├── Package.swift
│   │   │   ├── Sources
│   │   │   │   └── CalculatorAppFeature
│   │   │   │       ├── BackgroundEffect.swift
│   │   │   │       ├── CalculatorButton.swift
│   │   │   │       ├── CalculatorDisplay.swift
│   │   │   │       ├── CalculatorInputHandler.swift
│   │   │   │       ├── CalculatorService.swift
│   │   │   │       └── ContentView.swift
│   │   │   └── Tests
│   │   │       └── CalculatorAppFeatureTests
│   │   │           └── CalculatorServiceTests.swift
│   │   ├── CalculatorAppTests
│   │   │   └── CalculatorAppTests.swift
│   │   └── Config
│   │       ├── Debug.xcconfig
│   │       ├── Release.xcconfig
│   │       ├── Shared.xcconfig
│   │       └── Tests.xcconfig
│   ├── macOS
│   │   ├── MCPTest
│   │   │   ├── Assets.xcassets
│   │   │   │   ├── AccentColor.colorset
│   │   │   │   │   └── Contents.json
│   │   │   │   ├── AppIcon.appiconset
│   │   │   │   │   └── Contents.json
│   │   │   │   └── Contents.json
│   │   │   ├── ContentView.swift
│   │   │   ├── MCPTest.entitlements
│   │   │   ├── MCPTestApp.swift
│   │   │   └── Preview Content
│   │   │       └── Preview Assets.xcassets
│   │   │           └── Contents.json
│   │   └── MCPTest.xcodeproj
│   │       ├── project.pbxproj
│   │       └── xcshareddata
│   │           └── xcschemes
│   │               └── MCPTest.xcscheme
│   └── spm
│       ├── .gitignore
│       ├── Package.resolved
│       ├── Package.swift
│       ├── Sources
│       │   ├── long-server
│       │   │   └── main.swift
│       │   ├── quick-task
│       │   │   └── main.swift
│       │   ├── spm
│       │   │   └── main.swift
│       │   └── TestLib
│       │       └── TaskManager.swift
│       └── Tests
│           └── TestLibTests
│               └── SimpleTests.swift
├── LICENSE
├── mcp-install-dark.png
├── package-lock.json
├── package.json
├── README.md
├── scripts
│   ├── analysis
│   │   └── tools-analysis.ts
│   ├── bundle-axe.sh
│   ├── check-code-patterns.js
│   ├── release.sh
│   ├── tools-cli.ts
│   └── update-tools-docs.ts
├── server.json
├── smithery.yaml
├── src
│   ├── core
│   │   ├── __tests__
│   │   │   └── resources.test.ts
│   │   ├── dynamic-tools.ts
│   │   ├── plugin-registry.ts
│   │   ├── plugin-types.ts
│   │   └── resources.ts
│   ├── doctor-cli.ts
│   ├── index.ts
│   ├── mcp
│   │   ├── resources
│   │   │   ├── __tests__
│   │   │   │   ├── devices.test.ts
│   │   │   │   ├── doctor.test.ts
│   │   │   │   └── simulators.test.ts
│   │   │   ├── devices.ts
│   │   │   ├── doctor.ts
│   │   │   └── simulators.ts
│   │   └── tools
│   │       ├── device
│   │       │   ├── __tests__
│   │       │   │   ├── build_device.test.ts
│   │       │   │   ├── get_device_app_path.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── install_app_device.test.ts
│   │       │   │   ├── launch_app_device.test.ts
│   │       │   │   ├── list_devices.test.ts
│   │       │   │   ├── re-exports.test.ts
│   │       │   │   ├── stop_app_device.test.ts
│   │       │   │   └── test_device.test.ts
│   │       │   ├── build_device.ts
│   │       │   ├── clean.ts
│   │       │   ├── discover_projs.ts
│   │       │   ├── get_app_bundle_id.ts
│   │       │   ├── get_device_app_path.ts
│   │       │   ├── index.ts
│   │       │   ├── install_app_device.ts
│   │       │   ├── launch_app_device.ts
│   │       │   ├── list_devices.ts
│   │       │   ├── list_schemes.ts
│   │       │   ├── show_build_settings.ts
│   │       │   ├── start_device_log_cap.ts
│   │       │   ├── stop_app_device.ts
│   │       │   ├── stop_device_log_cap.ts
│   │       │   └── test_device.ts
│   │       ├── discovery
│   │       │   ├── __tests__
│   │       │   │   └── discover_tools.test.ts
│   │       │   ├── discover_tools.ts
│   │       │   └── index.ts
│   │       ├── doctor
│   │       │   ├── __tests__
│   │       │   │   ├── doctor.test.ts
│   │       │   │   └── index.test.ts
│   │       │   ├── doctor.ts
│   │       │   ├── index.ts
│   │       │   └── lib
│   │       │       └── doctor.deps.ts
│   │       ├── logging
│   │       │   ├── __tests__
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── start_device_log_cap.test.ts
│   │       │   │   ├── start_sim_log_cap.test.ts
│   │       │   │   ├── stop_device_log_cap.test.ts
│   │       │   │   └── stop_sim_log_cap.test.ts
│   │       │   ├── index.ts
│   │       │   ├── start_device_log_cap.ts
│   │       │   ├── start_sim_log_cap.ts
│   │       │   ├── stop_device_log_cap.ts
│   │       │   └── stop_sim_log_cap.ts
│   │       ├── macos
│   │       │   ├── __tests__
│   │       │   │   ├── build_macos.test.ts
│   │       │   │   ├── build_run_macos.test.ts
│   │       │   │   ├── get_mac_app_path.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── launch_mac_app.test.ts
│   │       │   │   ├── re-exports.test.ts
│   │       │   │   ├── stop_mac_app.test.ts
│   │       │   │   └── test_macos.test.ts
│   │       │   ├── build_macos.ts
│   │       │   ├── build_run_macos.ts
│   │       │   ├── clean.ts
│   │       │   ├── discover_projs.ts
│   │       │   ├── get_mac_app_path.ts
│   │       │   ├── get_mac_bundle_id.ts
│   │       │   ├── index.ts
│   │       │   ├── launch_mac_app.ts
│   │       │   ├── list_schemes.ts
│   │       │   ├── show_build_settings.ts
│   │       │   ├── stop_mac_app.ts
│   │       │   └── test_macos.ts
│   │       ├── project-discovery
│   │       │   ├── __tests__
│   │       │   │   ├── discover_projs.test.ts
│   │       │   │   ├── get_app_bundle_id.test.ts
│   │       │   │   ├── get_mac_bundle_id.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── list_schemes.test.ts
│   │       │   │   └── show_build_settings.test.ts
│   │       │   ├── discover_projs.ts
│   │       │   ├── get_app_bundle_id.ts
│   │       │   ├── get_mac_bundle_id.ts
│   │       │   ├── index.ts
│   │       │   ├── list_schemes.ts
│   │       │   └── show_build_settings.ts
│   │       ├── project-scaffolding
│   │       │   ├── __tests__
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── scaffold_ios_project.test.ts
│   │       │   │   └── scaffold_macos_project.test.ts
│   │       │   ├── index.ts
│   │       │   ├── scaffold_ios_project.ts
│   │       │   └── scaffold_macos_project.ts
│   │       ├── session-management
│   │       │   ├── __tests__
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── session_clear_defaults.test.ts
│   │       │   │   ├── session_set_defaults.test.ts
│   │       │   │   └── session_show_defaults.test.ts
│   │       │   ├── index.ts
│   │       │   ├── session_clear_defaults.ts
│   │       │   ├── session_set_defaults.ts
│   │       │   └── session_show_defaults.ts
│   │       ├── simulator
│   │       │   ├── __tests__
│   │       │   │   ├── boot_sim.test.ts
│   │       │   │   ├── build_run_sim.test.ts
│   │       │   │   ├── build_sim.test.ts
│   │       │   │   ├── get_sim_app_path.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── install_app_sim.test.ts
│   │       │   │   ├── launch_app_logs_sim.test.ts
│   │       │   │   ├── launch_app_sim.test.ts
│   │       │   │   ├── list_sims.test.ts
│   │       │   │   ├── open_sim.test.ts
│   │       │   │   ├── record_sim_video.test.ts
│   │       │   │   ├── screenshot.test.ts
│   │       │   │   ├── stop_app_sim.test.ts
│   │       │   │   └── test_sim.test.ts
│   │       │   ├── boot_sim.ts
│   │       │   ├── build_run_sim.ts
│   │       │   ├── build_sim.ts
│   │       │   ├── clean.ts
│   │       │   ├── describe_ui.ts
│   │       │   ├── discover_projs.ts
│   │       │   ├── get_app_bundle_id.ts
│   │       │   ├── get_sim_app_path.ts
│   │       │   ├── index.ts
│   │       │   ├── install_app_sim.ts
│   │       │   ├── launch_app_logs_sim.ts
│   │       │   ├── launch_app_sim.ts
│   │       │   ├── list_schemes.ts
│   │       │   ├── list_sims.ts
│   │       │   ├── open_sim.ts
│   │       │   ├── record_sim_video.ts
│   │       │   ├── screenshot.ts
│   │       │   ├── show_build_settings.ts
│   │       │   ├── stop_app_sim.ts
│   │       │   └── test_sim.ts
│   │       ├── simulator-management
│   │       │   ├── __tests__
│   │       │   │   ├── erase_sims.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── reset_sim_location.test.ts
│   │       │   │   ├── set_sim_appearance.test.ts
│   │       │   │   ├── set_sim_location.test.ts
│   │       │   │   └── sim_statusbar.test.ts
│   │       │   ├── boot_sim.ts
│   │       │   ├── erase_sims.ts
│   │       │   ├── index.ts
│   │       │   ├── list_sims.ts
│   │       │   ├── open_sim.ts
│   │       │   ├── reset_sim_location.ts
│   │       │   ├── set_sim_appearance.ts
│   │       │   ├── set_sim_location.ts
│   │       │   └── sim_statusbar.ts
│   │       ├── swift-package
│   │       │   ├── __tests__
│   │       │   │   ├── active-processes.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── swift_package_build.test.ts
│   │       │   │   ├── swift_package_clean.test.ts
│   │       │   │   ├── swift_package_list.test.ts
│   │       │   │   ├── swift_package_run.test.ts
│   │       │   │   ├── swift_package_stop.test.ts
│   │       │   │   └── swift_package_test.test.ts
│   │       │   ├── active-processes.ts
│   │       │   ├── index.ts
│   │       │   ├── swift_package_build.ts
│   │       │   ├── swift_package_clean.ts
│   │       │   ├── swift_package_list.ts
│   │       │   ├── swift_package_run.ts
│   │       │   ├── swift_package_stop.ts
│   │       │   └── swift_package_test.ts
│   │       ├── ui-testing
│   │       │   ├── __tests__
│   │       │   │   ├── button.test.ts
│   │       │   │   ├── describe_ui.test.ts
│   │       │   │   ├── gesture.test.ts
│   │       │   │   ├── index.test.ts
│   │       │   │   ├── key_press.test.ts
│   │       │   │   ├── key_sequence.test.ts
│   │       │   │   ├── long_press.test.ts
│   │       │   │   ├── screenshot.test.ts
│   │       │   │   ├── swipe.test.ts
│   │       │   │   ├── tap.test.ts
│   │       │   │   ├── touch.test.ts
│   │       │   │   └── type_text.test.ts
│   │       │   ├── button.ts
│   │       │   ├── describe_ui.ts
│   │       │   ├── gesture.ts
│   │       │   ├── index.ts
│   │       │   ├── key_press.ts
│   │       │   ├── key_sequence.ts
│   │       │   ├── long_press.ts
│   │       │   ├── screenshot.ts
│   │       │   ├── swipe.ts
│   │       │   ├── tap.ts
│   │       │   ├── touch.ts
│   │       │   └── type_text.ts
│   │       └── utilities
│   │           ├── __tests__
│   │           │   ├── clean.test.ts
│   │           │   └── index.test.ts
│   │           ├── clean.ts
│   │           └── index.ts
│   ├── server
│   │   └── server.ts
│   ├── test-utils
│   │   └── mock-executors.ts
│   ├── types
│   │   └── common.ts
│   └── utils
│       ├── __tests__
│       │   ├── build-utils.test.ts
│       │   ├── environment.test.ts
│       │   ├── session-aware-tool-factory.test.ts
│       │   ├── session-store.test.ts
│       │   ├── simulator-utils.test.ts
│       │   ├── test-runner-env-integration.test.ts
│       │   └── typed-tool-factory.test.ts
│       ├── axe
│       │   └── index.ts
│       ├── axe-helpers.ts
│       ├── build
│       │   └── index.ts
│       ├── build-utils.ts
│       ├── capabilities.ts
│       ├── command.ts
│       ├── CommandExecutor.ts
│       ├── environment.ts
│       ├── errors.ts
│       ├── execution
│       │   └── index.ts
│       ├── FileSystemExecutor.ts
│       ├── log_capture.ts
│       ├── log-capture
│       │   └── index.ts
│       ├── logger.ts
│       ├── logging
│       │   └── index.ts
│       ├── plugin-registry
│       │   └── index.ts
│       ├── responses
│       │   └── index.ts
│       ├── schema-helpers.ts
│       ├── sentry.ts
│       ├── session-store.ts
│       ├── simulator-utils.ts
│       ├── template
│       │   └── index.ts
│       ├── template-manager.ts
│       ├── test
│       │   └── index.ts
│       ├── test-common.ts
│       ├── tool-registry.ts
│       ├── typed-tool-factory.ts
│       ├── validation
│       │   └── index.ts
│       ├── validation.ts
│       ├── version
│       │   └── index.ts
│       ├── video_capture.ts
│       ├── video-capture
│       │   └── index.ts
│       ├── xcode.ts
│       ├── xcodemake
│       │   └── index.ts
│       └── xcodemake.ts
├── tsconfig.json
├── tsconfig.test.json
├── tsup.config.ts
└── vitest.config.ts
```

# Files

--------------------------------------------------------------------------------
/src/utils/log_capture.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import * as fs from 'fs';
  2 | import * as path from 'path';
  3 | import * as os from 'os';
  4 | import type { ChildProcess } from 'child_process';
  5 | import { v4 as uuidv4 } from 'uuid';
  6 | import { log } from '../utils/logger.ts';
  7 | import { CommandExecutor, getDefaultCommandExecutor } from './command.ts';
  8 | 
  9 | /**
 10 |  * Log file retention policy:
 11 |  * - Old log files (older than LOG_RETENTION_DAYS) are automatically deleted from the temp directory
 12 |  * - Cleanup runs on every new log capture start
 13 |  */
 14 | const LOG_RETENTION_DAYS = 3;
 15 | const LOG_FILE_PREFIX = 'xcodemcp_sim_log_';
 16 | 
 17 | export interface LogSession {
 18 |   processes: ChildProcess[];
 19 |   logFilePath: string;
 20 |   simulatorUuid: string;
 21 |   bundleId: string;
 22 | }
 23 | 
 24 | export const activeLogSessions: Map<string, LogSession> = new Map();
 25 | 
 26 | /**
 27 |  * Start a log capture session for an iOS simulator.
 28 |  * Returns { sessionId, logFilePath, processes, error? }
 29 |  */
 30 | export async function startLogCapture(
 31 |   params: {
 32 |     simulatorUuid: string;
 33 |     bundleId: string;
 34 |     captureConsole?: boolean;
 35 |     args?: string[];
 36 |   },
 37 |   executor: CommandExecutor = getDefaultCommandExecutor(),
 38 | ): Promise<{ sessionId: string; logFilePath: string; processes: ChildProcess[]; error?: string }> {
 39 |   // Clean up old logs before starting a new session
 40 |   await cleanOldLogs();
 41 | 
 42 |   const { simulatorUuid, bundleId, captureConsole = false, args = [] } = params;
 43 |   const logSessionId = uuidv4();
 44 |   const logFileName = `${LOG_FILE_PREFIX}${logSessionId}.log`;
 45 |   const logFilePath = path.join(os.tmpdir(), logFileName);
 46 | 
 47 |   try {
 48 |     await fs.promises.mkdir(os.tmpdir(), { recursive: true });
 49 |     await fs.promises.writeFile(logFilePath, '');
 50 |     const logStream = fs.createWriteStream(logFilePath, { flags: 'a' });
 51 |     const processes: ChildProcess[] = [];
 52 |     logStream.write('\n--- Log capture for bundle ID: ' + bundleId + ' ---\n');
 53 | 
 54 |     if (captureConsole) {
 55 |       const launchCommand = [
 56 |         'xcrun',
 57 |         'simctl',
 58 |         'launch',
 59 |         '--console-pty',
 60 |         '--terminate-running-process',
 61 |         simulatorUuid,
 62 |         bundleId,
 63 |       ];
 64 |       if (args.length > 0) {
 65 |         launchCommand.push(...args);
 66 |       }
 67 | 
 68 |       const stdoutLogResult = await executor(
 69 |         launchCommand,
 70 |         'Console Log Capture',
 71 |         true, // useShell
 72 |         undefined, // env
 73 |         true, // detached - don't wait for this streaming process to complete
 74 |       );
 75 | 
 76 |       if (!stdoutLogResult.success) {
 77 |         return {
 78 |           sessionId: '',
 79 |           logFilePath: '',
 80 |           processes: [],
 81 |           error: stdoutLogResult.error ?? 'Failed to start console log capture',
 82 |         };
 83 |       }
 84 | 
 85 |       stdoutLogResult.process.stdout?.pipe(logStream);
 86 |       stdoutLogResult.process.stderr?.pipe(logStream);
 87 |       processes.push(stdoutLogResult.process);
 88 |     }
 89 | 
 90 |     const osLogResult = await executor(
 91 |       [
 92 |         'xcrun',
 93 |         'simctl',
 94 |         'spawn',
 95 |         simulatorUuid,
 96 |         'log',
 97 |         'stream',
 98 |         '--level=debug',
 99 |         '--predicate',
100 |         `subsystem == "${bundleId}"`,
101 |       ],
102 |       'OS Log Capture',
103 |       true, // useShell
104 |       undefined, // env
105 |       true, // detached - don't wait for this streaming process to complete
106 |     );
107 | 
108 |     if (!osLogResult.success) {
109 |       return {
110 |         sessionId: '',
111 |         logFilePath: '',
112 |         processes: [],
113 |         error: osLogResult.error ?? 'Failed to start OS log capture',
114 |       };
115 |     }
116 | 
117 |     osLogResult.process.stdout?.pipe(logStream);
118 |     osLogResult.process.stderr?.pipe(logStream);
119 |     processes.push(osLogResult.process);
120 | 
121 |     for (const process of processes) {
122 |       process.on('close', (code) => {
123 |         log('info', `A log capture process for session ${logSessionId} exited with code ${code}.`);
124 |       });
125 |     }
126 | 
127 |     activeLogSessions.set(logSessionId, {
128 |       processes,
129 |       logFilePath,
130 |       simulatorUuid,
131 |       bundleId,
132 |     });
133 | 
134 |     log('info', `Log capture started with session ID: ${logSessionId}`);
135 |     return { sessionId: logSessionId, logFilePath, processes };
136 |   } catch (error) {
137 |     const message = error instanceof Error ? error.message : String(error);
138 |     log('error', `Failed to start log capture: ${message}`);
139 |     return { sessionId: '', logFilePath: '', processes: [], error: message };
140 |   }
141 | }
142 | 
143 | /**
144 |  * Stop a log capture session and retrieve the log content.
145 |  */
146 | export async function stopLogCapture(
147 |   logSessionId: string,
148 | ): Promise<{ logContent: string; error?: string }> {
149 |   const session = activeLogSessions.get(logSessionId);
150 |   if (!session) {
151 |     log('warning', `Log session not found: ${logSessionId}`);
152 |     return { logContent: '', error: `Log capture session not found: ${logSessionId}` };
153 |   }
154 | 
155 |   try {
156 |     log('info', `Attempting to stop log capture session: ${logSessionId}`);
157 |     const logFilePath = session.logFilePath;
158 |     for (const process of session.processes) {
159 |       if (!process.killed && process.exitCode === null) {
160 |         process.kill('SIGTERM');
161 |       }
162 |     }
163 |     activeLogSessions.delete(logSessionId);
164 |     log(
165 |       'info',
166 |       `Log capture session ${logSessionId} stopped. Log file retained at: ${logFilePath}`,
167 |     );
168 |     await fs.promises.access(logFilePath, fs.constants.R_OK);
169 |     const fileContent = await fs.promises.readFile(logFilePath, 'utf-8');
170 |     log('info', `Successfully read log content from ${logFilePath}`);
171 |     return { logContent: fileContent };
172 |   } catch (error) {
173 |     const message = error instanceof Error ? error.message : String(error);
174 |     log('error', `Failed to stop log capture session ${logSessionId}: ${message}`);
175 |     return { logContent: '', error: message };
176 |   }
177 | }
178 | 
179 | /**
180 |  * Deletes log files older than LOG_RETENTION_DAYS from the temp directory.
181 |  * Runs quietly; errors are logged but do not throw.
182 |  */
183 | async function cleanOldLogs(): Promise<void> {
184 |   const tempDir = os.tmpdir();
185 |   let files: string[];
186 |   try {
187 |     files = await fs.promises.readdir(tempDir);
188 |   } catch (err) {
189 |     log(
190 |       'warn',
191 |       `Could not read temp dir for log cleanup: ${err instanceof Error ? err.message : String(err)}`,
192 |     );
193 |     return;
194 |   }
195 |   const now = Date.now();
196 |   const retentionMs = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
197 |   await Promise.all(
198 |     files
199 |       .filter((f) => f.startsWith(LOG_FILE_PREFIX) && f.endsWith('.log'))
200 |       .map(async (f) => {
201 |         const filePath = path.join(tempDir, f);
202 |         try {
203 |           const stat = await fs.promises.stat(filePath);
204 |           if (now - stat.mtimeMs > retentionMs) {
205 |             await fs.promises.unlink(filePath);
206 |             log('info', `Deleted old log file: ${filePath}`);
207 |           }
208 |         } catch (err) {
209 |           log(
210 |             'warn',
211 |             `Error during log cleanup for ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
212 |           );
213 |         }
214 |       }),
215 |   );
216 | }
217 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/macos/get_mac_app_path.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * macOS Shared Plugin: Get macOS App Path (Unified)
  3 |  *
  4 |  * Gets the app bundle path for a macOS application using either a project or workspace.
  5 |  * Accepts mutually exclusive `projectPath` or `workspacePath`.
  6 |  */
  7 | 
  8 | import { z } from 'zod';
  9 | import { ToolResponse } from '../../../types/common.ts';
 10 | import { log } from '../../../utils/logging/index.ts';
 11 | import type { CommandExecutor } from '../../../utils/execution/index.ts';
 12 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
 13 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts';
 14 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts';
 15 | 
 16 | // Unified schema: XOR between projectPath and workspacePath, sharing common options
 17 | const baseOptions = {
 18 |   scheme: z.string().describe('The scheme to use'),
 19 |   configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'),
 20 |   derivedDataPath: z.string().optional().describe('Path to derived data directory'),
 21 |   extraArgs: z.array(z.string()).optional().describe('Additional arguments to pass to xcodebuild'),
 22 |   arch: z
 23 |     .enum(['arm64', 'x86_64'])
 24 |     .optional()
 25 |     .describe('Architecture to build for (arm64 or x86_64). For macOS only.'),
 26 | };
 27 | 
 28 | const baseSchemaObject = z.object({
 29 |   projectPath: z.string().optional().describe('Path to the .xcodeproj file'),
 30 |   workspacePath: z.string().optional().describe('Path to the .xcworkspace file'),
 31 |   ...baseOptions,
 32 | });
 33 | 
 34 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject);
 35 | 
 36 | const publicSchemaObject = baseSchemaObject.omit({
 37 |   projectPath: true,
 38 |   workspacePath: true,
 39 |   scheme: true,
 40 |   configuration: true,
 41 |   arch: true,
 42 | } as const);
 43 | 
 44 | const getMacosAppPathSchema = baseSchema
 45 |   .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, {
 46 |     message: 'Either projectPath or workspacePath is required.',
 47 |   })
 48 |   .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), {
 49 |     message: 'projectPath and workspacePath are mutually exclusive. Provide only one.',
 50 |   });
 51 | 
 52 | // Use z.infer for type safety
 53 | type GetMacosAppPathParams = z.infer<typeof getMacosAppPathSchema>;
 54 | 
 55 | const XcodePlatform = {
 56 |   iOS: 'iOS',
 57 |   watchOS: 'watchOS',
 58 |   tvOS: 'tvOS',
 59 |   visionOS: 'visionOS',
 60 |   iOSSimulator: 'iOS Simulator',
 61 |   watchOSSimulator: 'watchOS Simulator',
 62 |   tvOSSimulator: 'tvOS Simulator',
 63 |   visionOSSimulator: 'visionOS Simulator',
 64 |   macOS: 'macOS',
 65 | };
 66 | 
 67 | export async function get_mac_app_pathLogic(
 68 |   params: GetMacosAppPathParams,
 69 |   executor: CommandExecutor,
 70 | ): Promise<ToolResponse> {
 71 |   const configuration = params.configuration ?? 'Debug';
 72 | 
 73 |   log('info', `Getting app path for scheme ${params.scheme} on platform ${XcodePlatform.macOS}`);
 74 | 
 75 |   try {
 76 |     // Create the command array for xcodebuild with -showBuildSettings option
 77 |     const command = ['xcodebuild', '-showBuildSettings'];
 78 | 
 79 |     // Add the project or workspace
 80 |     if (params.projectPath) {
 81 |       command.push('-project', params.projectPath);
 82 |     } else if (params.workspacePath) {
 83 |       command.push('-workspace', params.workspacePath);
 84 |     } else {
 85 |       // This should never happen due to schema validation
 86 |       throw new Error('Either projectPath or workspacePath is required.');
 87 |     }
 88 | 
 89 |     // Add the scheme and configuration
 90 |     command.push('-scheme', params.scheme);
 91 |     command.push('-configuration', configuration);
 92 | 
 93 |     // Add optional derived data path
 94 |     if (params.derivedDataPath) {
 95 |       command.push('-derivedDataPath', params.derivedDataPath);
 96 |     }
 97 | 
 98 |     // Handle destination for macOS when arch is specified
 99 |     if (params.arch) {
100 |       const destinationString = `platform=macOS,arch=${params.arch}`;
101 |       command.push('-destination', destinationString);
102 |     }
103 | 
104 |     // Add extra arguments if provided
105 |     if (params.extraArgs && Array.isArray(params.extraArgs)) {
106 |       command.push(...params.extraArgs);
107 |     }
108 | 
109 |     // Execute the command directly with executor
110 |     const result = await executor(command, 'Get App Path', true, undefined);
111 | 
112 |     if (!result.success) {
113 |       return {
114 |         content: [
115 |           {
116 |             type: 'text',
117 |             text: `Error: Failed to get macOS app path\nDetails: ${result.error}`,
118 |           },
119 |         ],
120 |         isError: true,
121 |       };
122 |     }
123 | 
124 |     if (!result.output) {
125 |       return {
126 |         content: [
127 |           {
128 |             type: 'text',
129 |             text: 'Error: Failed to get macOS app path\nDetails: Failed to extract build settings output from the result',
130 |           },
131 |         ],
132 |         isError: true,
133 |       };
134 |     }
135 | 
136 |     const buildSettingsOutput = result.output;
137 |     const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m);
138 |     const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m);
139 | 
140 |     if (!builtProductsDirMatch || !fullProductNameMatch) {
141 |       return {
142 |         content: [
143 |           {
144 |             type: 'text',
145 |             text: 'Error: Failed to get macOS app path\nDetails: Could not extract app path from build settings',
146 |           },
147 |         ],
148 |         isError: true,
149 |       };
150 |     }
151 | 
152 |     const builtProductsDir = builtProductsDirMatch[1].trim();
153 |     const fullProductName = fullProductNameMatch[1].trim();
154 |     const appPath = `${builtProductsDir}/${fullProductName}`;
155 | 
156 |     // Include next steps guidance (following workspace pattern)
157 |     const nextStepsText = `Next Steps:
158 | 1. Get bundle ID: get_app_bundle_id({ appPath: "${appPath}" })
159 | 2. Launch app: launch_mac_app({ appPath: "${appPath}" })`;
160 | 
161 |     return {
162 |       content: [
163 |         {
164 |           type: 'text',
165 |           text: `✅ App path retrieved successfully: ${appPath}`,
166 |         },
167 |         {
168 |           type: 'text',
169 |           text: nextStepsText,
170 |         },
171 |       ],
172 |     };
173 |   } catch (error) {
174 |     const errorMessage = error instanceof Error ? error.message : String(error);
175 |     log('error', `Error retrieving app path: ${errorMessage}`);
176 |     return {
177 |       content: [
178 |         {
179 |           type: 'text',
180 |           text: `Error: Failed to get macOS app path\nDetails: ${errorMessage}`,
181 |         },
182 |       ],
183 |       isError: true,
184 |     };
185 |   }
186 | }
187 | 
188 | export default {
189 |   name: 'get_mac_app_path',
190 |   description: 'Retrieves the built macOS app bundle path.',
191 |   schema: publicSchemaObject.shape,
192 |   handler: createSessionAwareTool<GetMacosAppPathParams>({
193 |     internalSchema: getMacosAppPathSchema as unknown as z.ZodType<GetMacosAppPathParams>,
194 |     logicFunction: get_mac_app_pathLogic,
195 |     getExecutor: getDefaultCommandExecutor,
196 |     requirements: [
197 |       { allOf: ['scheme'], message: 'scheme is required' },
198 |       { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
199 |     ],
200 |     exclusivePairs: [['projectPath', 'workspacePath']],
201 |   }),
202 | };
203 | 
```

--------------------------------------------------------------------------------
/src/utils/__tests__/environment.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Unit tests for environment utilities
  3 |  */
  4 | 
  5 | import { describe, it, expect } from 'vitest';
  6 | import { normalizeTestRunnerEnv } from '../environment.ts';
  7 | 
  8 | describe('normalizeTestRunnerEnv', () => {
  9 |   describe('Basic Functionality', () => {
 10 |     it('should add TEST_RUNNER_ prefix to unprefixed keys', () => {
 11 |       const input = { FOO: 'value1', BAR: 'value2' };
 12 |       const result = normalizeTestRunnerEnv(input);
 13 | 
 14 |       expect(result).toEqual({
 15 |         TEST_RUNNER_FOO: 'value1',
 16 |         TEST_RUNNER_BAR: 'value2',
 17 |       });
 18 |     });
 19 | 
 20 |     it('should preserve keys already prefixed with TEST_RUNNER_', () => {
 21 |       const input = { TEST_RUNNER_FOO: 'value1', TEST_RUNNER_BAR: 'value2' };
 22 |       const result = normalizeTestRunnerEnv(input);
 23 | 
 24 |       expect(result).toEqual({
 25 |         TEST_RUNNER_FOO: 'value1',
 26 |         TEST_RUNNER_BAR: 'value2',
 27 |       });
 28 |     });
 29 | 
 30 |     it('should handle mixed prefixed and unprefixed keys', () => {
 31 |       const input = {
 32 |         FOO: 'value1',
 33 |         TEST_RUNNER_BAR: 'value2',
 34 |         BAZ: 'value3',
 35 |       };
 36 |       const result = normalizeTestRunnerEnv(input);
 37 | 
 38 |       expect(result).toEqual({
 39 |         TEST_RUNNER_FOO: 'value1',
 40 |         TEST_RUNNER_BAR: 'value2',
 41 |         TEST_RUNNER_BAZ: 'value3',
 42 |       });
 43 |     });
 44 |   });
 45 | 
 46 |   describe('Edge Cases', () => {
 47 |     it('should handle empty object', () => {
 48 |       const result = normalizeTestRunnerEnv({});
 49 |       expect(result).toEqual({});
 50 |     });
 51 | 
 52 |     it('should handle null/undefined values', () => {
 53 |       const input = {
 54 |         FOO: 'value1',
 55 |         BAR: null as any,
 56 |         BAZ: undefined as any,
 57 |         QUX: 'value4',
 58 |       };
 59 |       const result = normalizeTestRunnerEnv(input);
 60 | 
 61 |       expect(result).toEqual({
 62 |         TEST_RUNNER_FOO: 'value1',
 63 |         TEST_RUNNER_QUX: 'value4',
 64 |       });
 65 |     });
 66 | 
 67 |     it('should handle empty string values', () => {
 68 |       const input = { FOO: '', BAR: 'value2' };
 69 |       const result = normalizeTestRunnerEnv(input);
 70 | 
 71 |       expect(result).toEqual({
 72 |         TEST_RUNNER_FOO: '',
 73 |         TEST_RUNNER_BAR: 'value2',
 74 |       });
 75 |     });
 76 | 
 77 |     it('should handle special characters in keys', () => {
 78 |       const input = {
 79 |         FOO_BAR: 'value1',
 80 |         'FOO-BAR': 'value2',
 81 |         'FOO.BAR': 'value3',
 82 |       };
 83 |       const result = normalizeTestRunnerEnv(input);
 84 | 
 85 |       expect(result).toEqual({
 86 |         TEST_RUNNER_FOO_BAR: 'value1',
 87 |         'TEST_RUNNER_FOO-BAR': 'value2',
 88 |         'TEST_RUNNER_FOO.BAR': 'value3',
 89 |       });
 90 |     });
 91 | 
 92 |     it('should handle special characters in values', () => {
 93 |       const input = {
 94 |         FOO: 'value with spaces',
 95 |         BAR: 'value/with/slashes',
 96 |         BAZ: 'value=with=equals',
 97 |       };
 98 |       const result = normalizeTestRunnerEnv(input);
 99 | 
100 |       expect(result).toEqual({
101 |         TEST_RUNNER_FOO: 'value with spaces',
102 |         TEST_RUNNER_BAR: 'value/with/slashes',
103 |         TEST_RUNNER_BAZ: 'value=with=equals',
104 |       });
105 |     });
106 |   });
107 | 
108 |   describe('Real-world Usage Scenarios', () => {
109 |     it('should handle USE_DEV_MODE scenario from GitHub issue', () => {
110 |       const input = { USE_DEV_MODE: 'YES' };
111 |       const result = normalizeTestRunnerEnv(input);
112 | 
113 |       expect(result).toEqual({
114 |         TEST_RUNNER_USE_DEV_MODE: 'YES',
115 |       });
116 |     });
117 | 
118 |     it('should handle multiple test configuration variables', () => {
119 |       const input = {
120 |         USE_DEV_MODE: 'YES',
121 |         SKIP_ANIMATIONS: '1',
122 |         DEBUG_MODE: 'true',
123 |         TEST_TIMEOUT: '30',
124 |       };
125 |       const result = normalizeTestRunnerEnv(input);
126 | 
127 |       expect(result).toEqual({
128 |         TEST_RUNNER_USE_DEV_MODE: 'YES',
129 |         TEST_RUNNER_SKIP_ANIMATIONS: '1',
130 |         TEST_RUNNER_DEBUG_MODE: 'true',
131 |         TEST_RUNNER_TEST_TIMEOUT: '30',
132 |       });
133 |     });
134 | 
135 |     it('should handle user providing pre-prefixed variables', () => {
136 |       const input = {
137 |         TEST_RUNNER_USE_DEV_MODE: 'YES',
138 |         SKIP_ANIMATIONS: '1',
139 |       };
140 |       const result = normalizeTestRunnerEnv(input);
141 | 
142 |       expect(result).toEqual({
143 |         TEST_RUNNER_USE_DEV_MODE: 'YES',
144 |         TEST_RUNNER_SKIP_ANIMATIONS: '1',
145 |       });
146 |     });
147 | 
148 |     it('should handle boolean-like string values', () => {
149 |       const input = {
150 |         ENABLED: 'true',
151 |         DISABLED: 'false',
152 |         YES_FLAG: 'YES',
153 |         NO_FLAG: 'NO',
154 |       };
155 |       const result = normalizeTestRunnerEnv(input);
156 | 
157 |       expect(result).toEqual({
158 |         TEST_RUNNER_ENABLED: 'true',
159 |         TEST_RUNNER_DISABLED: 'false',
160 |         TEST_RUNNER_YES_FLAG: 'YES',
161 |         TEST_RUNNER_NO_FLAG: 'NO',
162 |       });
163 |     });
164 |   });
165 | 
166 |   describe('Prefix Handling Edge Cases', () => {
167 |     it('should not double-prefix already prefixed keys', () => {
168 |       const input = { TEST_RUNNER_FOO: 'value1' };
169 |       const result = normalizeTestRunnerEnv(input);
170 | 
171 |       expect(result).toEqual({
172 |         TEST_RUNNER_FOO: 'value1',
173 |       });
174 | 
175 |       // Ensure no double prefixing occurred
176 |       expect(result).not.toHaveProperty('TEST_RUNNER_TEST_RUNNER_FOO');
177 |     });
178 | 
179 |     it('should handle partial prefix matches correctly', () => {
180 |       const input = {
181 |         TEST_RUN: 'value1', // Should get prefixed (not TEST_RUNNER_)
182 |         TEST_RUNNER: 'value2', // Should get prefixed (no underscore)
183 |         TEST_RUNNER_FOO: 'value3', // Should not get prefixed
184 |       };
185 |       const result = normalizeTestRunnerEnv(input);
186 | 
187 |       expect(result).toEqual({
188 |         TEST_RUNNER_TEST_RUN: 'value1',
189 |         TEST_RUNNER_TEST_RUNNER: 'value2',
190 |         TEST_RUNNER_FOO: 'value3',
191 |       });
192 |     });
193 | 
194 |     it('should handle case-sensitive prefix detection', () => {
195 |       const input = {
196 |         test_runner_foo: 'value1', // lowercase - should get prefixed
197 |         Test_Runner_Bar: 'value2', // mixed case - should get prefixed
198 |         TEST_RUNNER_BAZ: 'value3', // correct case - should not get prefixed
199 |       };
200 |       const result = normalizeTestRunnerEnv(input);
201 | 
202 |       expect(result).toEqual({
203 |         TEST_RUNNER_test_runner_foo: 'value1',
204 |         TEST_RUNNER_Test_Runner_Bar: 'value2',
205 |         TEST_RUNNER_BAZ: 'value3',
206 |       });
207 |     });
208 |   });
209 | 
210 |   describe('Input Validation', () => {
211 |     it('should handle undefined input gracefully', () => {
212 |       const result = normalizeTestRunnerEnv(undefined as any);
213 |       expect(result).toEqual({});
214 |     });
215 | 
216 |     it('should handle null input gracefully', () => {
217 |       const result = normalizeTestRunnerEnv(null as any);
218 |       expect(result).toEqual({});
219 |     });
220 | 
221 |     it('should preserve original object (immutability)', () => {
222 |       const input = { FOO: 'value1', BAR: 'value2' };
223 |       const originalInput = { ...input };
224 |       const result = normalizeTestRunnerEnv(input);
225 | 
226 |       // Original input should remain unchanged
227 |       expect(input).toEqual(originalInput);
228 | 
229 |       // Result should be different from input
230 |       expect(result).not.toEqual(input);
231 |     });
232 |   });
233 | });
234 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/ui-testing/long_press.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * UI Testing Plugin: Long Press
  3 |  *
  4 |  * Long press at specific coordinates for given duration (ms).
  5 |  * Use describe_ui for precise coordinates (don't guess from screenshots).
  6 |  */
  7 | 
  8 | import { z } from 'zod';
  9 | import { ToolResponse } from '../../../types/common.ts';
 10 | import { log } from '../../../utils/logging/index.ts';
 11 | import {
 12 |   createTextResponse,
 13 |   createErrorResponse,
 14 |   DependencyError,
 15 |   AxeError,
 16 |   SystemError,
 17 | } from '../../../utils/responses/index.ts';
 18 | import type { CommandExecutor } from '../../../utils/execution/index.ts';
 19 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
 20 | import {
 21 |   createAxeNotAvailableResponse,
 22 |   getAxePath,
 23 |   getBundledAxeEnvironment,
 24 | } from '../../../utils/axe/index.ts';
 25 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
 26 | 
 27 | // Define schema as ZodObject
 28 | const longPressSchema = z.object({
 29 |   simulatorUuid: z.string().uuid('Invalid Simulator UUID format'),
 30 |   x: z.number().int('X coordinate for the long press'),
 31 |   y: z.number().int('Y coordinate for the long press'),
 32 |   duration: z.number().positive('Duration of the long press in milliseconds'),
 33 | });
 34 | 
 35 | // Use z.infer for type safety
 36 | type LongPressParams = z.infer<typeof longPressSchema>;
 37 | 
 38 | export interface AxeHelpers {
 39 |   getAxePath: () => string | null;
 40 |   getBundledAxeEnvironment: () => Record<string, string>;
 41 |   createAxeNotAvailableResponse: () => ToolResponse;
 42 | }
 43 | 
 44 | const LOG_PREFIX = '[AXe]';
 45 | 
 46 | export async function long_pressLogic(
 47 |   params: LongPressParams,
 48 |   executor: CommandExecutor,
 49 |   axeHelpers: AxeHelpers = {
 50 |     getAxePath,
 51 |     getBundledAxeEnvironment,
 52 |     createAxeNotAvailableResponse,
 53 |   },
 54 | ): Promise<ToolResponse> {
 55 |   const toolName = 'long_press';
 56 |   const { simulatorUuid, x, y, duration } = params;
 57 |   // AXe uses touch command with --down, --up, and --delay for long press
 58 |   const delayInSeconds = Number(duration) / 1000; // Convert ms to seconds
 59 |   const commandArgs = [
 60 |     'touch',
 61 |     '-x',
 62 |     String(x),
 63 |     '-y',
 64 |     String(y),
 65 |     '--down',
 66 |     '--up',
 67 |     '--delay',
 68 |     String(delayInSeconds),
 69 |   ];
 70 | 
 71 |   log(
 72 |     'info',
 73 |     `${LOG_PREFIX}/${toolName}: Starting for (${x}, ${y}), ${duration}ms on ${simulatorUuid}`,
 74 |   );
 75 | 
 76 |   try {
 77 |     await executeAxeCommand(commandArgs, simulatorUuid, 'touch', executor, axeHelpers);
 78 |     log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`);
 79 | 
 80 |     const warning = getCoordinateWarning(simulatorUuid);
 81 |     const message = `Long press at (${x}, ${y}) for ${duration}ms simulated successfully.`;
 82 | 
 83 |     if (warning) {
 84 |       return createTextResponse(`${message}\n\n${warning}`);
 85 |     }
 86 | 
 87 |     return createTextResponse(message);
 88 |   } catch (error) {
 89 |     log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`);
 90 |     if (error instanceof DependencyError) {
 91 |       return axeHelpers.createAxeNotAvailableResponse();
 92 |     } else if (error instanceof AxeError) {
 93 |       return createErrorResponse(
 94 |         `Failed to simulate long press at (${x}, ${y}): ${error.message}`,
 95 |         error.axeOutput,
 96 |       );
 97 |     } else if (error instanceof SystemError) {
 98 |       return createErrorResponse(
 99 |         `System error executing axe: ${error.message}`,
100 |         error.originalError?.stack,
101 |       );
102 |     }
103 |     return createErrorResponse(
104 |       `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`,
105 |     );
106 |   }
107 | }
108 | 
109 | export default {
110 |   name: 'long_press',
111 |   description:
112 |     "Long press at specific coordinates for given duration (ms). Use describe_ui for precise coordinates (don't guess from screenshots).",
113 |   schema: longPressSchema.shape, // MCP SDK compatibility
114 |   handler: createTypedTool(
115 |     longPressSchema,
116 |     (params: LongPressParams, executor: CommandExecutor) => {
117 |       return long_pressLogic(params, executor, {
118 |         getAxePath,
119 |         getBundledAxeEnvironment,
120 |         createAxeNotAvailableResponse,
121 |       });
122 |     },
123 |     getDefaultCommandExecutor,
124 |   ),
125 | };
126 | 
127 | // Session tracking for describe_ui warnings
128 | interface DescribeUISession {
129 |   timestamp: number;
130 |   simulatorUuid: string;
131 | }
132 | 
133 | const describeUITimestamps = new Map<string, DescribeUISession>();
134 | const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds
135 | 
136 | function getCoordinateWarning(simulatorUuid: string): string | null {
137 |   const session = describeUITimestamps.get(simulatorUuid);
138 |   if (!session) {
139 |     return 'Warning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.';
140 |   }
141 | 
142 |   const timeSinceDescribe = Date.now() - session.timestamp;
143 |   if (timeSinceDescribe > DESCRIBE_UI_WARNING_TIMEOUT) {
144 |     const secondsAgo = Math.round(timeSinceDescribe / 1000);
145 |     return `Warning: describe_ui was last called ${secondsAgo} seconds ago. Consider refreshing UI coordinates with describe_ui instead of using potentially stale coordinates.`;
146 |   }
147 | 
148 |   return null;
149 | }
150 | 
151 | // Helper function for executing axe commands (inlined from src/tools/axe/index.ts)
152 | async function executeAxeCommand(
153 |   commandArgs: string[],
154 |   simulatorUuid: string,
155 |   commandName: string,
156 |   executor: CommandExecutor = getDefaultCommandExecutor(),
157 |   axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse },
158 | ): Promise<void> {
159 |   // Get the appropriate axe binary path
160 |   const axeBinary = axeHelpers.getAxePath();
161 |   if (!axeBinary) {
162 |     throw new DependencyError('AXe binary not found');
163 |   }
164 | 
165 |   // Add --udid parameter to all commands
166 |   const fullArgs = [...commandArgs, '--udid', simulatorUuid];
167 | 
168 |   // Construct the full command array with the axe binary as the first element
169 |   const fullCommand = [axeBinary, ...fullArgs];
170 | 
171 |   try {
172 |     // Determine environment variables for bundled AXe
173 |     const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined;
174 | 
175 |     const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv);
176 | 
177 |     if (!result.success) {
178 |       throw new AxeError(
179 |         `axe command '${commandName}' failed.`,
180 |         commandName,
181 |         result.error ?? result.output,
182 |         simulatorUuid,
183 |       );
184 |     }
185 | 
186 |     // Check for stderr output in successful commands
187 |     if (result.error) {
188 |       log(
189 |         'warn',
190 |         `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`,
191 |       );
192 |     }
193 | 
194 |     // Function now returns void - the calling code creates its own response
195 |   } catch (error) {
196 |     if (error instanceof Error) {
197 |       if (error instanceof AxeError) {
198 |         throw error;
199 |       }
200 | 
201 |       // Otherwise wrap it in a SystemError
202 |       throw new SystemError(`Failed to execute axe command: ${error.message}`, error);
203 |     }
204 | 
205 |     // For any other type of error
206 |     throw new SystemError(`Failed to execute axe command: ${String(error)}`);
207 |   }
208 | }
209 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/ui-testing/touch.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * UI Testing Plugin: Touch
  3 |  *
  4 |  * Perform touch down/up events at specific coordinates.
  5 |  * Use describe_ui for precise coordinates (don't guess from screenshots).
  6 |  */
  7 | 
  8 | import { z } from 'zod';
  9 | import { log } from '../../../utils/logging/index.ts';
 10 | import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts';
 11 | import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts';
 12 | import type { CommandExecutor } from '../../../utils/execution/index.ts';
 13 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
 14 | import {
 15 |   createAxeNotAvailableResponse,
 16 |   getAxePath,
 17 |   getBundledAxeEnvironment,
 18 | } from '../../../utils/axe-helpers.ts';
 19 | import { ToolResponse } from '../../../types/common.ts';
 20 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
 21 | 
 22 | // Define schema as ZodObject
 23 | const touchSchema = z.object({
 24 |   simulatorUuid: z.string().uuid('Invalid Simulator UUID format'),
 25 |   x: z.number().int('X coordinate must be an integer'),
 26 |   y: z.number().int('Y coordinate must be an integer'),
 27 |   down: z.boolean().optional(),
 28 |   up: z.boolean().optional(),
 29 |   delay: z.number().min(0, 'Delay must be non-negative').optional(),
 30 | });
 31 | 
 32 | // Use z.infer for type safety
 33 | type TouchParams = z.infer<typeof touchSchema>;
 34 | 
 35 | interface AxeHelpers {
 36 |   getAxePath: () => string | null;
 37 |   getBundledAxeEnvironment: () => Record<string, string>;
 38 | }
 39 | 
 40 | const LOG_PREFIX = '[AXe]';
 41 | 
 42 | export async function touchLogic(
 43 |   params: TouchParams,
 44 |   executor: CommandExecutor,
 45 |   axeHelpers?: AxeHelpers,
 46 | ): Promise<ToolResponse> {
 47 |   const toolName = 'touch';
 48 | 
 49 |   // Params are already validated by createTypedTool - use directly
 50 |   const { simulatorUuid, x, y, down, up, delay } = params;
 51 | 
 52 |   // Validate that at least one of down or up is specified
 53 |   if (!down && !up) {
 54 |     return createErrorResponse('At least one of "down" or "up" must be true');
 55 |   }
 56 | 
 57 |   const commandArgs = ['touch', '-x', String(x), '-y', String(y)];
 58 |   if (down) {
 59 |     commandArgs.push('--down');
 60 |   }
 61 |   if (up) {
 62 |     commandArgs.push('--up');
 63 |   }
 64 |   if (delay !== undefined) {
 65 |     commandArgs.push('--delay', String(delay));
 66 |   }
 67 | 
 68 |   const actionText = down && up ? 'touch down+up' : down ? 'touch down' : 'touch up';
 69 |   log(
 70 |     'info',
 71 |     `${LOG_PREFIX}/${toolName}: Starting ${actionText} at (${x}, ${y}) on ${simulatorUuid}`,
 72 |   );
 73 | 
 74 |   try {
 75 |     await executeAxeCommand(commandArgs, simulatorUuid, 'touch', executor, axeHelpers);
 76 |     log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`);
 77 | 
 78 |     const warning = getCoordinateWarning(simulatorUuid);
 79 |     const message = `Touch event (${actionText}) at (${x}, ${y}) executed successfully.`;
 80 | 
 81 |     if (warning) {
 82 |       return createTextResponse(`${message}\n\n${warning}`);
 83 |     }
 84 | 
 85 |     return createTextResponse(message);
 86 |   } catch (error) {
 87 |     log(
 88 |       'error',
 89 |       `${LOG_PREFIX}/${toolName}: Failed - ${error instanceof Error ? error.message : String(error)}`,
 90 |     );
 91 |     if (error instanceof DependencyError) {
 92 |       return createAxeNotAvailableResponse();
 93 |     } else if (error instanceof AxeError) {
 94 |       return createErrorResponse(
 95 |         `Failed to execute touch event: ${error.message}`,
 96 |         error.axeOutput,
 97 |       );
 98 |     } else if (error instanceof SystemError) {
 99 |       return createErrorResponse(
100 |         `System error executing axe: ${error.message}`,
101 |         error.originalError?.stack,
102 |       );
103 |     }
104 |     return createErrorResponse(
105 |       `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`,
106 |     );
107 |   }
108 | }
109 | 
110 | export default {
111 |   name: 'touch',
112 |   description:
113 |     "Perform touch down/up events at specific coordinates. Use describe_ui for precise coordinates (don't guess from screenshots).",
114 |   schema: touchSchema.shape, // MCP SDK compatibility
115 |   handler: createTypedTool(touchSchema, touchLogic, getDefaultCommandExecutor),
116 | };
117 | 
118 | // Session tracking for describe_ui warnings
119 | interface DescribeUISession {
120 |   timestamp: number;
121 |   simulatorUuid: string;
122 | }
123 | 
124 | const describeUITimestamps = new Map<string, DescribeUISession>();
125 | const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds
126 | 
127 | function getCoordinateWarning(simulatorUuid: string): string | null {
128 |   const session = describeUITimestamps.get(simulatorUuid);
129 |   if (!session) {
130 |     return 'Warning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.';
131 |   }
132 | 
133 |   const timeSinceDescribe = Date.now() - session.timestamp;
134 |   if (timeSinceDescribe > DESCRIBE_UI_WARNING_TIMEOUT) {
135 |     const secondsAgo = Math.round(timeSinceDescribe / 1000);
136 |     return `Warning: describe_ui was last called ${secondsAgo} seconds ago. Consider refreshing UI coordinates with describe_ui instead of using potentially stale coordinates.`;
137 |   }
138 | 
139 |   return null;
140 | }
141 | 
142 | // Helper function for executing axe commands (inlined from src/tools/axe/index.ts)
143 | async function executeAxeCommand(
144 |   commandArgs: string[],
145 |   simulatorUuid: string,
146 |   commandName: string,
147 |   executor: CommandExecutor = getDefaultCommandExecutor(),
148 |   axeHelpers?: AxeHelpers,
149 | ): Promise<void> {
150 |   // Use injected helpers or default to imported functions
151 |   const helpers = axeHelpers ?? { getAxePath, getBundledAxeEnvironment };
152 | 
153 |   // Get the appropriate axe binary path
154 |   const axeBinary = helpers.getAxePath();
155 |   if (!axeBinary) {
156 |     throw new DependencyError('AXe binary not found');
157 |   }
158 | 
159 |   // Add --udid parameter to all commands
160 |   const fullArgs = [...commandArgs, '--udid', simulatorUuid];
161 | 
162 |   // Construct the full command array with the axe binary as the first element
163 |   const fullCommand = [axeBinary, ...fullArgs];
164 | 
165 |   try {
166 |     // Determine environment variables for bundled AXe
167 |     const axeEnv = axeBinary !== 'axe' ? helpers.getBundledAxeEnvironment() : undefined;
168 | 
169 |     const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv);
170 | 
171 |     if (!result.success) {
172 |       throw new AxeError(
173 |         `axe command '${commandName}' failed.`,
174 |         commandName,
175 |         result.error ?? result.output,
176 |         simulatorUuid,
177 |       );
178 |     }
179 | 
180 |     // Check for stderr output in successful commands
181 |     if (result.error) {
182 |       log(
183 |         'warn',
184 |         `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`,
185 |       );
186 |     }
187 | 
188 |     // Function now returns void - the calling code creates its own response
189 |   } catch (error) {
190 |     if (error instanceof Error) {
191 |       if (error instanceof AxeError) {
192 |         throw error;
193 |       }
194 | 
195 |       // Otherwise wrap it in a SystemError
196 |       throw new SystemError(`Failed to execute axe command: ${error.message}`, error);
197 |     }
198 | 
199 |     // For any other type of error
200 |     throw new SystemError(`Failed to execute axe command: ${String(error)}`);
201 |   }
202 | }
203 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/simulator/list_sims.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { z } from 'zod';
  2 | import type { ToolResponse } from '../../../types/common.ts';
  3 | import { log } from '../../../utils/logging/index.ts';
  4 | import type { CommandExecutor } from '../../../utils/execution/index.ts';
  5 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
  6 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
  7 | 
  8 | // Define schema as ZodObject
  9 | const listSimsSchema = z.object({
 10 |   enabled: z.boolean().optional().describe('Optional flag to enable the listing operation.'),
 11 | });
 12 | 
 13 | // Use z.infer for type safety
 14 | type ListSimsParams = z.infer<typeof listSimsSchema>;
 15 | 
 16 | interface SimulatorDevice {
 17 |   name: string;
 18 |   udid: string;
 19 |   state: string;
 20 |   isAvailable: boolean;
 21 |   runtime?: string;
 22 | }
 23 | 
 24 | interface SimulatorData {
 25 |   devices: Record<string, SimulatorDevice[]>;
 26 | }
 27 | 
 28 | // Parse text output as fallback for Apple simctl JSON bugs (e.g., duplicate runtime IDs)
 29 | function parseTextOutput(textOutput: string): SimulatorDevice[] {
 30 |   const devices: SimulatorDevice[] = [];
 31 |   const lines = textOutput.split('\n');
 32 |   let currentRuntime = '';
 33 | 
 34 |   for (const line of lines) {
 35 |     // Match runtime headers like "-- iOS 26.0 --" or "-- iOS 18.6 --"
 36 |     const runtimeMatch = line.match(/^-- ([\w\s.]+) --$/);
 37 |     if (runtimeMatch) {
 38 |       currentRuntime = runtimeMatch[1];
 39 |       continue;
 40 |     }
 41 | 
 42 |     // Match device lines like "    iPhone 17 Pro (UUID) (Booted)"
 43 |     // UUID pattern is flexible to handle test UUIDs like "test-uuid-123"
 44 |     const deviceMatch = line.match(
 45 |       /^\s+(.+?)\s+\(([^)]+)\)\s+\((Booted|Shutdown|Booting|Shutting Down)\)(\s+\(unavailable.*\))?$/i,
 46 |     );
 47 |     if (deviceMatch && currentRuntime) {
 48 |       const [, name, udid, state, unavailableSuffix] = deviceMatch;
 49 |       const isUnavailable = Boolean(unavailableSuffix);
 50 |       if (!isUnavailable) {
 51 |         devices.push({
 52 |           name: name.trim(),
 53 |           udid,
 54 |           state,
 55 |           isAvailable: true,
 56 |           runtime: currentRuntime,
 57 |         });
 58 |       }
 59 |     }
 60 |   }
 61 | 
 62 |   return devices;
 63 | }
 64 | 
 65 | function isSimulatorData(value: unknown): value is SimulatorData {
 66 |   if (!value || typeof value !== 'object') {
 67 |     return false;
 68 |   }
 69 | 
 70 |   const obj = value as Record<string, unknown>;
 71 |   if (!obj.devices || typeof obj.devices !== 'object') {
 72 |     return false;
 73 |   }
 74 | 
 75 |   const devices = obj.devices as Record<string, unknown>;
 76 |   for (const runtime in devices) {
 77 |     const deviceList = devices[runtime];
 78 |     if (!Array.isArray(deviceList)) {
 79 |       return false;
 80 |     }
 81 | 
 82 |     for (const device of deviceList) {
 83 |       if (!device || typeof device !== 'object') {
 84 |         return false;
 85 |       }
 86 | 
 87 |       const deviceObj = device as Record<string, unknown>;
 88 |       if (
 89 |         typeof deviceObj.name !== 'string' ||
 90 |         typeof deviceObj.udid !== 'string' ||
 91 |         typeof deviceObj.state !== 'string' ||
 92 |         typeof deviceObj.isAvailable !== 'boolean'
 93 |       ) {
 94 |         return false;
 95 |       }
 96 |     }
 97 |   }
 98 | 
 99 |   return true;
100 | }
101 | 
102 | export async function list_simsLogic(
103 |   params: ListSimsParams,
104 |   executor: CommandExecutor,
105 | ): Promise<ToolResponse> {
106 |   log('info', 'Starting xcrun simctl list devices request');
107 | 
108 |   try {
109 |     // Try JSON first for structured data
110 |     const jsonCommand = ['xcrun', 'simctl', 'list', 'devices', '--json'];
111 |     const jsonResult = await executor(jsonCommand, 'List Simulators (JSON)', true);
112 | 
113 |     if (!jsonResult.success) {
114 |       return {
115 |         content: [
116 |           {
117 |             type: 'text',
118 |             text: `Failed to list simulators: ${jsonResult.error}`,
119 |           },
120 |         ],
121 |       };
122 |     }
123 | 
124 |     // Parse JSON output
125 |     let jsonDevices: Record<string, SimulatorDevice[]> = {};
126 |     try {
127 |       const parsedData: unknown = JSON.parse(jsonResult.output);
128 |       if (isSimulatorData(parsedData)) {
129 |         jsonDevices = parsedData.devices;
130 |       }
131 |     } catch {
132 |       log('warn', 'Failed to parse JSON output, falling back to text parsing');
133 |     }
134 | 
135 |     // Fallback to text parsing for Apple simctl bugs (duplicate runtime IDs in iOS 26.0 beta)
136 |     const textCommand = ['xcrun', 'simctl', 'list', 'devices'];
137 |     const textResult = await executor(textCommand, 'List Simulators (Text)', true);
138 | 
139 |     const textDevices = textResult.success ? parseTextOutput(textResult.output) : [];
140 | 
141 |     // Merge JSON and text devices, preferring JSON but adding any missing from text
142 |     const allDevices: Record<string, SimulatorDevice[]> = { ...jsonDevices };
143 |     const jsonUUIDs = new Set<string>();
144 | 
145 |     // Collect all UUIDs from JSON
146 |     for (const runtime in jsonDevices) {
147 |       for (const device of jsonDevices[runtime]) {
148 |         if (device.isAvailable) {
149 |           jsonUUIDs.add(device.udid);
150 |         }
151 |       }
152 |     }
153 | 
154 |     // Add devices from text that aren't in JSON (handles Apple's duplicate runtime ID bug)
155 |     for (const textDevice of textDevices) {
156 |       if (!jsonUUIDs.has(textDevice.udid)) {
157 |         const runtime = textDevice.runtime ?? 'Unknown Runtime';
158 |         if (!allDevices[runtime]) {
159 |           allDevices[runtime] = [];
160 |         }
161 |         allDevices[runtime].push(textDevice);
162 |         log(
163 |           'info',
164 |           `Added missing device from text parsing: ${textDevice.name} (${textDevice.udid})`,
165 |         );
166 |       }
167 |     }
168 | 
169 |     // Format output
170 |     let responseText = 'Available iOS Simulators:\n\n';
171 | 
172 |     for (const runtime in allDevices) {
173 |       const devices = allDevices[runtime].filter((d) => d.isAvailable);
174 | 
175 |       if (devices.length === 0) continue;
176 | 
177 |       responseText += `${runtime}:\n`;
178 | 
179 |       for (const device of devices) {
180 |         responseText += `- ${device.name} (${device.udid})${device.state === 'Booted' ? ' [Booted]' : ''}\n`;
181 |       }
182 | 
183 |       responseText += '\n';
184 |     }
185 | 
186 |     responseText += 'Next Steps:\n';
187 |     responseText += "1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })\n";
188 |     responseText += '2. Open the simulator UI: open_sim({})\n';
189 |     responseText +=
190 |       "3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })\n";
191 |     responseText +=
192 |       "4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })";
193 | 
194 |     return {
195 |       content: [
196 |         {
197 |           type: 'text',
198 |           text: responseText,
199 |         },
200 |       ],
201 |     };
202 |   } catch (error) {
203 |     const errorMessage = error instanceof Error ? error.message : String(error);
204 |     log('error', `Error listing simulators: ${errorMessage}`);
205 |     return {
206 |       content: [
207 |         {
208 |           type: 'text',
209 |           text: `Failed to list simulators: ${errorMessage}`,
210 |         },
211 |       ],
212 |     };
213 |   }
214 | }
215 | 
216 | export default {
217 |   name: 'list_sims',
218 |   description: 'Lists available iOS simulators with their UUIDs. ',
219 |   schema: listSimsSchema.shape, // MCP SDK compatibility
220 |   handler: createTypedTool(listSimsSchema, list_simsLogic, getDefaultCommandExecutor),
221 | };
222 | 
```

--------------------------------------------------------------------------------
/src/utils/typed-tool-factory.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Type-safe tool factory for XcodeBuildMCP
  3 |  *
  4 |  * This module provides a factory function to create MCP tool handlers that safely
  5 |  * convert from the generic Record<string, unknown> signature required by the MCP SDK
  6 |  * to strongly-typed parameters using runtime validation with Zod.
  7 |  *
  8 |  * This eliminates the need for unsafe type assertions while maintaining full
  9 |  * compatibility with the MCP SDK's tool handler signature requirements.
 10 |  */
 11 | 
 12 | import { z } from 'zod';
 13 | import { ToolResponse } from '../types/common.ts';
 14 | import type { CommandExecutor } from './execution/index.ts';
 15 | import { createErrorResponse } from './responses/index.ts';
 16 | import { sessionStore, type SessionDefaults } from './session-store.ts';
 17 | 
 18 | /**
 19 |  * Creates a type-safe tool handler that validates parameters at runtime
 20 |  * before passing them to the typed logic function.
 21 |  *
 22 |  * This is the ONLY safe way to cross the type boundary from the generic
 23 |  * MCP handler signature to our typed domain logic.
 24 |  *
 25 |  * @param schema - Zod schema for parameter validation
 26 |  * @param logicFunction - The typed logic function to execute
 27 |  * @param getExecutor - Function to get the command executor (must be provided)
 28 |  * @returns A handler function compatible with MCP SDK requirements
 29 |  */
 30 | export function createTypedTool<TParams>(
 31 |   schema: z.ZodType<TParams>,
 32 |   logicFunction: (params: TParams, executor: CommandExecutor) => Promise<ToolResponse>,
 33 |   getExecutor: () => CommandExecutor,
 34 | ) {
 35 |   return async (args: Record<string, unknown>): Promise<ToolResponse> => {
 36 |     try {
 37 |       // Runtime validation - the ONLY safe way to cross the type boundary
 38 |       // This provides both compile-time and runtime type safety
 39 |       const validatedParams = schema.parse(args);
 40 | 
 41 |       // Now we have guaranteed type safety - no assertions needed!
 42 |       return await logicFunction(validatedParams, getExecutor());
 43 |     } catch (error) {
 44 |       if (error instanceof z.ZodError) {
 45 |         // Format validation errors in a user-friendly way
 46 |         const errorMessages = error.errors.map((e) => {
 47 |           const path = e.path.length > 0 ? `${e.path.join('.')}` : 'root';
 48 |           return `${path}: ${e.message}`;
 49 |         });
 50 | 
 51 |         return createErrorResponse(
 52 |           'Parameter validation failed',
 53 |           `Invalid parameters:\n${errorMessages.join('\n')}`,
 54 |         );
 55 |       }
 56 | 
 57 |       // Re-throw unexpected errors (they'll be caught by the MCP framework)
 58 |       throw error;
 59 |     }
 60 |   };
 61 | }
 62 | 
 63 | export type SessionRequirement =
 64 |   | { allOf: (keyof SessionDefaults)[]; message?: string }
 65 |   | { oneOf: (keyof SessionDefaults)[]; message?: string };
 66 | 
 67 | function missingFromMerged(
 68 |   keys: (keyof SessionDefaults)[],
 69 |   merged: Record<string, unknown>,
 70 | ): string[] {
 71 |   return keys.filter((k) => merged[k] == null);
 72 | }
 73 | 
 74 | export function createSessionAwareTool<TParams>(opts: {
 75 |   internalSchema: z.ZodType<TParams>;
 76 |   logicFunction: (params: TParams, executor: CommandExecutor) => Promise<ToolResponse>;
 77 |   getExecutor: () => CommandExecutor;
 78 |   sessionKeys?: (keyof SessionDefaults)[];
 79 |   requirements?: SessionRequirement[];
 80 |   exclusivePairs?: (keyof SessionDefaults)[][]; // when args provide one side, drop conflicting session-default side(s)
 81 | }) {
 82 |   const {
 83 |     internalSchema,
 84 |     logicFunction,
 85 |     getExecutor,
 86 |     requirements = [],
 87 |     exclusivePairs = [],
 88 |   } = opts;
 89 | 
 90 |   return async (rawArgs: Record<string, unknown>): Promise<ToolResponse> => {
 91 |     try {
 92 |       // Sanitize args: treat null/undefined as "not provided" so they don't override session defaults
 93 |       const sanitizedArgs: Record<string, unknown> = {};
 94 |       for (const [k, v] of Object.entries(rawArgs)) {
 95 |         if (v === null || v === undefined) continue;
 96 |         if (typeof v === 'string' && v.trim() === '') continue;
 97 |         sanitizedArgs[k] = v;
 98 |       }
 99 | 
100 |       // Factory-level mutual exclusivity check: if user provides multiple explicit values
101 |       // within an exclusive group, reject early even if tool schema doesn't enforce XOR.
102 |       for (const pair of exclusivePairs) {
103 |         const provided = pair.filter((k) => Object.prototype.hasOwnProperty.call(sanitizedArgs, k));
104 |         if (provided.length >= 2) {
105 |           return createErrorResponse(
106 |             'Parameter validation failed',
107 |             `Invalid parameters:\nMutually exclusive parameters provided: ${provided.join(
108 |               ', ',
109 |             )}. Provide only one.`,
110 |           );
111 |         }
112 |       }
113 | 
114 |       // Start with session defaults merged with explicit args (args override session)
115 |       const merged: Record<string, unknown> = { ...sessionStore.getAll(), ...sanitizedArgs };
116 | 
117 |       // Apply exclusive pair pruning: only when caller provided a concrete (non-null/undefined) value
118 |       // for any key in the pair. When activated, drop other keys in the pair coming from session defaults.
119 |       for (const pair of exclusivePairs) {
120 |         const userProvidedConcrete = pair.some((k) =>
121 |           Object.prototype.hasOwnProperty.call(sanitizedArgs, k),
122 |         );
123 |         if (!userProvidedConcrete) continue;
124 | 
125 |         for (const k of pair) {
126 |           if (!Object.prototype.hasOwnProperty.call(sanitizedArgs, k) && k in merged) {
127 |             delete merged[k];
128 |           }
129 |         }
130 |       }
131 | 
132 |       for (const req of requirements) {
133 |         if ('allOf' in req) {
134 |           const missing = missingFromMerged(req.allOf, merged);
135 |           if (missing.length > 0) {
136 |             return createErrorResponse(
137 |               'Missing required session defaults',
138 |               `${req.message ?? `Required: ${req.allOf.join(', ')}`}\n` +
139 |                 `Set with: session-set-defaults { ${missing
140 |                   .map((k) => `"${k}": "..."`)
141 |                   .join(', ')} }`,
142 |             );
143 |           }
144 |         } else if ('oneOf' in req) {
145 |           const satisfied = req.oneOf.some((k) => merged[k] != null);
146 |           if (!satisfied) {
147 |             const options = req.oneOf.join(', ');
148 |             const setHints = req.oneOf
149 |               .map((k) => `session-set-defaults { "${k}": "..." }`)
150 |               .join(' OR ');
151 |             return createErrorResponse(
152 |               'Missing required session defaults',
153 |               `${req.message ?? `Provide one of: ${options}`}\nSet with: ${setHints}`,
154 |             );
155 |           }
156 |         }
157 |       }
158 | 
159 |       const validated = internalSchema.parse(merged);
160 |       return await logicFunction(validated, getExecutor());
161 |     } catch (error) {
162 |       if (error instanceof z.ZodError) {
163 |         const errorMessages = error.errors.map((e) => {
164 |           const path = e.path.length > 0 ? `${e.path.join('.')}` : 'root';
165 |           return `${path}: ${e.message}`;
166 |         });
167 | 
168 |         return createErrorResponse(
169 |           'Parameter validation failed',
170 |           `Invalid parameters:\n${errorMessages.join('\n')}\nTip: set session defaults via session-set-defaults`,
171 |         );
172 |       }
173 |       throw error;
174 |     }
175 |   };
176 | }
177 | 
```

--------------------------------------------------------------------------------
/src/core/dynamic-tools.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { log } from '../utils/logger.ts';
  2 | import { getDefaultCommandExecutor, CommandExecutor } from '../utils/command.ts';
  3 | import { WORKFLOW_LOADERS, WorkflowName, WORKFLOW_METADATA } from './generated-plugins.ts';
  4 | import { ToolResponse } from '../types/common.ts';
  5 | import { PluginMeta } from './plugin-types.ts';
  6 | import { McpServer } from '@camsoft/mcp-sdk/server/mcp.js';
  7 | import {
  8 |   registerAndTrackTools,
  9 |   removeTrackedTools,
 10 |   isToolRegistered,
 11 | } from '../utils/tool-registry.ts';
 12 | import { ZodRawShape } from 'zod';
 13 | 
 14 | // Track enabled workflows and their tools for replacement functionality
 15 | const enabledWorkflows = new Set<string>();
 16 | const enabledTools = new Map<string, string>(); // toolName -> workflowName
 17 | 
 18 | // Type for the handler function from our tools
 19 | type ToolHandler = (
 20 |   args: Record<string, unknown>,
 21 |   executor: CommandExecutor,
 22 | ) => Promise<ToolResponse>;
 23 | 
 24 | // Use the actual McpServer type from the SDK instead of a custom interface
 25 | 
 26 | /**
 27 |  * Wrapper function to adapt MCP SDK handler calling convention to our dependency injection pattern
 28 |  * MCP SDK calls handlers with just (args), but our handlers expect (args, executor)
 29 |  */
 30 | function wrapHandlerWithExecutor(handler: ToolHandler) {
 31 |   return async (args: unknown): Promise<ToolResponse> => {
 32 |     return handler(args as Record<string, unknown>, getDefaultCommandExecutor());
 33 |   };
 34 | }
 35 | 
 36 | /**
 37 |  * Clear currently enabled workflows by actually removing registered tools
 38 |  */
 39 | export function clearEnabledWorkflows(): void {
 40 |   if (enabledTools.size === 0) {
 41 |     log('debug', 'No tools to clear');
 42 |     return;
 43 |   }
 44 | 
 45 |   const clearedWorkflows = Array.from(enabledWorkflows);
 46 |   const toolNamesToRemove = Array.from(enabledTools.keys());
 47 |   const clearedToolCount = toolNamesToRemove.length;
 48 | 
 49 |   log('info', `Removing ${clearedToolCount} tools from workflows: ${clearedWorkflows.join(', ')}`);
 50 | 
 51 |   // Actually remove the registered tools using the tool registry
 52 |   const removedTools = removeTrackedTools(toolNamesToRemove);
 53 | 
 54 |   // Clear our tracking
 55 |   enabledWorkflows.clear();
 56 |   enabledTools.clear();
 57 | 
 58 |   log('info', `✅ Removed ${removedTools.length} tools successfully`);
 59 | }
 60 | 
 61 | /**
 62 |  * Get currently enabled workflows
 63 |  */
 64 | export function getEnabledWorkflows(): string[] {
 65 |   return Array.from(enabledWorkflows);
 66 | }
 67 | 
 68 | /**
 69 |  * Enable workflows by registering their tools dynamically using generated loaders
 70 |  * @param server - MCP server instance
 71 |  * @param workflowNames - Array of workflow names to enable
 72 |  * @param additive - If true, add to existing workflows. If false (default), replace existing workflows
 73 |  */
 74 | export async function enableWorkflows(
 75 |   server: McpServer,
 76 |   workflowNames: string[],
 77 |   additive: boolean = false,
 78 | ): Promise<void> {
 79 |   if (!server) {
 80 |     throw new Error('Server instance not available for dynamic tool registration');
 81 |   }
 82 | 
 83 |   // Clear existing workflow tracking unless in additive mode
 84 |   if (!additive && enabledWorkflows.size > 0) {
 85 |     log('info', `Replacing existing workflows: ${Array.from(enabledWorkflows).join(', ')}`);
 86 |     clearEnabledWorkflows();
 87 |   }
 88 | 
 89 |   let totalToolsAdded = 0;
 90 | 
 91 |   for (const workflowName of workflowNames) {
 92 |     const loader = WORKFLOW_LOADERS[workflowName as WorkflowName];
 93 | 
 94 |     if (!loader) {
 95 |       log('warn', `Workflow '${workflowName}' not found in available workflows`);
 96 |       continue;
 97 |     }
 98 | 
 99 |     try {
100 |       log('info', `Loading workflow '${workflowName}' with code-splitting...`);
101 | 
102 |       // Dynamic import with code-splitting
103 |       const workflowModule = (await loader()) as Record<string, unknown>;
104 | 
105 |       // Get tools count from the module (excluding 'workflow' key)
106 |       const toolKeys = Object.keys(workflowModule).filter((key) => key !== 'workflow');
107 | 
108 |       log('info', `Enabling ${toolKeys.length} tools from '${workflowName}' workflow`);
109 | 
110 |       const toolsToRegister: Array<{
111 |         name: string;
112 |         config: {
113 |           title?: string;
114 |           description?: string;
115 |           inputSchema?: ZodRawShape;
116 |           outputSchema?: ZodRawShape;
117 |           annotations?: Record<string, unknown>;
118 |         };
119 |         callback: (args: Record<string, unknown>) => Promise<ToolResponse>;
120 |       }> = [];
121 | 
122 |       // Collect all tools from this workflow, filtering out already-registered tools
123 |       for (const toolKey of toolKeys) {
124 |         const tool = workflowModule[toolKey] as PluginMeta | undefined;
125 | 
126 |         if (tool?.name && typeof tool.handler === 'function') {
127 |           // Always skip tools that are already registered (in all modes)
128 |           if (isToolRegistered(tool.name)) {
129 |             log('debug', `Skipping already registered tool: ${tool.name}`);
130 |             continue;
131 |           }
132 | 
133 |           toolsToRegister.push({
134 |             name: tool.name,
135 |             config: {
136 |               description: tool.description ?? '',
137 |               inputSchema: tool.schema,
138 |             },
139 |             callback: wrapHandlerWithExecutor(tool.handler as ToolHandler),
140 |           });
141 | 
142 |           // Track the tool and workflow
143 |           enabledTools.set(tool.name, workflowName);
144 |           totalToolsAdded++;
145 |         } else {
146 |           log('warn', `Invalid tool definition for '${toolKey}' in workflow '${workflowName}'`);
147 |         }
148 |       }
149 | 
150 |       // Register all tools using bulk registration
151 |       if (toolsToRegister.length > 0) {
152 |         log(
153 |           'info',
154 |           `🚀 Registering ${toolsToRegister.length} tools from '${workflowName}' workflow`,
155 |         );
156 | 
157 |         // Convert to proper tool registration format
158 |         const toolRegistrations = toolsToRegister.map((tool) => ({
159 |           name: tool.name,
160 |           config: {
161 |             description: tool.config.description,
162 |             inputSchema: tool.config.inputSchema as unknown,
163 |           },
164 |           callback: (args: unknown): Promise<ToolResponse> =>
165 |             tool.callback(args as Record<string, unknown>),
166 |         }));
167 | 
168 |         // Use bulk registration - no fallback needed with proper duplicate handling
169 |         const registeredTools = registerAndTrackTools(server, toolRegistrations);
170 |         log('info', `✅ Registered ${registeredTools.length} tools from '${workflowName}'`);
171 |       } else {
172 |         log('info', `No new tools to register from '${workflowName}' (all already registered)`);
173 |       }
174 | 
175 |       // Track the workflow as enabled
176 |       enabledWorkflows.add(workflowName);
177 |     } catch (error) {
178 |       log(
179 |         'error',
180 |         `Failed to load workflow '${workflowName}': ${error instanceof Error ? error.message : 'Unknown error'}`,
181 |       );
182 |     }
183 |   }
184 | 
185 |   // registerAndTrackTools() handles tool list change notifications automatically
186 | 
187 |   log(
188 |     'info',
189 |     `✅ Successfully enabled ${totalToolsAdded} tools from ${workflowNames.length} workflows`,
190 |   );
191 | }
192 | 
193 | /**
194 |  * Get list of currently available workflows using generated metadata
195 |  */
196 | export function getAvailableWorkflows(): string[] {
197 |   return Object.keys(WORKFLOW_LOADERS);
198 | }
199 | 
200 | /**
201 |  * Get workflow information for LLM prompt generation using generated metadata
202 |  */
203 | export function generateWorkflowDescriptions(): string {
204 |   return Object.entries(WORKFLOW_METADATA)
205 |     .map(([name, metadata]) => `- **${name.toUpperCase()}**: ${metadata.description}`)
206 |     .join('\n');
207 | }
208 | 
```

--------------------------------------------------------------------------------
/src/utils/__tests__/session-aware-tool-factory.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, beforeEach } from 'vitest';
  2 | import { z } from 'zod';
  3 | import { createSessionAwareTool } from '../typed-tool-factory.ts';
  4 | import { sessionStore } from '../session-store.ts';
  5 | import { createMockExecutor } from '../../test-utils/mock-executors.ts';
  6 | 
  7 | describe('createSessionAwareTool', () => {
  8 |   beforeEach(() => {
  9 |     sessionStore.clear();
 10 |   });
 11 | 
 12 |   const internalSchema = z
 13 |     .object({
 14 |       scheme: z.string(),
 15 |       projectPath: z.string().optional(),
 16 |       workspacePath: z.string().optional(),
 17 |       simulatorId: z.string().optional(),
 18 |       simulatorName: z.string().optional(),
 19 |     })
 20 |     .refine((v) => !!v.projectPath !== !!v.workspacePath, {
 21 |       message: 'projectPath and workspacePath are mutually exclusive',
 22 |       path: ['projectPath'],
 23 |     })
 24 |     .refine((v) => !!v.simulatorId !== !!v.simulatorName, {
 25 |       message: 'simulatorId and simulatorName are mutually exclusive',
 26 |       path: ['simulatorId'],
 27 |     });
 28 | 
 29 |   type Params = z.infer<typeof internalSchema>;
 30 | 
 31 |   async function logic(_params: Params): Promise<import('../../types/common.ts').ToolResponse> {
 32 |     return { content: [{ type: 'text', text: 'OK' }], isError: false };
 33 |   }
 34 | 
 35 |   const handler = createSessionAwareTool<Params>({
 36 |     internalSchema,
 37 |     logicFunction: logic,
 38 |     getExecutor: () => createMockExecutor({ success: true }),
 39 |     requirements: [
 40 |       { allOf: ['scheme'], message: 'scheme is required' },
 41 |       { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
 42 |       { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' },
 43 |     ],
 44 |   });
 45 | 
 46 |   it('should merge session defaults and satisfy requirements', async () => {
 47 |     sessionStore.setDefaults({
 48 |       scheme: 'App',
 49 |       projectPath: '/path/proj.xcodeproj',
 50 |       simulatorId: 'SIM-1',
 51 |     });
 52 | 
 53 |     const result = await handler({});
 54 |     expect(result.isError).toBe(false);
 55 |     expect(result.content[0].text).toBe('OK');
 56 |   });
 57 | 
 58 |   it('should prefer explicit args over session defaults (same key wins)', async () => {
 59 |     // Create a handler that echoes the chosen scheme
 60 |     const echoHandler = createSessionAwareTool<Params>({
 61 |       internalSchema,
 62 |       logicFunction: async (params) => ({
 63 |         content: [{ type: 'text', text: params.scheme }],
 64 |         isError: false,
 65 |       }),
 66 |       getExecutor: () => createMockExecutor({ success: true }),
 67 |       requirements: [
 68 |         { allOf: ['scheme'], message: 'scheme is required' },
 69 |         { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
 70 |         {
 71 |           oneOf: ['simulatorId', 'simulatorName'],
 72 |           message: 'Provide simulatorId or simulatorName',
 73 |         },
 74 |       ],
 75 |     });
 76 | 
 77 |     sessionStore.setDefaults({
 78 |       scheme: 'Default',
 79 |       projectPath: '/a.xcodeproj',
 80 |       simulatorId: 'SIM-A',
 81 |     });
 82 |     const result = await echoHandler({ scheme: 'FromArgs' });
 83 |     expect(result.isError).toBe(false);
 84 |     expect(result.content[0].text).toBe('FromArgs');
 85 |   });
 86 | 
 87 |   it('should return friendly error when allOf requirement missing', async () => {
 88 |     const result = await handler({ projectPath: '/p.xcodeproj', simulatorId: 'SIM-1' });
 89 |     expect(result.isError).toBe(true);
 90 |     expect(result.content[0].text).toContain('Missing required session defaults');
 91 |     expect(result.content[0].text).toContain('scheme is required');
 92 |   });
 93 | 
 94 |   it('should return friendly error when oneOf requirement missing', async () => {
 95 |     const result = await handler({ scheme: 'App', simulatorId: 'SIM-1' });
 96 |     expect(result.isError).toBe(true);
 97 |     expect(result.content[0].text).toContain('Missing required session defaults');
 98 |     expect(result.content[0].text).toContain('Provide a project or workspace');
 99 |   });
100 | 
101 |   it('should surface Zod validation errors with tip when invalid', async () => {
102 |     const badHandler = createSessionAwareTool<any>({
103 |       internalSchema,
104 |       logicFunction: logic,
105 |       getExecutor: () => createMockExecutor({ success: true }),
106 |     });
107 |     const result = await badHandler({ scheme: 123 });
108 |     expect(result.isError).toBe(true);
109 |     expect(result.content[0].text).toContain('Parameter validation failed');
110 |     expect(result.content[0].text).toContain('Tip: set session defaults');
111 |   });
112 | 
113 |   it('exclusivePairs should NOT prune session defaults when user provides null (treat as not provided)', async () => {
114 |     const handlerWithExclusive = createSessionAwareTool<Params>({
115 |       internalSchema,
116 |       logicFunction: logic,
117 |       getExecutor: () => createMockExecutor({ success: true }),
118 |       requirements: [
119 |         { allOf: ['scheme'], message: 'scheme is required' },
120 |         { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
121 |       ],
122 |       exclusivePairs: [['projectPath', 'workspacePath']],
123 |     });
124 | 
125 |     sessionStore.setDefaults({
126 |       scheme: 'App',
127 |       projectPath: '/path/proj.xcodeproj',
128 |       simulatorId: 'SIM-1',
129 |     });
130 | 
131 |     const res = await handlerWithExclusive({ workspacePath: null as unknown as string });
132 |     expect(res.isError).toBe(false);
133 |     expect(res.content[0].text).toBe('OK');
134 |   });
135 | 
136 |   it('exclusivePairs should NOT prune when user provides undefined (key present)', async () => {
137 |     const handlerWithExclusive = createSessionAwareTool<Params>({
138 |       internalSchema,
139 |       logicFunction: logic,
140 |       getExecutor: () => createMockExecutor({ success: true }),
141 |       requirements: [
142 |         { allOf: ['scheme'], message: 'scheme is required' },
143 |         { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
144 |       ],
145 |       exclusivePairs: [['projectPath', 'workspacePath']],
146 |     });
147 | 
148 |     sessionStore.setDefaults({
149 |       scheme: 'App',
150 |       projectPath: '/path/proj.xcodeproj',
151 |       simulatorId: 'SIM-1',
152 |     });
153 | 
154 |     const res = await handlerWithExclusive({ workspacePath: undefined as unknown as string });
155 |     expect(res.isError).toBe(false);
156 |     expect(res.content[0].text).toBe('OK');
157 |   });
158 | 
159 |   it('rejects when multiple explicit args in an exclusive pair are provided (factory-level)', async () => {
160 |     const internalSchemaNoXor = z.object({
161 |       scheme: z.string(),
162 |       projectPath: z.string().optional(),
163 |       workspacePath: z.string().optional(),
164 |     });
165 | 
166 |     const handlerNoXor = createSessionAwareTool<z.infer<typeof internalSchemaNoXor>>({
167 |       internalSchema: internalSchemaNoXor,
168 |       logicFunction: (async () => ({
169 |         content: [{ type: 'text', text: 'OK' }],
170 |         isError: false,
171 |       })) as any,
172 |       getExecutor: () => createMockExecutor({ success: true }),
173 |       requirements: [{ allOf: ['scheme'], message: 'scheme is required' }],
174 |       exclusivePairs: [['projectPath', 'workspacePath']],
175 |     });
176 | 
177 |     const res = await handlerNoXor({
178 |       scheme: 'App',
179 |       projectPath: '/path/a.xcodeproj',
180 |       workspacePath: '/path/b.xcworkspace',
181 |     });
182 | 
183 |     expect(res.isError).toBe(true);
184 |     const msg = res.content[0].text;
185 |     expect(msg).toContain('Parameter validation failed');
186 |     expect(msg).toContain('Mutually exclusive parameters provided');
187 |     expect(msg).toContain('projectPath');
188 |     expect(msg).toContain('workspacePath');
189 |   });
190 | });
191 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/simulator/record_sim_video.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { z } from 'zod';
  2 | import type { ToolResponse } from '../../../types/common.ts';
  3 | import { createTextResponse } from '../../../utils/responses/index.ts';
  4 | import {
  5 |   getDefaultCommandExecutor,
  6 |   getDefaultFileSystemExecutor,
  7 | } from '../../../utils/execution/index.ts';
  8 | import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts';
  9 | import {
 10 |   areAxeToolsAvailable,
 11 |   isAxeAtLeastVersion,
 12 |   createAxeNotAvailableResponse,
 13 | } from '../../../utils/axe/index.ts';
 14 | import {
 15 |   startSimulatorVideoCapture,
 16 |   stopSimulatorVideoCapture,
 17 | } from '../../../utils/video-capture/index.ts';
 18 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts';
 19 | import { dirname } from 'path';
 20 | 
 21 | // Base schema object (used for MCP schema exposure)
 22 | const recordSimVideoSchemaObject = z.object({
 23 |   simulatorId: z
 24 |     .string()
 25 |     .uuid('Invalid Simulator UUID format')
 26 |     .describe('UUID of the simulator to record'),
 27 |   start: z.boolean().optional().describe('Start recording if true'),
 28 |   stop: z.boolean().optional().describe('Stop recording if true'),
 29 |   fps: z.number().int().min(1).max(120).optional().describe('Frames per second (default 30)'),
 30 |   outputFile: z
 31 |     .string()
 32 |     .optional()
 33 |     .describe('Destination MP4 path to move the recorded video to on stop'),
 34 | });
 35 | 
 36 | // Schema enforcing mutually exclusive start/stop and requiring outputFile on stop
 37 | const recordSimVideoSchema = recordSimVideoSchemaObject
 38 |   .refine(
 39 |     (v) => {
 40 |       const s = v.start === true ? 1 : 0;
 41 |       const t = v.stop === true ? 1 : 0;
 42 |       return s + t === 1;
 43 |     },
 44 |     {
 45 |       message:
 46 |         'Provide exactly one of start=true or stop=true; these options are mutually exclusive',
 47 |       path: ['start'],
 48 |     },
 49 |   )
 50 |   .refine((v) => (v.stop ? typeof v.outputFile === 'string' && v.outputFile.length > 0 : true), {
 51 |     message: 'outputFile is required when stop=true',
 52 |     path: ['outputFile'],
 53 |   });
 54 | 
 55 | type RecordSimVideoParams = z.infer<typeof recordSimVideoSchema>;
 56 | 
 57 | export async function record_sim_videoLogic(
 58 |   params: RecordSimVideoParams,
 59 |   executor: CommandExecutor,
 60 |   axe: {
 61 |     areAxeToolsAvailable(): boolean;
 62 |     isAxeAtLeastVersion(v: string, e: CommandExecutor): Promise<boolean>;
 63 |     createAxeNotAvailableResponse(): ToolResponse;
 64 |   } = {
 65 |     areAxeToolsAvailable,
 66 |     isAxeAtLeastVersion,
 67 |     createAxeNotAvailableResponse,
 68 |   },
 69 |   video: {
 70 |     startSimulatorVideoCapture: typeof startSimulatorVideoCapture;
 71 |     stopSimulatorVideoCapture: typeof stopSimulatorVideoCapture;
 72 |   } = {
 73 |     startSimulatorVideoCapture,
 74 |     stopSimulatorVideoCapture,
 75 |   },
 76 |   fs: FileSystemExecutor = getDefaultFileSystemExecutor(),
 77 | ): Promise<ToolResponse> {
 78 |   // Preflight checks for AXe availability and version
 79 |   if (!axe.areAxeToolsAvailable()) {
 80 |     return axe.createAxeNotAvailableResponse();
 81 |   }
 82 |   const hasVersion = await axe.isAxeAtLeastVersion('1.1.0', executor);
 83 |   if (!hasVersion) {
 84 |     return createTextResponse(
 85 |       'AXe v1.1.0 or newer is required for simulator video capture. Please update bundled AXe artifacts.',
 86 |       true,
 87 |     );
 88 |   }
 89 | 
 90 |   // using injected fs executor
 91 | 
 92 |   if (params.start) {
 93 |     const fpsUsed = Number.isFinite(params.fps as number) ? Number(params.fps) : 30;
 94 |     const startRes = await video.startSimulatorVideoCapture(
 95 |       { simulatorUuid: params.simulatorId, fps: fpsUsed },
 96 |       executor,
 97 |     );
 98 | 
 99 |     if (!startRes.started) {
100 |       return createTextResponse(
101 |         `Failed to start video recording: ${startRes.error ?? 'Unknown error'}`,
102 |         true,
103 |       );
104 |     }
105 | 
106 |     const notes: string[] = [];
107 |     if (typeof params.outputFile === 'string' && params.outputFile.length > 0) {
108 |       notes.push(
109 |         'Note: outputFile is ignored when start=true; provide it when stopping to move/rename the recorded file.',
110 |       );
111 |     }
112 |     if (startRes.warning) {
113 |       notes.push(startRes.warning);
114 |     }
115 | 
116 |     const nextSteps = `Next Steps:
117 | Stop and save the recording:
118 | record_sim_video({ simulatorId: "${params.simulatorId}", stop: true, outputFile: "/path/to/output.mp4" })`;
119 | 
120 |     return {
121 |       content: [
122 |         {
123 |           type: 'text',
124 |           text: `🎥 Video recording started for simulator ${params.simulatorId} at ${fpsUsed} fps.\nSession: ${startRes.sessionId}`,
125 |         },
126 |         ...(notes.length > 0
127 |           ? [
128 |               {
129 |                 type: 'text' as const,
130 |                 text: notes.join('\n'),
131 |               },
132 |             ]
133 |           : []),
134 |         {
135 |           type: 'text',
136 |           text: nextSteps,
137 |         },
138 |       ],
139 |       isError: false,
140 |     };
141 |   }
142 | 
143 |   // params.stop must be true here per schema
144 |   const stopRes = await video.stopSimulatorVideoCapture(
145 |     { simulatorUuid: params.simulatorId },
146 |     executor,
147 |   );
148 | 
149 |   if (!stopRes.stopped) {
150 |     return createTextResponse(
151 |       `Failed to stop video recording: ${stopRes.error ?? 'Unknown error'}`,
152 |       true,
153 |     );
154 |   }
155 | 
156 |   // Attempt to move/rename the recording if we parsed a source path and an outputFile was given
157 |   const outputs: string[] = [];
158 |   let finalSavedPath = params.outputFile ?? stopRes.parsedPath ?? '';
159 |   try {
160 |     if (params.outputFile) {
161 |       if (!stopRes.parsedPath) {
162 |         return createTextResponse(
163 |           `Recording stopped but could not determine the recorded file path from AXe output.\nRaw output:\n${stopRes.stdout ?? '(no output captured)'}`,
164 |           true,
165 |         );
166 |       }
167 | 
168 |       const src = stopRes.parsedPath;
169 |       const dest = params.outputFile;
170 |       await fs.mkdir(dirname(dest), { recursive: true });
171 |       await fs.cp(src, dest);
172 |       try {
173 |         await fs.rm(src, { recursive: false });
174 |       } catch {
175 |         // Ignore cleanup failure
176 |       }
177 |       finalSavedPath = dest;
178 | 
179 |       outputs.push(`Original file: ${src}`);
180 |       outputs.push(`Saved to: ${dest}`);
181 |     } else if (stopRes.parsedPath) {
182 |       outputs.push(`Saved to: ${stopRes.parsedPath}`);
183 |       finalSavedPath = stopRes.parsedPath;
184 |     }
185 |   } catch (e) {
186 |     const msg = e instanceof Error ? e.message : String(e);
187 |     return createTextResponse(
188 |       `Recording stopped but failed to save/move the video file: ${msg}`,
189 |       true,
190 |     );
191 |   }
192 | 
193 |   return {
194 |     content: [
195 |       {
196 |         type: 'text',
197 |         text: `✅ Video recording stopped for simulator ${params.simulatorId}.`,
198 |       },
199 |       ...(outputs.length > 0
200 |         ? [
201 |             {
202 |               type: 'text' as const,
203 |               text: outputs.join('\n'),
204 |             },
205 |           ]
206 |         : []),
207 |       ...(!outputs.length && stopRes.stdout
208 |         ? [
209 |             {
210 |               type: 'text' as const,
211 |               text: `AXe output:\n${stopRes.stdout}`,
212 |             },
213 |           ]
214 |         : []),
215 |     ],
216 |     isError: false,
217 |     _meta: finalSavedPath ? { outputFile: finalSavedPath } : undefined,
218 |   };
219 | }
220 | 
221 | const publicSchemaObject = recordSimVideoSchemaObject.omit({
222 |   simulatorId: true,
223 | } as const);
224 | 
225 | export default {
226 |   name: 'record_sim_video',
227 |   description: 'Starts or stops video capture for an iOS simulator.',
228 |   schema: publicSchemaObject.shape,
229 |   handler: createSessionAwareTool<RecordSimVideoParams>({
230 |     internalSchema: recordSimVideoSchema as unknown as z.ZodType<RecordSimVideoParams>,
231 |     logicFunction: record_sim_videoLogic,
232 |     getExecutor: getDefaultCommandExecutor,
233 |     requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }],
234 |   }),
235 | };
236 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/ui-testing/swipe.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * UI Testing Plugin: Swipe
  3 |  *
  4 |  * Swipe from one coordinate to another on iOS simulator with customizable duration and delta.
  5 |  */
  6 | 
  7 | import { z } from 'zod';
  8 | import { ToolResponse } from '../../../types/common.ts';
  9 | import { log } from '../../../utils/logging/index.ts';
 10 | import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts';
 11 | import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts';
 12 | import type { CommandExecutor } from '../../../utils/execution/index.ts';
 13 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
 14 | import {
 15 |   createAxeNotAvailableResponse,
 16 |   getAxePath,
 17 |   getBundledAxeEnvironment,
 18 | } from '../../../utils/axe-helpers.ts';
 19 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
 20 | 
 21 | // Define schema as ZodObject
 22 | const swipeSchema = z.object({
 23 |   simulatorUuid: z.string().uuid('Invalid Simulator UUID format'),
 24 |   x1: z.number().int('Start X coordinate'),
 25 |   y1: z.number().int('Start Y coordinate'),
 26 |   x2: z.number().int('End X coordinate'),
 27 |   y2: z.number().int('End Y coordinate'),
 28 |   duration: z.number().min(0, 'Duration must be non-negative').optional(),
 29 |   delta: z.number().min(0, 'Delta must be non-negative').optional(),
 30 |   preDelay: z.number().min(0, 'Pre-delay must be non-negative').optional(),
 31 |   postDelay: z.number().min(0, 'Post-delay must be non-negative').optional(),
 32 | });
 33 | 
 34 | // Use z.infer for type safety
 35 | type SwipeParams = z.infer<typeof swipeSchema>;
 36 | 
 37 | export interface AxeHelpers {
 38 |   getAxePath: () => string | null;
 39 |   getBundledAxeEnvironment: () => Record<string, string>;
 40 |   createAxeNotAvailableResponse: () => ToolResponse;
 41 | }
 42 | 
 43 | const LOG_PREFIX = '[AXe]';
 44 | 
 45 | /**
 46 |  * Core swipe logic implementation
 47 |  */
 48 | export async function swipeLogic(
 49 |   params: SwipeParams,
 50 |   executor: CommandExecutor,
 51 |   axeHelpers: AxeHelpers = {
 52 |     getAxePath,
 53 |     getBundledAxeEnvironment,
 54 |     createAxeNotAvailableResponse,
 55 |   },
 56 | ): Promise<ToolResponse> {
 57 |   const toolName = 'swipe';
 58 | 
 59 |   const { simulatorUuid, x1, y1, x2, y2, duration, delta, preDelay, postDelay } = params;
 60 |   const commandArgs = [
 61 |     'swipe',
 62 |     '--start-x',
 63 |     String(x1),
 64 |     '--start-y',
 65 |     String(y1),
 66 |     '--end-x',
 67 |     String(x2),
 68 |     '--end-y',
 69 |     String(y2),
 70 |   ];
 71 |   if (duration !== undefined) {
 72 |     commandArgs.push('--duration', String(duration));
 73 |   }
 74 |   if (delta !== undefined) {
 75 |     commandArgs.push('--delta', String(delta));
 76 |   }
 77 |   if (preDelay !== undefined) {
 78 |     commandArgs.push('--pre-delay', String(preDelay));
 79 |   }
 80 |   if (postDelay !== undefined) {
 81 |     commandArgs.push('--post-delay', String(postDelay));
 82 |   }
 83 | 
 84 |   const optionsText = duration ? ` duration=${duration}s` : '';
 85 |   log(
 86 |     'info',
 87 |     `${LOG_PREFIX}/${toolName}: Starting swipe (${x1},${y1})->(${x2},${y2})${optionsText} on ${simulatorUuid}`,
 88 |   );
 89 | 
 90 |   try {
 91 |     await executeAxeCommand(commandArgs, simulatorUuid, 'swipe', executor, axeHelpers);
 92 |     log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`);
 93 | 
 94 |     const warning = getCoordinateWarning(simulatorUuid);
 95 |     const message = `Swipe from (${x1}, ${y1}) to (${x2}, ${y2})${optionsText} simulated successfully.`;
 96 | 
 97 |     if (warning) {
 98 |       return createTextResponse(`${message}\n\n${warning}`);
 99 |     }
100 | 
101 |     return createTextResponse(message);
102 |   } catch (error) {
103 |     log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`);
104 |     if (error instanceof DependencyError) {
105 |       return axeHelpers.createAxeNotAvailableResponse();
106 |     } else if (error instanceof AxeError) {
107 |       return createErrorResponse(`Failed to simulate swipe: ${error.message}`, error.axeOutput);
108 |     } else if (error instanceof SystemError) {
109 |       return createErrorResponse(
110 |         `System error executing axe: ${error.message}`,
111 |         error.originalError?.stack,
112 |       );
113 |     }
114 |     return createErrorResponse(
115 |       `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`,
116 |     );
117 |   }
118 | }
119 | 
120 | export default {
121 |   name: 'swipe',
122 |   description:
123 |     "Swipe from one point to another. Use describe_ui for precise coordinates (don't guess from screenshots). Supports configurable timing.",
124 |   schema: swipeSchema.shape, // MCP SDK compatibility
125 |   handler: createTypedTool(
126 |     swipeSchema,
127 |     (params: SwipeParams, executor: CommandExecutor) => {
128 |       return swipeLogic(params, executor, {
129 |         getAxePath,
130 |         getBundledAxeEnvironment,
131 |         createAxeNotAvailableResponse,
132 |       });
133 |     },
134 |     getDefaultCommandExecutor,
135 |   ),
136 | };
137 | 
138 | // Session tracking for describe_ui warnings
139 | interface DescribeUISession {
140 |   timestamp: number;
141 |   simulatorUuid: string;
142 | }
143 | 
144 | const describeUITimestamps = new Map<string, DescribeUISession>();
145 | const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds
146 | 
147 | function getCoordinateWarning(simulatorUuid: string): string | null {
148 |   const session = describeUITimestamps.get(simulatorUuid);
149 |   if (!session) {
150 |     return 'Warning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.';
151 |   }
152 | 
153 |   const timeSinceDescribe = Date.now() - session.timestamp;
154 |   if (timeSinceDescribe > DESCRIBE_UI_WARNING_TIMEOUT) {
155 |     const secondsAgo = Math.round(timeSinceDescribe / 1000);
156 |     return `Warning: describe_ui was last called ${secondsAgo} seconds ago. Consider refreshing UI coordinates with describe_ui instead of using potentially stale coordinates.`;
157 |   }
158 | 
159 |   return null;
160 | }
161 | 
162 | // Helper function for executing axe commands (inlined from src/tools/axe/index.ts)
163 | async function executeAxeCommand(
164 |   commandArgs: string[],
165 |   simulatorUuid: string,
166 |   commandName: string,
167 |   executor: CommandExecutor = getDefaultCommandExecutor(),
168 |   axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse },
169 | ): Promise<void> {
170 |   // Get the appropriate axe binary path
171 |   const axeBinary = axeHelpers.getAxePath();
172 |   if (!axeBinary) {
173 |     throw new DependencyError('AXe binary not found');
174 |   }
175 | 
176 |   // Add --udid parameter to all commands
177 |   const fullArgs = [...commandArgs, '--udid', simulatorUuid];
178 | 
179 |   // Construct the full command array with the axe binary as the first element
180 |   const fullCommand = [axeBinary, ...fullArgs];
181 | 
182 |   try {
183 |     // Determine environment variables for bundled AXe
184 |     const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined;
185 | 
186 |     const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv);
187 | 
188 |     if (!result.success) {
189 |       throw new AxeError(
190 |         `axe command '${commandName}' failed.`,
191 |         commandName,
192 |         result.error ?? result.output,
193 |         simulatorUuid,
194 |       );
195 |     }
196 | 
197 |     // Check for stderr output in successful commands
198 |     if (result.error) {
199 |       log(
200 |         'warn',
201 |         `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`,
202 |       );
203 |     }
204 | 
205 |     // Function now returns void - the calling code creates its own response
206 |   } catch (error) {
207 |     if (error instanceof Error) {
208 |       if (error instanceof AxeError) {
209 |         throw error;
210 |       }
211 | 
212 |       // Otherwise wrap it in a SystemError
213 |       throw new SystemError(`Failed to execute axe command: ${error.message}`, error);
214 |     }
215 | 
216 |     // For any other type of error
217 |     throw new SystemError(`Failed to execute axe command: ${String(error)}`);
218 |   }
219 | }
220 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/ui-testing/gesture.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * UI Testing Plugin: Gesture
  3 |  *
  4 |  * Perform gesture on iOS simulator using preset gestures: scroll-up, scroll-down, scroll-left, scroll-right,
  5 |  * swipe-from-left-edge, swipe-from-right-edge, swipe-from-top-edge, swipe-from-bottom-edge.
  6 |  */
  7 | 
  8 | import { z } from 'zod';
  9 | import { ToolResponse } from '../../../types/common.ts';
 10 | import { log } from '../../../utils/logging/index.ts';
 11 | import {
 12 |   createTextResponse,
 13 |   createErrorResponse,
 14 |   DependencyError,
 15 |   AxeError,
 16 |   SystemError,
 17 | } from '../../../utils/responses/index.ts';
 18 | import type { CommandExecutor } from '../../../utils/execution/index.ts';
 19 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
 20 | import {
 21 |   createAxeNotAvailableResponse,
 22 |   getAxePath,
 23 |   getBundledAxeEnvironment,
 24 | } from '../../../utils/axe/index.ts';
 25 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
 26 | 
 27 | // Define schema as ZodObject
 28 | const gestureSchema = z.object({
 29 |   simulatorUuid: z.string().uuid('Invalid Simulator UUID format'),
 30 |   preset: z
 31 |     .enum([
 32 |       'scroll-up',
 33 |       'scroll-down',
 34 |       'scroll-left',
 35 |       'scroll-right',
 36 |       'swipe-from-left-edge',
 37 |       'swipe-from-right-edge',
 38 |       'swipe-from-top-edge',
 39 |       'swipe-from-bottom-edge',
 40 |     ])
 41 |     .describe(
 42 |       'The gesture preset to perform. Must be one of: scroll-up, scroll-down, scroll-left, scroll-right, swipe-from-left-edge, swipe-from-right-edge, swipe-from-top-edge, swipe-from-bottom-edge.',
 43 |     ),
 44 |   screenWidth: z
 45 |     .number()
 46 |     .int()
 47 |     .min(1)
 48 |     .optional()
 49 |     .describe(
 50 |       'Optional: Screen width in pixels. Used for gesture calculations. Auto-detected if not provided.',
 51 |     ),
 52 |   screenHeight: z
 53 |     .number()
 54 |     .int()
 55 |     .min(1)
 56 |     .optional()
 57 |     .describe(
 58 |       'Optional: Screen height in pixels. Used for gesture calculations. Auto-detected if not provided.',
 59 |     ),
 60 |   duration: z
 61 |     .number()
 62 |     .min(0, 'Duration must be non-negative')
 63 |     .optional()
 64 |     .describe('Optional: Duration of the gesture in seconds.'),
 65 |   delta: z
 66 |     .number()
 67 |     .min(0, 'Delta must be non-negative')
 68 |     .optional()
 69 |     .describe('Optional: Distance to move in pixels.'),
 70 |   preDelay: z
 71 |     .number()
 72 |     .min(0, 'Pre-delay must be non-negative')
 73 |     .optional()
 74 |     .describe('Optional: Delay before starting the gesture in seconds.'),
 75 |   postDelay: z
 76 |     .number()
 77 |     .min(0, 'Post-delay must be non-negative')
 78 |     .optional()
 79 |     .describe('Optional: Delay after completing the gesture in seconds.'),
 80 | });
 81 | 
 82 | // Use z.infer for type safety
 83 | type GestureParams = z.infer<typeof gestureSchema>;
 84 | 
 85 | export interface AxeHelpers {
 86 |   getAxePath: () => string | null;
 87 |   getBundledAxeEnvironment: () => Record<string, string>;
 88 |   createAxeNotAvailableResponse: () => ToolResponse;
 89 | }
 90 | 
 91 | const LOG_PREFIX = '[AXe]';
 92 | 
 93 | export async function gestureLogic(
 94 |   params: GestureParams,
 95 |   executor: CommandExecutor,
 96 |   axeHelpers: AxeHelpers = {
 97 |     getAxePath,
 98 |     getBundledAxeEnvironment,
 99 |     createAxeNotAvailableResponse,
100 |   },
101 | ): Promise<ToolResponse> {
102 |   const toolName = 'gesture';
103 |   const { simulatorUuid, preset, screenWidth, screenHeight, duration, delta, preDelay, postDelay } =
104 |     params;
105 |   const commandArgs = ['gesture', preset];
106 | 
107 |   if (screenWidth !== undefined) {
108 |     commandArgs.push('--screen-width', String(screenWidth));
109 |   }
110 |   if (screenHeight !== undefined) {
111 |     commandArgs.push('--screen-height', String(screenHeight));
112 |   }
113 |   if (duration !== undefined) {
114 |     commandArgs.push('--duration', String(duration));
115 |   }
116 |   if (delta !== undefined) {
117 |     commandArgs.push('--delta', String(delta));
118 |   }
119 |   if (preDelay !== undefined) {
120 |     commandArgs.push('--pre-delay', String(preDelay));
121 |   }
122 |   if (postDelay !== undefined) {
123 |     commandArgs.push('--post-delay', String(postDelay));
124 |   }
125 | 
126 |   log('info', `${LOG_PREFIX}/${toolName}: Starting gesture '${preset}' on ${simulatorUuid}`);
127 | 
128 |   try {
129 |     await executeAxeCommand(commandArgs, simulatorUuid, 'gesture', executor, axeHelpers);
130 |     log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`);
131 |     return createTextResponse(`Gesture '${preset}' executed successfully.`);
132 |   } catch (error) {
133 |     log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`);
134 |     if (error instanceof DependencyError) {
135 |       return axeHelpers.createAxeNotAvailableResponse();
136 |     } else if (error instanceof AxeError) {
137 |       return createErrorResponse(
138 |         `Failed to execute gesture '${preset}': ${error.message}`,
139 |         error.axeOutput,
140 |       );
141 |     } else if (error instanceof SystemError) {
142 |       return createErrorResponse(
143 |         `System error executing axe: ${error.message}`,
144 |         error.originalError?.stack,
145 |       );
146 |     }
147 |     return createErrorResponse(
148 |       `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`,
149 |     );
150 |   }
151 | }
152 | 
153 | export default {
154 |   name: 'gesture',
155 |   description:
156 |     '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',
157 |   schema: gestureSchema.shape, // MCP SDK compatibility
158 |   handler: createTypedTool(
159 |     gestureSchema,
160 |     (params: GestureParams, executor: CommandExecutor) => {
161 |       return gestureLogic(params, executor, {
162 |         getAxePath,
163 |         getBundledAxeEnvironment,
164 |         createAxeNotAvailableResponse,
165 |       });
166 |     },
167 |     getDefaultCommandExecutor,
168 |   ),
169 | };
170 | 
171 | // Helper function for executing axe commands (inlined from src/tools/axe/index.ts)
172 | async function executeAxeCommand(
173 |   commandArgs: string[],
174 |   simulatorUuid: string,
175 |   commandName: string,
176 |   executor: CommandExecutor = getDefaultCommandExecutor(),
177 |   axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse },
178 | ): Promise<void> {
179 |   // Get the appropriate axe binary path
180 |   const axeBinary = axeHelpers.getAxePath();
181 |   if (!axeBinary) {
182 |     throw new DependencyError('AXe binary not found');
183 |   }
184 | 
185 |   // Add --udid parameter to all commands
186 |   const fullArgs = [...commandArgs, '--udid', simulatorUuid];
187 | 
188 |   // Construct the full command array with the axe binary as the first element
189 |   const fullCommand = [axeBinary, ...fullArgs];
190 | 
191 |   try {
192 |     // Determine environment variables for bundled AXe
193 |     const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined;
194 | 
195 |     const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv);
196 | 
197 |     if (!result.success) {
198 |       throw new AxeError(
199 |         `axe command '${commandName}' failed.`,
200 |         commandName,
201 |         result.error ?? result.output,
202 |         simulatorUuid,
203 |       );
204 |     }
205 | 
206 |     // Check for stderr output in successful commands
207 |     if (result.error) {
208 |       log(
209 |         'warn',
210 |         `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`,
211 |       );
212 |     }
213 | 
214 |     // Function now returns void - the calling code creates its own response
215 |   } catch (error) {
216 |     if (error instanceof Error) {
217 |       if (error instanceof AxeError) {
218 |         throw error;
219 |       }
220 | 
221 |       // Otherwise wrap it in a SystemError
222 |       throw new SystemError(`Failed to execute axe command: ${error.message}`, error);
223 |     }
224 | 
225 |     // For any other type of error
226 |     throw new SystemError(`Failed to execute axe command: ${String(error)}`);
227 |   }
228 | }
229 | 
```

--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------

```yaml
  1 | name: Release
  2 | 
  3 | on:
  4 |   push:
  5 |     tags:
  6 |       - 'v*'
  7 |   workflow_dispatch:
  8 |     inputs:
  9 |       version:
 10 |         description: 'Test version (e.g., 1.9.1-test)'
 11 |         required: true
 12 |         type: string
 13 | 
 14 | permissions:
 15 |   contents: write
 16 |   id-token: write
 17 | 
 18 | jobs:
 19 |   release:
 20 |     runs-on: macos-latest
 21 |     
 22 |     steps:
 23 |       - name: Checkout code
 24 |         uses: actions/checkout@v4
 25 |         with:
 26 |           fetch-depth: 0
 27 | 
 28 |       - name: Setup Node.js
 29 |         uses: actions/setup-node@v4
 30 |         with:
 31 |           node-version: '24'
 32 |           registry-url: 'https://registry.npmjs.org'
 33 | 
 34 |       - name: Clear npm cache and install dependencies
 35 |         run: |
 36 |           npm cache clean --force
 37 |           rm -rf node_modules package-lock.json
 38 |           npm install --ignore-scripts
 39 | 
 40 |       - name: Check formatting
 41 |         run: npm run format:check
 42 | 
 43 |       - name: Bundle AXe artifacts
 44 |         run: npm run bundle:axe
 45 | 
 46 |       - name: Build TypeScript
 47 |         run: npm run build
 48 | 
 49 |       - name: Run tests
 50 |         run: npm test
 51 | 
 52 |       - name: Get version from tag or input
 53 |         id: get_version
 54 |         run: |
 55 |           if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
 56 |             VERSION="${{ github.event.inputs.version }}"
 57 |             echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
 58 |             echo "IS_TEST=true" >> $GITHUB_OUTPUT
 59 |             echo "📝 Test version: $VERSION"
 60 |             # Update package.json version for test releases only
 61 |             npm version $VERSION --no-git-tag-version
 62 |           else
 63 |             VERSION=${GITHUB_REF#refs/tags/v}
 64 |             echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
 65 |             echo "IS_TEST=false" >> $GITHUB_OUTPUT
 66 |             echo "🚀 Release version: $VERSION"
 67 |             # For tag-based releases, package.json was already updated by release script
 68 |           fi
 69 | 
 70 |       - name: Create package
 71 |         run: npm pack
 72 | 
 73 |       - name: Test publish (dry run for manual triggers)
 74 |         if: github.event_name == 'workflow_dispatch'
 75 |         run: |
 76 |           echo "🧪 Testing package creation (dry run)"
 77 |           npm publish --dry-run --access public
 78 | 
 79 |       - name: Publish to NPM (production releases only)
 80 |         if: github.event_name == 'push'
 81 |         run: |
 82 |           VERSION="${{ steps.get_version.outputs.VERSION }}"
 83 |           # Skip if this exact version is already published (idempotent reruns)
 84 |           if npm view xcodebuildmcp@"$VERSION" version >/dev/null 2>&1; then
 85 |             echo "✅ xcodebuildmcp@$VERSION already on NPM. Skipping publish."
 86 |             exit 0
 87 |           fi
 88 |           # Determine the appropriate npm tag based on version
 89 |           if [[ "$VERSION" == *"-beta"* ]]; then
 90 |             NPM_TAG="beta"
 91 |           elif [[ "$VERSION" == *"-alpha"* ]]; then
 92 |             NPM_TAG="alpha"
 93 |           elif [[ "$VERSION" == *"-rc"* ]]; then
 94 |             NPM_TAG="rc"
 95 |           else
 96 |             # For stable releases, explicitly use latest tag
 97 |             NPM_TAG="latest"
 98 |           fi
 99 |           echo "📦 Publishing to NPM with tag: $NPM_TAG"
100 |           npm publish --access public --tag "$NPM_TAG"
101 |         env:
102 |           NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
103 | 
104 |       - name: Create GitHub Release (production releases only)
105 |         if: github.event_name == 'push'
106 |         uses: softprops/action-gh-release@v1
107 |         with:
108 |           tag_name: v${{ steps.get_version.outputs.VERSION }}
109 |           name: Release v${{ steps.get_version.outputs.VERSION }}
110 |           body: |
111 |             ## Release v${{ steps.get_version.outputs.VERSION }}
112 |             
113 |             ### Features
114 |             - Bundled AXe binary and frameworks for zero-setup UI automation
115 |             - No manual installation required - works out of the box
116 |             
117 |             ### Installation
118 |             ```bash
119 |             npm install -g xcodebuildmcp@${{ steps.get_version.outputs.VERSION }}
120 |             ```
121 |             
122 |             Or use with npx:
123 |             ```bash
124 |             npx xcodebuildmcp@${{ steps.get_version.outputs.VERSION }}
125 |             ```
126 |             
127 |             📦 **NPM Package**: https://www.npmjs.com/package/xcodebuildmcp/v/${{ steps.get_version.outputs.VERSION }}
128 |             
129 |             ### What's Included
130 |             - Latest AXe binary from [cameroncooke/axe](https://github.com/cameroncooke/axe)
131 |             - All required frameworks (FBControlCore, FBDeviceControl, FBSimulatorControl, XCTestBootstrap)
132 |             - Full XcodeBuildMCP functionality with UI automation support
133 |           files: |
134 |             xcodebuildmcp-${{ steps.get_version.outputs.VERSION }}.tgz
135 |           draft: false
136 |           prerelease: false
137 | 
138 |       - name: Summary
139 |         run: |
140 |           if [ "${{ steps.get_version.outputs.IS_TEST }}" = "true" ]; then
141 |             echo "🧪 Test completed for version: ${{ steps.get_version.outputs.VERSION }}"
142 |             echo "Ready for production release!"
143 |           else
144 |             echo "🎉 Production release completed!"
145 |             echo "Version: ${{ steps.get_version.outputs.VERSION }}"
146 |             echo "📦 NPM: https://www.npmjs.com/package/xcodebuildmcp/v/${{ steps.get_version.outputs.VERSION }}"
147 |             echo "📚 MCP Registry: publish attempted in separate job (mcp_registry)"
148 |           fi
149 | 
150 |   mcp_registry:
151 |     if: github.event_name == 'push'
152 |     needs: release
153 |     runs-on: ubuntu-latest
154 |     env:
155 |       MCP_DNS_PRIVATE_KEY: ${{ secrets.MCP_DNS_PRIVATE_KEY }}
156 |     steps:
157 |       - name: Checkout code
158 |         uses: actions/checkout@v4
159 |         with:
160 |           fetch-depth: 0
161 | 
162 |       - name: Get version from tag
163 |         id: get_version_mcp
164 |         run: |
165 |           VERSION=${GITHUB_REF#refs/tags/v}
166 |           echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
167 |           echo "🚢 MCP publish for version: $VERSION"
168 | 
169 |       - name: Missing secret — skip MCP publish
170 |         if: env.MCP_DNS_PRIVATE_KEY == ''
171 |         run: |
172 |           echo "⚠️ Skipping MCP Registry publish: secrets.MCP_DNS_PRIVATE_KEY is not set."
173 |           echo "This is optional and does not affect the release."
174 | 
175 |       - name: Setup Go (for MCP Publisher)
176 |         if: env.MCP_DNS_PRIVATE_KEY != ''
177 |         uses: actions/setup-go@v5
178 |         with:
179 |           go-version: '1.22'
180 | 
181 |       - name: Install MCP Publisher
182 |         if: env.MCP_DNS_PRIVATE_KEY != ''
183 |         run: |
184 |           echo "📥 Fetching MCP Publisher"
185 |           git clone https://github.com/modelcontextprotocol/registry publisher-repo
186 |           cd publisher-repo
187 |           make publisher
188 |           cp bin/mcp-publisher ../mcp-publisher
189 |           cd ..
190 |           chmod +x mcp-publisher
191 | 
192 |       - name: Login to MCP Registry (DNS)
193 |         if: env.MCP_DNS_PRIVATE_KEY != ''
194 |         run: |
195 |           echo "🔐 Using DNS authentication for com.xcodebuildmcp/* namespace"
196 |           ./mcp-publisher login dns --domain xcodebuildmcp.com --private-key "${MCP_DNS_PRIVATE_KEY}"
197 | 
198 |       - name: Publish to MCP Registry (best-effort)
199 |         if: env.MCP_DNS_PRIVATE_KEY != ''
200 |         run: |
201 |           echo "🚢 Publishing to MCP Registry with retries..."
202 |           attempts=0
203 |           max_attempts=5
204 |           delay=5
205 |           until ./mcp-publisher publish; do
206 |             rc=$?
207 |             attempts=$((attempts+1))
208 |             if [ $attempts -ge $max_attempts ]; then
209 |               echo "⚠️ MCP Registry publish failed after $attempts attempts (exit $rc). Skipping without failing workflow."
210 |               exit 0
211 |             fi
212 |             echo "⚠️ Publish failed (exit $rc). Retrying in ${delay}s... (attempt ${attempts}/${max_attempts})"
213 |             sleep $delay
214 |             delay=$((delay*2))
215 |           done
216 |           echo "✅ MCP Registry publish succeeded."
217 | 
```

--------------------------------------------------------------------------------
/src/utils/__tests__/build-utils.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Tests for build-utils Sentry classification logic
  3 |  */
  4 | 
  5 | import { describe, it, expect } from 'vitest';
  6 | import { createMockExecutor } from '../../test-utils/mock-executors.ts';
  7 | import { executeXcodeBuildCommand } from '../build-utils.ts';
  8 | import { XcodePlatform } from '../xcode.ts';
  9 | 
 10 | describe('build-utils Sentry Classification', () => {
 11 |   const mockPlatformOptions = {
 12 |     platform: XcodePlatform.macOS,
 13 |     logPrefix: 'Test Build',
 14 |   };
 15 | 
 16 |   const mockParams = {
 17 |     scheme: 'TestScheme',
 18 |     configuration: 'Debug',
 19 |     projectPath: '/path/to/project.xcodeproj',
 20 |   };
 21 | 
 22 |   describe('Exit Code 64 Classification (MCP Error)', () => {
 23 |     it('should trigger Sentry logging for exit code 64 (invalid arguments)', async () => {
 24 |       const mockExecutor = createMockExecutor({
 25 |         success: false,
 26 |         error: 'xcodebuild: error: invalid option',
 27 |         exitCode: 64,
 28 |       });
 29 | 
 30 |       const result = await executeXcodeBuildCommand(
 31 |         mockParams,
 32 |         mockPlatformOptions,
 33 |         false,
 34 |         'build',
 35 |         mockExecutor,
 36 |       );
 37 | 
 38 |       expect(result.isError).toBe(true);
 39 |       expect(result.content[0].text).toContain('❌ [stderr] xcodebuild: error: invalid option');
 40 |       expect(result.content[1].text).toContain('❌ Test Build build failed for scheme TestScheme');
 41 |     });
 42 |   });
 43 | 
 44 |   describe('Other Exit Codes Classification (User Error)', () => {
 45 |     it('should not trigger Sentry logging for exit code 65 (user error)', async () => {
 46 |       const mockExecutor = createMockExecutor({
 47 |         success: false,
 48 |         error: 'Scheme TestScheme was not found',
 49 |         exitCode: 65,
 50 |       });
 51 | 
 52 |       const result = await executeXcodeBuildCommand(
 53 |         mockParams,
 54 |         mockPlatformOptions,
 55 |         false,
 56 |         'build',
 57 |         mockExecutor,
 58 |       );
 59 | 
 60 |       expect(result.isError).toBe(true);
 61 |       expect(result.content[0].text).toContain('❌ [stderr] Scheme TestScheme was not found');
 62 |       expect(result.content[1].text).toContain('❌ Test Build build failed for scheme TestScheme');
 63 |     });
 64 | 
 65 |     it('should not trigger Sentry logging for exit code 66 (file not found)', async () => {
 66 |       const mockExecutor = createMockExecutor({
 67 |         success: false,
 68 |         error: 'project.xcodeproj cannot be opened',
 69 |         exitCode: 66,
 70 |       });
 71 | 
 72 |       const result = await executeXcodeBuildCommand(
 73 |         mockParams,
 74 |         mockPlatformOptions,
 75 |         false,
 76 |         'build',
 77 |         mockExecutor,
 78 |       );
 79 | 
 80 |       expect(result.isError).toBe(true);
 81 |       expect(result.content[0].text).toContain('❌ [stderr] project.xcodeproj cannot be opened');
 82 |     });
 83 | 
 84 |     it('should not trigger Sentry logging for exit code 70 (destination error)', async () => {
 85 |       const mockExecutor = createMockExecutor({
 86 |         success: false,
 87 |         error: 'Unable to find a destination matching the provided destination specifier',
 88 |         exitCode: 70,
 89 |       });
 90 | 
 91 |       const result = await executeXcodeBuildCommand(
 92 |         mockParams,
 93 |         mockPlatformOptions,
 94 |         false,
 95 |         'build',
 96 |         mockExecutor,
 97 |       );
 98 | 
 99 |       expect(result.isError).toBe(true);
100 |       expect(result.content[0].text).toContain('❌ [stderr] Unable to find a destination matching');
101 |     });
102 | 
103 |     it('should not trigger Sentry logging for exit code 1 (general build failure)', async () => {
104 |       const mockExecutor = createMockExecutor({
105 |         success: false,
106 |         error: 'Build failed with errors',
107 |         exitCode: 1,
108 |       });
109 | 
110 |       const result = await executeXcodeBuildCommand(
111 |         mockParams,
112 |         mockPlatformOptions,
113 |         false,
114 |         'build',
115 |         mockExecutor,
116 |       );
117 | 
118 |       expect(result.isError).toBe(true);
119 |       expect(result.content[0].text).toContain('❌ [stderr] Build failed with errors');
120 |     });
121 |   });
122 | 
123 |   describe('Spawn Error Classification (Environment Error)', () => {
124 |     it('should not trigger Sentry logging for ENOENT spawn error', async () => {
125 |       const spawnError = new Error('spawn xcodebuild ENOENT') as NodeJS.ErrnoException;
126 |       spawnError.code = 'ENOENT';
127 | 
128 |       const mockExecutor = createMockExecutor({
129 |         success: false,
130 |         error: '',
131 |         shouldThrow: spawnError,
132 |       });
133 | 
134 |       const result = await executeXcodeBuildCommand(
135 |         mockParams,
136 |         mockPlatformOptions,
137 |         false,
138 |         'build',
139 |         mockExecutor,
140 |       );
141 | 
142 |       expect(result.isError).toBe(true);
143 |       expect(result.content[0].text).toContain(
144 |         'Error during Test Build build: spawn xcodebuild ENOENT',
145 |       );
146 |     });
147 | 
148 |     it('should not trigger Sentry logging for EACCES spawn error', async () => {
149 |       const spawnError = new Error('spawn xcodebuild EACCES') as NodeJS.ErrnoException;
150 |       spawnError.code = 'EACCES';
151 | 
152 |       const mockExecutor = createMockExecutor({
153 |         success: false,
154 |         error: '',
155 |         shouldThrow: spawnError,
156 |       });
157 | 
158 |       const result = await executeXcodeBuildCommand(
159 |         mockParams,
160 |         mockPlatformOptions,
161 |         false,
162 |         'build',
163 |         mockExecutor,
164 |       );
165 | 
166 |       expect(result.isError).toBe(true);
167 |       expect(result.content[0].text).toContain(
168 |         'Error during Test Build build: spawn xcodebuild EACCES',
169 |       );
170 |     });
171 | 
172 |     it('should not trigger Sentry logging for EPERM spawn error', async () => {
173 |       const spawnError = new Error('spawn xcodebuild EPERM') as NodeJS.ErrnoException;
174 |       spawnError.code = 'EPERM';
175 | 
176 |       const mockExecutor = createMockExecutor({
177 |         success: false,
178 |         error: '',
179 |         shouldThrow: spawnError,
180 |       });
181 | 
182 |       const result = await executeXcodeBuildCommand(
183 |         mockParams,
184 |         mockPlatformOptions,
185 |         false,
186 |         'build',
187 |         mockExecutor,
188 |       );
189 | 
190 |       expect(result.isError).toBe(true);
191 |       expect(result.content[0].text).toContain(
192 |         'Error during Test Build build: spawn xcodebuild EPERM',
193 |       );
194 |     });
195 | 
196 |     it('should trigger Sentry logging for non-spawn exceptions', async () => {
197 |       const otherError = new Error('Unexpected internal error');
198 | 
199 |       const mockExecutor = createMockExecutor({
200 |         success: false,
201 |         error: '',
202 |         shouldThrow: otherError,
203 |       });
204 | 
205 |       const result = await executeXcodeBuildCommand(
206 |         mockParams,
207 |         mockPlatformOptions,
208 |         false,
209 |         'build',
210 |         mockExecutor,
211 |       );
212 | 
213 |       expect(result.isError).toBe(true);
214 |       expect(result.content[0].text).toContain(
215 |         'Error during Test Build build: Unexpected internal error',
216 |       );
217 |     });
218 |   });
219 | 
220 |   describe('Success Case (No Sentry Logging)', () => {
221 |     it('should not trigger any error logging for successful builds', async () => {
222 |       const mockExecutor = createMockExecutor({
223 |         success: true,
224 |         output: 'BUILD SUCCEEDED',
225 |         exitCode: 0,
226 |       });
227 | 
228 |       const result = await executeXcodeBuildCommand(
229 |         mockParams,
230 |         mockPlatformOptions,
231 |         false,
232 |         'build',
233 |         mockExecutor,
234 |       );
235 | 
236 |       expect(result.isError).toBeFalsy();
237 |       expect(result.content[0].text).toContain(
238 |         '✅ Test Build build succeeded for scheme TestScheme',
239 |       );
240 |     });
241 |   });
242 | 
243 |   describe('Exit Code Undefined Cases', () => {
244 |     it('should not trigger Sentry logging when exitCode is undefined', async () => {
245 |       const mockExecutor = createMockExecutor({
246 |         success: false,
247 |         error: 'Some error without exit code',
248 |         exitCode: undefined,
249 |       });
250 | 
251 |       const result = await executeXcodeBuildCommand(
252 |         mockParams,
253 |         mockPlatformOptions,
254 |         false,
255 |         'build',
256 |         mockExecutor,
257 |       );
258 | 
259 |       expect(result.isError).toBe(true);
260 |       expect(result.content[0].text).toContain('❌ [stderr] Some error without exit code');
261 |     });
262 |   });
263 | });
264 | 
```

--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Changelog
  2 | 
  3 | ## [1.14.0] - 2025-09-22
  4 | - Add video capture tool for simulators
  5 | 
  6 | ## [1.13.1] - 2025-09-21
  7 | - Add simulator erase content and settings tool
  8 | 
  9 | ## [1.12.3] - 2025-08-22
 10 | - Pass environment variables to test runs on device, simulator, and macOS via an optional testRunnerEnv input (auto-prefixed as TEST_RUNNER_).
 11 | 
 12 | ## [1.12.2] - 2025-08-21
 13 | ### Fixed
 14 | - **Clean tool**: Fixed issue where clean would fail for simulators
 15 | 
 16 | ## [1.12.1] - 2025-08-18
 17 | ### Improved
 18 | - **Sentry Logging**: No longer logs domain errors to Sentry, now only logs MCP server errors.
 19 | 
 20 | ## [1.12.0] - 2025-08-17
 21 | ### Added
 22 | - Unify project/workspace and sim id/name tools into a single tools reducing the number of tools from 81 to 59, this helps reduce the client agent's context window size by 27%!
 23 | - **Selective Workflow Loading**: New `XCODEBUILDMCP_ENABLED_WORKFLOWS` environment variable allows loading only specific workflow groups in static mode, reducing context window usage for clients that don't support MCP sampling (Thanks to @codeman9 for their first contribution!)
 24 | - Rename `diagnosics` tool and cli to `doctor`
 25 | - Add Sentry instrumentation to track MCP usage statistics (can be disabled by setting `XCODEBUILDMCP_SENTRY_DISABLED=true`)
 26 | - Add support for MCP setLevel handler to allow clients to control the log level of the MCP server
 27 | 
 28 | ## [v1.11.2] - 2025-08-08
 29 | - Fixed "registerTools is not a function" errors during package upgrades
 30 | 
 31 | ## [v1.11.1] - 2025-08-07
 32 | - Improved tool discovery to be more accurate and context-aware
 33 | 
 34 | ## [v1.11.0] - 2025-08-07
 35 | - Major refactor/rewrite to improve code quality and maintainability in preparation for future development
 36 | - Added support for dynamic tools (VSCode only for now)
 37 | - Added support for MCP Resources (devices, simulators, environment info)
 38 | - Workaround for https://github.com/cameroncooke/XcodeBuildMCP/issues/66 and https://github.com/anthropics/claude-code/issues/1804 issues where Claude Code would only see the first text content from tool responses
 39 | 
 40 | ## [v1.10.0] - 2025-06-10
 41 | ### Added
 42 | - **App Lifecycle Management**: New tools for stopping running applications
 43 |   - `stop_app_device`: Stop apps running on physical Apple devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro)
 44 |   - `stop_app_sim`: Stop apps running on iOS/watchOS/tvOS/visionOS simulators
 45 |   - `stop_mac_app`: Stop macOS applications by name or process ID
 46 | - **Enhanced Launch Tools**: Device launch tools now return process IDs for better app management
 47 | - **Bundled AXe Distribution**: AXe binary and frameworks now included in npm package for zero-setup UI automation
 48 | 
 49 | ### Fixed
 50 | - **WiFi Device Detection**: Improved detection of Apple devices connected over WiFi networks
 51 | - **Device Connectivity**: Better handling of paired devices with different connection states
 52 | 
 53 | ### Improved
 54 | - **Simplified Installation**: No separate AXe installation required - everything works out of the box
 55 | 
 56 | ## [v1.9.0] - 2025-06-09
 57 | - Added support for hardware devices over USB and Wi-Fi
 58 | - New tools for Apple device deployment:
 59 |   - `install_app_device`
 60 |   - `launch_app_device`
 61 | - Updated all simulator and device tools to be platform-agnostic, supporting all Apple platforms (iOS, iPadOS, watchOS, tvOS, visionOS)
 62 | - Changed `get_ios_bundle_id` to `get_app_bundle_id` with support for all Apple platforms
 63 | 
 64 | ## [v1.8.0] - 2025-06-07
 65 | - Added support for running tests on macOS, iOS simulators, and iOS devices
 66 | - New tools for testing:
 67 |   - `test_macos_workspace`
 68 |   - `test_macos_project`
 69 |   - `test_ios_simulator_name_workspace`
 70 |   - `test_ios_simulator_name_project`
 71 |   - `test_ios_simulator_id_workspace`
 72 |   - `test_ios_simulator_id_project`
 73 |   - `test_ios_device_workspace`
 74 |   - `test_ios_device_project`
 75 | 
 76 | ## [v1.7.0] - 2025-06-04
 77 | - Added support for Swift Package Manager (SPM)
 78 | - New tools for Swift Package Manager:
 79 |   - `swift_package_build`
 80 |   - `swift_package_clean`
 81 |   - `swift_package_test`
 82 |   - `swift_package_run`
 83 |   - `swift_package_list`
 84 |   - `swift_package_stop`
 85 | 
 86 | ## [v1.6.1] - 2025-06-03
 87 | - Improve UI tool hints
 88 | 
 89 | ## [v1.6.0] - 2025-06-03
 90 | - Moved project templates to external GitHub repositories for independent versioning
 91 | - Added support for downloading templates from GitHub releases
 92 | - Added local template override support via environment variables
 93 | - Added `scaffold_ios_project` and `scaffold_macos_project` tools for creating new projects
 94 | - Centralized template version management in package.json for easier updates
 95 | 
 96 | ## [v1.5.0] - 2025-06-01
 97 | - UI automation is no longer in beta!
 98 | - Added support for AXe UI automation
 99 | - Revised default installation instructions to prefer npx instead of mise
100 | 
101 | ## [v1.4.0] - 2025-05-11
102 | - Merge the incremental build beta branch into main
103 | - Add preferXcodebuild argument to build tools with improved error handling allowing the agent to force the use of xcodebuild over xcodemake for complex projects. It also adds a hint when incremental builds fail due to non-compiler errors, enabling the agent to automatically switch to xcodebuild for a recovery build attempt, improving reliability.
104 | 
105 | ## [v1.3.7] - 2025-05-08
106 | - Fix Claude Code issue due to long tool names
107 | 
108 | ## [v1.4.0-beta.3] - 2025-05-07
109 | - Fixed issue where incremental builds would only work for "Debug" build configurations
110 | -
111 | ## [v1.4.0-beta.2] - 2025-05-07
112 | - Same as beta 1 but has the latest features from the main release channel
113 | 
114 | ## [v1.4.0-beta.1] - 2025-05-05
115 | - Added experimental support for incremental builds (requires opt-in)
116 | 
117 | ## [v1.3.6] - 2025-05-07
118 | - Added support for enabling/disabling tools via environment variables
119 | 
120 | ## [v1.3.5] - 2025-05-05
121 | - Fixed the text input UI automation tool
122 | - Improve the UI automation tool hints to reduce agent tool call errors
123 | - Improved the project discovery tool to reduce agent tool call errors
124 | - Added instructions for installing idb client manually
125 | 
126 | ## [v1.3.4] - 2025-05-04
127 | - Improved Sentry integration
128 | 
129 | ## [v1.3.3] - 2025-05-04
130 | - Added Sentry opt-out functionality
131 | 
132 | ## [v1.3.1] - 2025-05-03
133 | - Added Sentry integration for error reporting
134 | 
135 | ## [v1.3.0] - 2025-04-28
136 | 
137 | - Added support for interacting with the simulator (tap, swipe etc.)
138 | - Added support for capturing simulator screenshots
139 | 
140 | Please note that the UI automation features are an early preview and currently in beta your mileage may vary.
141 | 
142 | ## [v1.2.4] - 2025-04-24
143 | - Improved xcodebuild reporting of warnings and errors in tool response
144 | - Refactor build utils and remove redundant code
145 | 
146 | ## [v1.2.3] - 2025-04-23
147 | - Added support for skipping macro validation
148 | 
149 | ## [v1.2.2] - 2025-04-23
150 | - Improved log readability with version information for easier debugging
151 | - Enhanced overall stability and performance
152 | 
153 | ## [v1.2.1] - 2025-04-23
154 | - General stability improvements and bug fixes
155 | 
156 | ## [v1.2.0] - 2025-04-14
157 | ### Added
158 | - New simulator log capture feature: Easily view and debug your app's logs while running in the simulator
159 | - Automatic project discovery: XcodeBuildMCP now finds your Xcode projects and workspaces automatically
160 | - Support for both Intel and Apple Silicon Macs in macOS builds
161 | 
162 | ### Improved
163 | - Cleaner, more readable build output with better error messages
164 | - Faster build times and more reliable build process
165 | - Enhanced documentation with clearer usage examples
166 | 
167 | ## [v1.1.0] - 2025-04-05
168 | ### Added
169 | - Real-time build progress reporting
170 | - Separate tools for iOS and macOS builds
171 | - Better workspace and project support
172 | 
173 | ### Improved
174 | - Simplified build commands with better parameter handling
175 | - More reliable clean operations for both projects and workspaces
176 | 
177 | ## [v1.0.2] - 2025-04-02
178 | - Improved documentation with better examples and clearer instructions
179 | - Easier version tracking for compatibility checks
180 | 
181 | ## [v1.0.1] - 2025-04-02
182 | - Initial release of XcodeBuildMCP
183 | - Basic support for building iOS and macOS applications
184 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, beforeEach } from 'vitest';
  2 | import { z } from 'zod';
  3 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
  4 | import { sessionStore } from '../../../../utils/session-store.ts';
  5 | import plugin, { stop_app_simLogic } from '../stop_app_sim.ts';
  6 | 
  7 | describe('stop_app_sim tool', () => {
  8 |   beforeEach(() => {
  9 |     sessionStore.clear();
 10 |   });
 11 | 
 12 |   describe('Export Field Validation (Literal)', () => {
 13 |     it('should expose correct metadata', () => {
 14 |       expect(plugin.name).toBe('stop_app_sim');
 15 |       expect(plugin.description).toBe('Stops an app running in an iOS simulator.');
 16 |     });
 17 | 
 18 |     it('should expose public schema with only bundleId', () => {
 19 |       const schema = z.object(plugin.schema);
 20 | 
 21 |       expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true);
 22 |       expect(schema.safeParse({}).success).toBe(false);
 23 |       expect(schema.safeParse({ bundleId: 42 }).success).toBe(false);
 24 |       expect(Object.keys(plugin.schema)).toEqual(['bundleId']);
 25 |     });
 26 |   });
 27 | 
 28 |   describe('Handler Requirements', () => {
 29 |     it('should require simulator identifier when not provided', async () => {
 30 |       const result = await plugin.handler({ bundleId: 'com.example.app' });
 31 | 
 32 |       expect(result.isError).toBe(true);
 33 |       expect(result.content[0].text).toContain('Missing required session defaults');
 34 |       expect(result.content[0].text).toContain('Provide simulatorId or simulatorName');
 35 |       expect(result.content[0].text).toContain('session-set-defaults');
 36 |     });
 37 | 
 38 |     it('should validate bundleId when simulatorId default exists', async () => {
 39 |       sessionStore.setDefaults({ simulatorId: 'SIM-UUID' });
 40 | 
 41 |       const result = await plugin.handler({});
 42 | 
 43 |       expect(result.isError).toBe(true);
 44 |       expect(result.content[0].text).toContain('Parameter validation failed');
 45 |       expect(result.content[0].text).toContain('bundleId: Required');
 46 |       expect(result.content[0].text).toContain(
 47 |         'Tip: set session defaults via session-set-defaults',
 48 |       );
 49 |     });
 50 | 
 51 |     it('should reject mutually exclusive simulator parameters', async () => {
 52 |       const result = await plugin.handler({
 53 |         simulatorId: 'SIM-UUID',
 54 |         simulatorName: 'iPhone 16',
 55 |         bundleId: 'com.example.app',
 56 |       });
 57 | 
 58 |       expect(result.isError).toBe(true);
 59 |       expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
 60 |       expect(result.content[0].text).toContain('simulatorId');
 61 |       expect(result.content[0].text).toContain('simulatorName');
 62 |     });
 63 |   });
 64 | 
 65 |   describe('Logic Behavior (Literal Returns)', () => {
 66 |     it('should stop app successfully with simulatorId', async () => {
 67 |       const mockExecutor = createMockExecutor({ success: true, output: '' });
 68 | 
 69 |       const result = await stop_app_simLogic(
 70 |         {
 71 |           simulatorId: 'test-uuid',
 72 |           bundleId: 'com.example.App',
 73 |         },
 74 |         mockExecutor,
 75 |       );
 76 | 
 77 |       expect(result).toEqual({
 78 |         content: [
 79 |           {
 80 |             type: 'text',
 81 |             text: '✅ App com.example.App stopped successfully in simulator test-uuid',
 82 |           },
 83 |         ],
 84 |       });
 85 |     });
 86 | 
 87 |     it('should stop app successfully when resolving simulatorName', async () => {
 88 |       let callCount = 0;
 89 |       const sequencedExecutor = async (command: string[]) => {
 90 |         callCount++;
 91 |         if (callCount === 1) {
 92 |           return {
 93 |             success: true,
 94 |             output: JSON.stringify({
 95 |               devices: {
 96 |                 'iOS 17.0': [
 97 |                   { name: 'iPhone 16', udid: 'resolved-uuid', isAvailable: true, state: 'Booted' },
 98 |                 ],
 99 |               },
100 |             }),
101 |             error: '',
102 |             process: {} as any,
103 |           };
104 |         }
105 |         return {
106 |           success: true,
107 |           output: '',
108 |           error: '',
109 |           process: {} as any,
110 |         };
111 |       };
112 | 
113 |       const result = await stop_app_simLogic(
114 |         {
115 |           simulatorName: 'iPhone 16',
116 |           bundleId: 'com.example.App',
117 |         },
118 |         sequencedExecutor,
119 |       );
120 | 
121 |       expect(result).toEqual({
122 |         content: [
123 |           {
124 |             type: 'text',
125 |             text: '✅ App com.example.App stopped successfully in simulator "iPhone 16" (resolved-uuid)',
126 |           },
127 |         ],
128 |       });
129 |     });
130 | 
131 |     it('should handle simulator lookup failure', async () => {
132 |       const listExecutor = createMockExecutor({
133 |         success: true,
134 |         output: JSON.stringify({ devices: {} }),
135 |         error: '',
136 |       });
137 | 
138 |       const result = await stop_app_simLogic(
139 |         {
140 |           simulatorName: 'Unknown Simulator',
141 |           bundleId: 'com.example.App',
142 |         },
143 |         listExecutor,
144 |       );
145 | 
146 |       expect(result).toEqual({
147 |         content: [
148 |           {
149 |             type: 'text',
150 |             text: 'Simulator named "Unknown Simulator" not found. Use list_sims to see available simulators.',
151 |           },
152 |         ],
153 |         isError: true,
154 |       });
155 |     });
156 | 
157 |     it('should handle simulator list command failure', async () => {
158 |       const listExecutor = createMockExecutor({
159 |         success: false,
160 |         output: '',
161 |         error: 'simctl list failed',
162 |       });
163 | 
164 |       const result = await stop_app_simLogic(
165 |         {
166 |           simulatorName: 'iPhone 16',
167 |           bundleId: 'com.example.App',
168 |         },
169 |         listExecutor,
170 |       );
171 | 
172 |       expect(result).toEqual({
173 |         content: [
174 |           {
175 |             type: 'text',
176 |             text: 'Failed to list simulators: simctl list failed',
177 |           },
178 |         ],
179 |         isError: true,
180 |       });
181 |     });
182 | 
183 |     it('should surface terminate failures', async () => {
184 |       const terminateExecutor = createMockExecutor({
185 |         success: false,
186 |         output: '',
187 |         error: 'Simulator not found',
188 |       });
189 | 
190 |       const result = await stop_app_simLogic(
191 |         {
192 |           simulatorId: 'invalid-uuid',
193 |           bundleId: 'com.example.App',
194 |         },
195 |         terminateExecutor,
196 |       );
197 | 
198 |       expect(result).toEqual({
199 |         content: [
200 |           {
201 |             type: 'text',
202 |             text: 'Stop app in simulator operation failed: Simulator not found',
203 |           },
204 |         ],
205 |         isError: true,
206 |       });
207 |     });
208 | 
209 |     it('should handle unexpected exceptions', async () => {
210 |       const throwingExecutor = async () => {
211 |         throw new Error('Unexpected error');
212 |       };
213 | 
214 |       const result = await stop_app_simLogic(
215 |         {
216 |           simulatorId: 'test-uuid',
217 |           bundleId: 'com.example.App',
218 |         },
219 |         throwingExecutor,
220 |       );
221 | 
222 |       expect(result).toEqual({
223 |         content: [
224 |           {
225 |             type: 'text',
226 |             text: 'Stop app in simulator operation failed: Unexpected error',
227 |           },
228 |         ],
229 |         isError: true,
230 |       });
231 |     });
232 | 
233 |     it('should call correct terminate command', async () => {
234 |       const calls: Array<{
235 |         command: string[];
236 |         description: string;
237 |         suppressErrorLogging: boolean;
238 |         timeout?: number;
239 |       }> = [];
240 | 
241 |       const trackingExecutor = async (
242 |         command: string[],
243 |         description: string,
244 |         suppressErrorLogging: boolean,
245 |         timeout?: number,
246 |       ) => {
247 |         calls.push({ command, description, suppressErrorLogging, timeout });
248 |         return {
249 |           success: true,
250 |           output: '',
251 |           error: undefined,
252 |           process: { pid: 12345 },
253 |         };
254 |       };
255 | 
256 |       await stop_app_simLogic(
257 |         {
258 |           simulatorId: 'test-uuid',
259 |           bundleId: 'com.example.App',
260 |         },
261 |         trackingExecutor,
262 |       );
263 | 
264 |       expect(calls).toEqual([
265 |         {
266 |           command: ['xcrun', 'simctl', 'terminate', 'test-uuid', 'com.example.App'],
267 |           description: 'Stop App in Simulator',
268 |           suppressErrorLogging: true,
269 |           timeout: undefined,
270 |         },
271 |       ]);
272 |     });
273 |   });
274 | });
275 | 
```

--------------------------------------------------------------------------------
/src/utils/xcodemake.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * xcodemake Utilities - Support for using xcodemake as an alternative build strategy
  3 |  *
  4 |  * This utility module provides functions for using xcodemake (https://github.com/johnno1962/xcodemake)
  5 |  * as an alternative build strategy for Xcode projects. xcodemake logs xcodebuild output to generate
  6 |  * a Makefile for an Xcode project, allowing for faster incremental builds using the "make" command.
  7 |  *
  8 |  * Responsibilities:
  9 |  * - Checking if xcodemake is enabled via environment variable
 10 |  * - Executing xcodemake commands with proper argument handling
 11 |  * - Converting xcodebuild arguments to xcodemake arguments
 12 |  * - Handling xcodemake-specific output and error reporting
 13 |  * - Auto-downloading xcodemake if enabled but not found
 14 |  */
 15 | 
 16 | import { log } from './logger.ts';
 17 | import { CommandResponse, getDefaultCommandExecutor } from './command.ts';
 18 | import { existsSync, readdirSync } from 'fs';
 19 | import * as path from 'path';
 20 | import * as os from 'os';
 21 | import * as fs from 'fs/promises';
 22 | 
 23 | // Environment variable to control xcodemake usage
 24 | export const XCODEMAKE_ENV_VAR = 'INCREMENTAL_BUILDS_ENABLED';
 25 | 
 26 | // Store the overridden path for xcodemake if needed
 27 | let overriddenXcodemakePath: string | null = null;
 28 | 
 29 | /**
 30 |  * Check if xcodemake is enabled via environment variable
 31 |  * @returns boolean indicating if xcodemake should be used
 32 |  */
 33 | export function isXcodemakeEnabled(): boolean {
 34 |   const envValue = process.env[XCODEMAKE_ENV_VAR];
 35 |   return envValue === '1' || envValue === 'true' || envValue === 'yes';
 36 | }
 37 | 
 38 | /**
 39 |  * Get the xcodemake command to use
 40 |  * @returns The command string for xcodemake
 41 |  */
 42 | function getXcodemakeCommand(): string {
 43 |   return overriddenXcodemakePath ?? 'xcodemake';
 44 | }
 45 | 
 46 | /**
 47 |  * Override the xcodemake command path
 48 |  * @param path Path to the xcodemake executable
 49 |  */
 50 | function overrideXcodemakeCommand(path: string): void {
 51 |   overriddenXcodemakePath = path;
 52 |   log('info', `Using overridden xcodemake path: ${path}`);
 53 | }
 54 | 
 55 | /**
 56 |  * Install xcodemake by downloading it from GitHub
 57 |  * @returns Promise resolving to boolean indicating if installation was successful
 58 |  */
 59 | async function installXcodemake(): Promise<boolean> {
 60 |   const tempDir = os.tmpdir();
 61 |   const xcodemakeDir = path.join(tempDir, 'xcodebuildmcp');
 62 |   const xcodemakePath = path.join(xcodemakeDir, 'xcodemake');
 63 | 
 64 |   log('info', `Attempting to install xcodemake to ${xcodemakePath}`);
 65 | 
 66 |   try {
 67 |     // Create directory if it doesn't exist
 68 |     await fs.mkdir(xcodemakeDir, { recursive: true });
 69 | 
 70 |     // Download the script
 71 |     log('info', 'Downloading xcodemake from GitHub...');
 72 |     const response = await fetch(
 73 |       'https://raw.githubusercontent.com/cameroncooke/xcodemake/main/xcodemake',
 74 |     );
 75 | 
 76 |     if (!response.ok) {
 77 |       throw new Error(`Failed to download xcodemake: ${response.status} ${response.statusText}`);
 78 |     }
 79 | 
 80 |     const scriptContent = await response.text();
 81 |     await fs.writeFile(xcodemakePath, scriptContent, 'utf8');
 82 | 
 83 |     // Make executable
 84 |     await fs.chmod(xcodemakePath, 0o755);
 85 |     log('info', 'Made xcodemake executable');
 86 | 
 87 |     // Override the command to use the direct path
 88 |     overrideXcodemakeCommand(xcodemakePath);
 89 | 
 90 |     return true;
 91 |   } catch (error) {
 92 |     log(
 93 |       'error',
 94 |       `Error installing xcodemake: ${error instanceof Error ? error.message : String(error)}`,
 95 |     );
 96 |     return false;
 97 |   }
 98 | }
 99 | 
100 | /**
101 |  * Check if xcodemake is installed and available. If enabled but not available, attempts to download it.
102 |  * @returns Promise resolving to boolean indicating if xcodemake is available
103 |  */
104 | export async function isXcodemakeAvailable(): Promise<boolean> {
105 |   // First check if xcodemake is enabled, if not, no need to check or install
106 |   if (!isXcodemakeEnabled()) {
107 |     log('debug', 'xcodemake is not enabled, skipping availability check');
108 |     return false;
109 |   }
110 | 
111 |   try {
112 |     // Check if we already have an overridden path
113 |     if (overriddenXcodemakePath && existsSync(overriddenXcodemakePath)) {
114 |       log('debug', `xcodemake found at overridden path: ${overriddenXcodemakePath}`);
115 |       return true;
116 |     }
117 | 
118 |     // Check if xcodemake is available in PATH
119 |     const result = await getDefaultCommandExecutor()(['which', 'xcodemake']);
120 |     if (result.success) {
121 |       log('debug', 'xcodemake found in PATH');
122 |       return true;
123 |     }
124 | 
125 |     // If not found, download and install it
126 |     log('info', 'xcodemake not found in PATH, attempting to download...');
127 |     const installed = await installXcodemake();
128 |     if (installed) {
129 |       log('info', 'xcodemake installed successfully');
130 |       return true;
131 |     } else {
132 |       log('warn', 'xcodemake installation failed');
133 |       return false;
134 |     }
135 |   } catch (error) {
136 |     log(
137 |       'error',
138 |       `Error checking for xcodemake: ${error instanceof Error ? error.message : String(error)}`,
139 |     );
140 |     return false;
141 |   }
142 | }
143 | 
144 | /**
145 |  * Check if a Makefile exists in the current directory
146 |  * @returns boolean indicating if a Makefile exists
147 |  */
148 | export function doesMakefileExist(projectDir: string): boolean {
149 |   return existsSync(`${projectDir}/Makefile`);
150 | }
151 | 
152 | /**
153 |  * Check if a Makefile log exists in the current directory
154 |  * @param projectDir Directory containing the Makefile
155 |  * @param command Command array to check for log file
156 |  * @returns boolean indicating if a Makefile log exists
157 |  */
158 | export function doesMakeLogFileExist(projectDir: string, command: string[]): boolean {
159 |   // Change to the project directory as xcodemake requires being in the project dir
160 |   const originalDir = process.cwd();
161 | 
162 |   try {
163 |     process.chdir(projectDir);
164 | 
165 |     // Construct the expected log filename
166 |     const xcodemakeCommand = ['xcodemake', ...command.slice(1)];
167 |     const escapedCommand = xcodemakeCommand.map((arg) => {
168 |       // Remove projectDir from arguments if present at the start
169 |       const prefix = projectDir + '/';
170 |       if (arg.startsWith(prefix)) {
171 |         return arg.substring(prefix.length);
172 |       }
173 |       return arg;
174 |     });
175 |     const commandString = escapedCommand.join(' ');
176 |     const logFileName = `${commandString}.log`;
177 |     log('debug', `Checking for Makefile log: ${logFileName} in directory: ${process.cwd()}`);
178 | 
179 |     // Read directory contents and check if the file exists
180 |     const files = readdirSync('.');
181 |     const exists = files.includes(logFileName);
182 |     log('debug', `Makefile log ${exists ? 'exists' : 'does not exist'}: ${logFileName}`);
183 |     return exists;
184 |   } catch (error) {
185 |     // Log potential errors like directory not found, permissions issues, etc.
186 |     log(
187 |       'error',
188 |       `Error checking for Makefile log: ${error instanceof Error ? error.message : String(error)}`,
189 |     );
190 |     return false;
191 |   } finally {
192 |     // Always restore the original directory
193 |     process.chdir(originalDir);
194 |   }
195 | }
196 | 
197 | /**
198 |  * Execute an xcodemake command to generate a Makefile
199 |  * @param buildArgs Build arguments to pass to xcodemake (without the 'xcodebuild' command)
200 |  * @param logPrefix Prefix for logging
201 |  * @returns Promise resolving to command response
202 |  */
203 | export async function executeXcodemakeCommand(
204 |   projectDir: string,
205 |   buildArgs: string[],
206 |   logPrefix: string,
207 | ): Promise<CommandResponse> {
208 |   // Change directory to project directory, this is needed for xcodemake to work
209 |   process.chdir(projectDir);
210 | 
211 |   const xcodemakeCommand = [getXcodemakeCommand(), ...buildArgs];
212 | 
213 |   // Remove projectDir from arguments
214 |   const command = xcodemakeCommand.map((arg) => arg.replace(projectDir + '/', ''));
215 | 
216 |   return getDefaultCommandExecutor()(command, logPrefix);
217 | }
218 | 
219 | /**
220 |  * Execute a make command for incremental builds
221 |  * @param projectDir Directory containing the Makefile
222 |  * @param logPrefix Prefix for logging
223 |  * @returns Promise resolving to command response
224 |  */
225 | export async function executeMakeCommand(
226 |   projectDir: string,
227 |   logPrefix: string,
228 | ): Promise<CommandResponse> {
229 |   const command = ['cd', projectDir, '&&', 'make'];
230 |   return getDefaultCommandExecutor()(command, logPrefix);
231 | }
232 | 
```

--------------------------------------------------------------------------------
/src/utils/validation.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Validation Utilities - Input validation and error response generation
  3 |  *
  4 |  * This utility module provides a comprehensive set of validation functions to ensure
  5 |  * that tool inputs meet expected requirements. It centralizes validation logic,
  6 |  * error message formatting, and response generation for consistent error handling
  7 |  * across the application.
  8 |  *
  9 |  * Responsibilities:
 10 |  * - Validating required parameters (validateRequiredParam)
 11 |  * - Checking parameters against allowed values (validateAllowedValues, validateEnumParam)
 12 |  * - Verifying file existence (validateFileExists)
 13 |  * - Validating logical conditions (validateCondition)
 14 |  * - Ensuring at least one of multiple parameters is provided (validateAtLeastOneParam)
 15 |  * - Creating standardized response objects for tools (createTextResponse)
 16 |  *
 17 |  * Using these validation utilities ensures consistent error messaging and helps
 18 |  * provide clear feedback to users when their inputs don't meet requirements.
 19 |  * The functions return ValidationResult objects that make it easy to chain
 20 |  * validations and generate appropriate responses.
 21 |  */
 22 | 
 23 | import * as fs from 'fs';
 24 | import { log } from './logger.ts';
 25 | import { ToolResponse, ValidationResult } from '../types/common.ts';
 26 | import { FileSystemExecutor } from './FileSystemExecutor.ts';
 27 | import { getDefaultEnvironmentDetector } from './environment.ts';
 28 | 
 29 | /**
 30 |  * Creates a text response with the given message
 31 |  * @param message The message to include in the response
 32 |  * @param isError Whether this is an error response
 33 |  * @returns A ToolResponse object with the message
 34 |  */
 35 | export function createTextResponse(message: string, isError = false): ToolResponse {
 36 |   return {
 37 |     content: [
 38 |       {
 39 |         type: 'text',
 40 |         text: message,
 41 |       },
 42 |     ],
 43 |     isError,
 44 |   };
 45 | }
 46 | 
 47 | /**
 48 |  * Validates that a required parameter is present
 49 |  * @param paramName Name of the parameter
 50 |  * @param paramValue Value of the parameter
 51 |  * @param helpfulMessage Optional helpful message to include in the error response
 52 |  * @returns Validation result
 53 |  */
 54 | export function validateRequiredParam(
 55 |   paramName: string,
 56 |   paramValue: unknown,
 57 |   helpfulMessage = `Required parameter '${paramName}' is missing. Please provide a value for this parameter.`,
 58 | ): ValidationResult {
 59 |   if (paramValue === undefined || paramValue === null) {
 60 |     log('warning', `Required parameter '${paramName}' is missing`);
 61 |     return {
 62 |       isValid: false,
 63 |       errorResponse: createTextResponse(helpfulMessage, true),
 64 |     };
 65 |   }
 66 | 
 67 |   return { isValid: true };
 68 | }
 69 | 
 70 | /**
 71 |  * Validates that a parameter value is one of the allowed values
 72 |  * @param paramName Name of the parameter
 73 |  * @param paramValue Value of the parameter
 74 |  * @param allowedValues Array of allowed values
 75 |  * @returns Validation result
 76 |  */
 77 | export function validateAllowedValues<T>(
 78 |   paramName: string,
 79 |   paramValue: T,
 80 |   allowedValues: T[],
 81 | ): ValidationResult {
 82 |   if (!allowedValues.includes(paramValue)) {
 83 |     log(
 84 |       'warning',
 85 |       `Parameter '${paramName}' has invalid value '${paramValue}'. Allowed values: ${allowedValues.join(
 86 |         ', ',
 87 |       )}`,
 88 |     );
 89 |     return {
 90 |       isValid: false,
 91 |       errorResponse: createTextResponse(
 92 |         `Parameter '${paramName}' must be one of: ${allowedValues.join(', ')}. You provided: '${paramValue}'.`,
 93 |         true,
 94 |       ),
 95 |     };
 96 |   }
 97 | 
 98 |   return { isValid: true };
 99 | }
100 | 
101 | /**
102 |  * Validates that a condition is true
103 |  * @param condition Condition to validate
104 |  * @param message Message to include in the warning response
105 |  * @param logWarning Whether to log a warning message
106 |  * @returns Validation result
107 |  */
108 | export function validateCondition(
109 |   condition: boolean,
110 |   message: string,
111 |   logWarning: boolean = true,
112 | ): ValidationResult {
113 |   if (!condition) {
114 |     if (logWarning) {
115 |       log('warning', message);
116 |     }
117 |     return {
118 |       isValid: false,
119 |       warningResponse: createTextResponse(message),
120 |     };
121 |   }
122 | 
123 |   return { isValid: true };
124 | }
125 | 
126 | /**
127 |  * Validates that a file exists
128 |  * @param filePath Path to check
129 |  * @returns Validation result
130 |  */
131 | export function validateFileExists(
132 |   filePath: string,
133 |   fileSystem?: FileSystemExecutor,
134 | ): ValidationResult {
135 |   const exists = fileSystem ? fileSystem.existsSync(filePath) : fs.existsSync(filePath);
136 |   if (!exists) {
137 |     return {
138 |       isValid: false,
139 |       errorResponse: createTextResponse(
140 |         `File not found: '${filePath}'. Please check the path and try again.`,
141 |         true,
142 |       ),
143 |     };
144 |   }
145 | 
146 |   return { isValid: true };
147 | }
148 | 
149 | /**
150 |  * Validates that at least one of two parameters is provided
151 |  * @param param1Name Name of the first parameter
152 |  * @param param1Value Value of the first parameter
153 |  * @param param2Name Name of the second parameter
154 |  * @param param2Value Value of the second parameter
155 |  * @returns Validation result
156 |  */
157 | export function validateAtLeastOneParam(
158 |   param1Name: string,
159 |   param1Value: unknown,
160 |   param2Name: string,
161 |   param2Value: unknown,
162 | ): ValidationResult {
163 |   if (
164 |     (param1Value === undefined || param1Value === null) &&
165 |     (param2Value === undefined || param2Value === null)
166 |   ) {
167 |     log('warning', `At least one of '${param1Name}' or '${param2Name}' must be provided`);
168 |     return {
169 |       isValid: false,
170 |       errorResponse: createTextResponse(
171 |         `At least one of '${param1Name}' or '${param2Name}' must be provided.`,
172 |         true,
173 |       ),
174 |     };
175 |   }
176 | 
177 |   return { isValid: true };
178 | }
179 | 
180 | /**
181 |  * Validates that a parameter value is one of the allowed enum values
182 |  * @param paramName Name of the parameter
183 |  * @param paramValue Value of the parameter
184 |  * @param allowedValues Array of allowed enum values
185 |  * @returns Validation result
186 |  */
187 | export function validateEnumParam<T>(
188 |   paramName: string,
189 |   paramValue: T,
190 |   allowedValues: T[],
191 | ): ValidationResult {
192 |   if (!allowedValues.includes(paramValue)) {
193 |     log(
194 |       'warning',
195 |       `Parameter '${paramName}' has invalid value '${paramValue}'. Allowed values: ${allowedValues.join(
196 |         ', ',
197 |       )}`,
198 |     );
199 |     return {
200 |       isValid: false,
201 |       errorResponse: createTextResponse(
202 |         `Parameter '${paramName}' must be one of: ${allowedValues.join(', ')}. You provided: '${paramValue}'.`,
203 |         true,
204 |       ),
205 |     };
206 |   }
207 | 
208 |   return { isValid: true };
209 | }
210 | 
211 | /**
212 |  * Consolidates multiple content blocks into a single text response for Claude Code compatibility
213 |  *
214 |  * Claude Code violates the MCP specification by only showing the first content block.
215 |  * This function provides a workaround by concatenating all text content into a single block.
216 |  * Detection is automatic - no environment variable configuration required.
217 |  *
218 |  * @param response The original ToolResponse with multiple content blocks
219 |  * @returns A new ToolResponse with consolidated content
220 |  */
221 | export function consolidateContentForClaudeCode(response: ToolResponse): ToolResponse {
222 |   // Automatically detect if running under Claude Code
223 |   const shouldConsolidate = getDefaultEnvironmentDetector().isRunningUnderClaudeCode();
224 | 
225 |   if (!shouldConsolidate || !response.content || response.content.length <= 1) {
226 |     return response;
227 |   }
228 | 
229 |   // Extract all text content and concatenate with separators
230 |   const textParts: string[] = [];
231 | 
232 |   response.content.forEach((item, index) => {
233 |     if (item.type === 'text') {
234 |       // Add a separator between content blocks (except for the first one)
235 |       if (index > 0 && textParts.length > 0) {
236 |         textParts.push('\n---\n');
237 |       }
238 |       textParts.push(item.text);
239 |     }
240 |     // Note: Image content is not handled in this workaround as it requires special formatting
241 |   });
242 | 
243 |   // If no text content was found, return the original response to preserve non-text content
244 |   if (textParts.length === 0) {
245 |     return response;
246 |   }
247 | 
248 |   const consolidatedText = textParts.join('');
249 | 
250 |   return {
251 |     ...response,
252 |     content: [
253 |       {
254 |         type: 'text',
255 |         text: consolidatedText,
256 |       },
257 |     ],
258 |   };
259 | }
260 | 
261 | // Export the ToolResponse type for use in other files
262 | export { ToolResponse, ValidationResult };
263 | 
```

--------------------------------------------------------------------------------
/src/utils/command.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Command Utilities - Generic command execution utilities
  3 |  *
  4 |  * This utility module provides functions for executing shell commands.
  5 |  * It serves as a foundation for other utility modules that need to execute commands.
  6 |  *
  7 |  * Responsibilities:
  8 |  * - Executing shell commands with proper argument handling
  9 |  * - Managing process spawning, output capture, and error handling
 10 |  */
 11 | 
 12 | import { spawn } from 'child_process';
 13 | import { existsSync } from 'fs';
 14 | import { tmpdir as osTmpdir } from 'os';
 15 | import { log } from './logger.ts';
 16 | import { FileSystemExecutor } from './FileSystemExecutor.ts';
 17 | import { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts';
 18 | 
 19 | // Re-export types for backward compatibility
 20 | export { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts';
 21 | export { FileSystemExecutor } from './FileSystemExecutor.ts';
 22 | 
 23 | /**
 24 |  * Default executor implementation using spawn (current production behavior)
 25 |  * Private instance - use getDefaultCommandExecutor() for access
 26 |  * @param command An array of command and arguments
 27 |  * @param logPrefix Prefix for logging
 28 |  * @param useShell Whether to use shell execution (true) or direct execution (false)
 29 |  * @param opts Optional execution options (env: environment variables to merge with process.env, cwd: working directory)
 30 |  * @param detached Whether to spawn process without waiting for completion (for streaming/background processes)
 31 |  * @returns Promise resolving to command response with the process
 32 |  */
 33 | async function defaultExecutor(
 34 |   command: string[],
 35 |   logPrefix?: string,
 36 |   useShell: boolean = true,
 37 |   opts?: CommandExecOptions,
 38 |   detached: boolean = false,
 39 | ): Promise<CommandResponse> {
 40 |   // Properly escape arguments for shell
 41 |   let escapedCommand = command;
 42 |   if (useShell) {
 43 |     // For shell execution, we need to format as ['sh', '-c', 'full command string']
 44 |     const commandString = command
 45 |       .map((arg) => {
 46 |         // Shell metacharacters that require quoting: space, quotes, equals, dollar, backticks, semicolons, pipes, etc.
 47 |         if (/[\s,"'=$`;&|<>(){}[\]\\*?~]/.test(arg) && !/^".*"$/.test(arg)) {
 48 |           // Escape all quotes and backslashes, then wrap in double quotes
 49 |           return `"${arg.replace(/(["\\])/g, '\\$1')}"`;
 50 |         }
 51 |         return arg;
 52 |       })
 53 |       .join(' ');
 54 | 
 55 |     escapedCommand = ['sh', '-c', commandString];
 56 |   }
 57 | 
 58 |   // Log the actual command that will be executed
 59 |   const displayCommand =
 60 |     useShell && escapedCommand.length === 3 ? escapedCommand[2] : escapedCommand.join(' ');
 61 |   log('info', `Executing ${logPrefix ?? ''} command: ${displayCommand}`);
 62 | 
 63 |   return new Promise((resolve, reject) => {
 64 |     const executable = escapedCommand[0];
 65 |     const args = escapedCommand.slice(1);
 66 | 
 67 |     const spawnOpts: Parameters<typeof spawn>[2] = {
 68 |       stdio: ['ignore', 'pipe', 'pipe'], // ignore stdin, pipe stdout/stderr
 69 |       env: { ...process.env, ...(opts?.env ?? {}) },
 70 |       cwd: opts?.cwd,
 71 |     };
 72 | 
 73 |     const childProcess = spawn(executable, args, spawnOpts);
 74 | 
 75 |     let stdout = '';
 76 |     let stderr = '';
 77 | 
 78 |     childProcess.stdout?.on('data', (data: Buffer) => {
 79 |       stdout += data.toString();
 80 |     });
 81 | 
 82 |     childProcess.stderr?.on('data', (data: Buffer) => {
 83 |       stderr += data.toString();
 84 |     });
 85 | 
 86 |     // For detached processes, handle differently to avoid race conditions
 87 |     if (detached) {
 88 |       // For detached processes, only wait for spawn success/failure
 89 |       let resolved = false;
 90 | 
 91 |       childProcess.on('error', (err) => {
 92 |         if (!resolved) {
 93 |           resolved = true;
 94 |           reject(err);
 95 |         }
 96 |       });
 97 | 
 98 |       // Give a small delay to ensure the process starts successfully
 99 |       setTimeout(() => {
100 |         if (!resolved) {
101 |           resolved = true;
102 |           if (childProcess.pid) {
103 |             resolve({
104 |               success: true,
105 |               output: '', // No output for detached processes
106 |               process: childProcess,
107 |             });
108 |           } else {
109 |             resolve({
110 |               success: false,
111 |               output: '',
112 |               error: 'Failed to start detached process',
113 |               process: childProcess,
114 |             });
115 |           }
116 |         }
117 |       }, 100);
118 |     } else {
119 |       // For non-detached processes, handle normally
120 |       childProcess.on('close', (code) => {
121 |         const success = code === 0;
122 |         const response: CommandResponse = {
123 |           success,
124 |           output: stdout,
125 |           error: success ? undefined : stderr,
126 |           process: childProcess,
127 |           exitCode: code ?? undefined,
128 |         };
129 | 
130 |         resolve(response);
131 |       });
132 | 
133 |       childProcess.on('error', (err) => {
134 |         reject(err);
135 |       });
136 |     }
137 |   });
138 | }
139 | 
140 | /**
141 |  * Default file system executor implementation using Node.js fs/promises
142 |  * Private instance - use getDefaultFileSystemExecutor() for access
143 |  */
144 | const defaultFileSystemExecutor: FileSystemExecutor = {
145 |   async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
146 |     const fs = await import('fs/promises');
147 |     await fs.mkdir(path, options);
148 |   },
149 | 
150 |   async readFile(path: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
151 |     const fs = await import('fs/promises');
152 |     const content = await fs.readFile(path, encoding);
153 |     return content;
154 |   },
155 | 
156 |   async writeFile(path: string, content: string, encoding: BufferEncoding = 'utf8'): Promise<void> {
157 |     const fs = await import('fs/promises');
158 |     await fs.writeFile(path, content, encoding);
159 |   },
160 | 
161 |   async cp(source: string, destination: string, options?: { recursive?: boolean }): Promise<void> {
162 |     const fs = await import('fs/promises');
163 |     await fs.cp(source, destination, options);
164 |   },
165 | 
166 |   async readdir(path: string, options?: { withFileTypes?: boolean }): Promise<unknown[]> {
167 |     const fs = await import('fs/promises');
168 |     return await fs.readdir(path, options as Record<string, unknown>);
169 |   },
170 | 
171 |   async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> {
172 |     const fs = await import('fs/promises');
173 |     await fs.rm(path, options);
174 |   },
175 | 
176 |   existsSync(path: string): boolean {
177 |     return existsSync(path);
178 |   },
179 | 
180 |   async stat(path: string): Promise<{ isDirectory(): boolean }> {
181 |     const fs = await import('fs/promises');
182 |     return await fs.stat(path);
183 |   },
184 | 
185 |   async mkdtemp(prefix: string): Promise<string> {
186 |     const fs = await import('fs/promises');
187 |     return await fs.mkdtemp(prefix);
188 |   },
189 | 
190 |   tmpdir(): string {
191 |     return osTmpdir();
192 |   },
193 | };
194 | 
195 | /**
196 |  * Get default command executor with test safety
197 |  * Throws error if used in test environment to ensure proper mocking
198 |  */
199 | export function getDefaultCommandExecutor(): CommandExecutor {
200 |   if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') {
201 |     throw new Error(
202 |       `🚨 REAL SYSTEM EXECUTOR DETECTED IN TEST! 🚨\n` +
203 |         `This test is trying to use the default command executor instead of a mock.\n` +
204 |         `Fix: Pass createMockExecutor() as the commandExecutor parameter in your test.\n` +
205 |         `Example: await plugin.handler(args, createMockExecutor({success: true}), mockFileSystem)\n` +
206 |         `See docs/TESTING.md for proper testing patterns.`,
207 |     );
208 |   }
209 |   return defaultExecutor;
210 | }
211 | 
212 | /**
213 |  * Get default file system executor with test safety
214 |  * Throws error if used in test environment to ensure proper mocking
215 |  */
216 | export function getDefaultFileSystemExecutor(): FileSystemExecutor {
217 |   if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') {
218 |     throw new Error(
219 |       `🚨 REAL FILESYSTEM EXECUTOR DETECTED IN TEST! 🚨\n` +
220 |         `This test is trying to use the default filesystem executor instead of a mock.\n` +
221 |         `Fix: Pass createMockFileSystemExecutor() as the fileSystemExecutor parameter in your test.\n` +
222 |         `Example: await plugin.handler(args, mockCmd, createMockFileSystemExecutor())\n` +
223 |         `See docs/TESTING.md for proper testing patterns.`,
224 |     );
225 |   }
226 |   return defaultFileSystemExecutor;
227 | }
228 | 
```
Page 5/14FirstPrevNextLast