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 |
```