This is page 4 of 11. Use http://codebase.md/cameroncooke/xcodebuildmcp?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/mcp/tools/macos/get_mac_app_path.ts: -------------------------------------------------------------------------------- ```typescript /** * macOS Shared Plugin: Get macOS App Path (Unified) * * Gets the app bundle path for a macOS application using either a project or workspace. * Accepts mutually exclusive `projectPath` or `workspacePath`. */ import { z } from 'zod'; import { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; // Unified schema: XOR between projectPath and workspacePath, sharing common options const baseOptions = { scheme: z.string().describe('The scheme to use'), configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), derivedDataPath: z.string().optional().describe('Path to derived data directory'), extraArgs: z.array(z.string()).optional().describe('Additional arguments to pass to xcodebuild'), arch: z .enum(['arm64', 'x86_64']) .optional() .describe('Architecture to build for (arm64 or x86_64). For macOS only.'), }; const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), ...baseOptions, }); const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); const publicSchemaObject = baseSchemaObject.omit({ projectPath: true, workspacePath: true, scheme: true, configuration: true, arch: true, } as const); const getMacosAppPathSchema = baseSchema .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { message: 'Either projectPath or workspacePath is required.', }) .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', }); // Use z.infer for type safety type GetMacosAppPathParams = z.infer<typeof getMacosAppPathSchema>; const XcodePlatform = { iOS: 'iOS', watchOS: 'watchOS', tvOS: 'tvOS', visionOS: 'visionOS', iOSSimulator: 'iOS Simulator', watchOSSimulator: 'watchOS Simulator', tvOSSimulator: 'tvOS Simulator', visionOSSimulator: 'visionOS Simulator', macOS: 'macOS', }; export async function get_mac_app_pathLogic( params: GetMacosAppPathParams, executor: CommandExecutor, ): Promise<ToolResponse> { const configuration = params.configuration ?? 'Debug'; log('info', `Getting app path for scheme ${params.scheme} on platform ${XcodePlatform.macOS}`); try { // Create the command array for xcodebuild with -showBuildSettings option const command = ['xcodebuild', '-showBuildSettings']; // Add the project or workspace if (params.projectPath) { command.push('-project', params.projectPath); } else if (params.workspacePath) { command.push('-workspace', params.workspacePath); } else { // This should never happen due to schema validation throw new Error('Either projectPath or workspacePath is required.'); } // Add the scheme and configuration command.push('-scheme', params.scheme); command.push('-configuration', configuration); // Add optional derived data path if (params.derivedDataPath) { command.push('-derivedDataPath', params.derivedDataPath); } // Handle destination for macOS when arch is specified if (params.arch) { const destinationString = `platform=macOS,arch=${params.arch}`; command.push('-destination', destinationString); } // Add extra arguments if provided if (params.extraArgs && Array.isArray(params.extraArgs)) { command.push(...params.extraArgs); } // Execute the command directly with executor const result = await executor(command, 'Get App Path', true, undefined); if (!result.success) { return { content: [ { type: 'text', text: `Error: Failed to get macOS app path\nDetails: ${result.error}`, }, ], isError: true, }; } if (!result.output) { return { content: [ { type: 'text', text: 'Error: Failed to get macOS app path\nDetails: Failed to extract build settings output from the result', }, ], isError: true, }; } const buildSettingsOutput = result.output; const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); if (!builtProductsDirMatch || !fullProductNameMatch) { return { content: [ { type: 'text', text: 'Error: Failed to get macOS app path\nDetails: Could not extract app path from build settings', }, ], isError: true, }; } const builtProductsDir = builtProductsDirMatch[1].trim(); const fullProductName = fullProductNameMatch[1].trim(); const appPath = `${builtProductsDir}/${fullProductName}`; // Include next steps guidance (following workspace pattern) const nextStepsText = `Next Steps: 1. Get bundle ID: get_app_bundle_id({ appPath: "${appPath}" }) 2. Launch app: launch_mac_app({ appPath: "${appPath}" })`; return { content: [ { type: 'text', text: `✅ App path retrieved successfully: ${appPath}`, }, { type: 'text', text: nextStepsText, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error retrieving app path: ${errorMessage}`); return { content: [ { type: 'text', text: `Error: Failed to get macOS app path\nDetails: ${errorMessage}`, }, ], isError: true, }; } } export default { name: 'get_mac_app_path', description: 'Retrieves the built macOS app bundle path.', schema: publicSchemaObject.shape, handler: createSessionAwareTool<GetMacosAppPathParams>({ internalSchema: getMacosAppPathSchema as unknown as z.ZodType<GetMacosAppPathParams>, logicFunction: get_mac_app_pathLogic, getExecutor: getDefaultCommandExecutor, requirements: [ { allOf: ['scheme'], message: 'scheme is required' }, { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, ], exclusivePairs: [['projectPath', 'workspacePath']], }), }; ``` -------------------------------------------------------------------------------- /src/utils/__tests__/environment.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Unit tests for environment utilities */ import { describe, it, expect } from 'vitest'; import { normalizeTestRunnerEnv } from '../environment.ts'; describe('normalizeTestRunnerEnv', () => { describe('Basic Functionality', () => { it('should add TEST_RUNNER_ prefix to unprefixed keys', () => { const input = { FOO: 'value1', BAR: 'value2' }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_FOO: 'value1', TEST_RUNNER_BAR: 'value2', }); }); it('should preserve keys already prefixed with TEST_RUNNER_', () => { const input = { TEST_RUNNER_FOO: 'value1', TEST_RUNNER_BAR: 'value2' }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_FOO: 'value1', TEST_RUNNER_BAR: 'value2', }); }); it('should handle mixed prefixed and unprefixed keys', () => { const input = { FOO: 'value1', TEST_RUNNER_BAR: 'value2', BAZ: 'value3', }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_FOO: 'value1', TEST_RUNNER_BAR: 'value2', TEST_RUNNER_BAZ: 'value3', }); }); }); describe('Edge Cases', () => { it('should handle empty object', () => { const result = normalizeTestRunnerEnv({}); expect(result).toEqual({}); }); it('should handle null/undefined values', () => { const input = { FOO: 'value1', BAR: null as any, BAZ: undefined as any, QUX: 'value4', }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_FOO: 'value1', TEST_RUNNER_QUX: 'value4', }); }); it('should handle empty string values', () => { const input = { FOO: '', BAR: 'value2' }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_FOO: '', TEST_RUNNER_BAR: 'value2', }); }); it('should handle special characters in keys', () => { const input = { FOO_BAR: 'value1', 'FOO-BAR': 'value2', 'FOO.BAR': 'value3', }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_FOO_BAR: 'value1', 'TEST_RUNNER_FOO-BAR': 'value2', 'TEST_RUNNER_FOO.BAR': 'value3', }); }); it('should handle special characters in values', () => { const input = { FOO: 'value with spaces', BAR: 'value/with/slashes', BAZ: 'value=with=equals', }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_FOO: 'value with spaces', TEST_RUNNER_BAR: 'value/with/slashes', TEST_RUNNER_BAZ: 'value=with=equals', }); }); }); describe('Real-world Usage Scenarios', () => { it('should handle USE_DEV_MODE scenario from GitHub issue', () => { const input = { USE_DEV_MODE: 'YES' }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_USE_DEV_MODE: 'YES', }); }); it('should handle multiple test configuration variables', () => { const input = { USE_DEV_MODE: 'YES', SKIP_ANIMATIONS: '1', DEBUG_MODE: 'true', TEST_TIMEOUT: '30', }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_USE_DEV_MODE: 'YES', TEST_RUNNER_SKIP_ANIMATIONS: '1', TEST_RUNNER_DEBUG_MODE: 'true', TEST_RUNNER_TEST_TIMEOUT: '30', }); }); it('should handle user providing pre-prefixed variables', () => { const input = { TEST_RUNNER_USE_DEV_MODE: 'YES', SKIP_ANIMATIONS: '1', }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_USE_DEV_MODE: 'YES', TEST_RUNNER_SKIP_ANIMATIONS: '1', }); }); it('should handle boolean-like string values', () => { const input = { ENABLED: 'true', DISABLED: 'false', YES_FLAG: 'YES', NO_FLAG: 'NO', }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_ENABLED: 'true', TEST_RUNNER_DISABLED: 'false', TEST_RUNNER_YES_FLAG: 'YES', TEST_RUNNER_NO_FLAG: 'NO', }); }); }); describe('Prefix Handling Edge Cases', () => { it('should not double-prefix already prefixed keys', () => { const input = { TEST_RUNNER_FOO: 'value1' }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_FOO: 'value1', }); // Ensure no double prefixing occurred expect(result).not.toHaveProperty('TEST_RUNNER_TEST_RUNNER_FOO'); }); it('should handle partial prefix matches correctly', () => { const input = { TEST_RUN: 'value1', // Should get prefixed (not TEST_RUNNER_) TEST_RUNNER: 'value2', // Should get prefixed (no underscore) TEST_RUNNER_FOO: 'value3', // Should not get prefixed }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_TEST_RUN: 'value1', TEST_RUNNER_TEST_RUNNER: 'value2', TEST_RUNNER_FOO: 'value3', }); }); it('should handle case-sensitive prefix detection', () => { const input = { test_runner_foo: 'value1', // lowercase - should get prefixed Test_Runner_Bar: 'value2', // mixed case - should get prefixed TEST_RUNNER_BAZ: 'value3', // correct case - should not get prefixed }; const result = normalizeTestRunnerEnv(input); expect(result).toEqual({ TEST_RUNNER_test_runner_foo: 'value1', TEST_RUNNER_Test_Runner_Bar: 'value2', TEST_RUNNER_BAZ: 'value3', }); }); }); describe('Input Validation', () => { it('should handle undefined input gracefully', () => { const result = normalizeTestRunnerEnv(undefined as any); expect(result).toEqual({}); }); it('should handle null input gracefully', () => { const result = normalizeTestRunnerEnv(null as any); expect(result).toEqual({}); }); it('should preserve original object (immutability)', () => { const input = { FOO: 'value1', BAR: 'value2' }; const originalInput = { ...input }; const result = normalizeTestRunnerEnv(input); // Original input should remain unchanged expect(input).toEqual(originalInput); // Result should be different from input expect(result).not.toEqual(input); }); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/long_press.ts: -------------------------------------------------------------------------------- ```typescript /** * UI Testing Plugin: Long Press * * Long press at specific coordinates for given duration (ms). * Use describe_ui for precise coordinates (don't guess from screenshots). */ import { z } from 'zod'; import { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, createErrorResponse, DependencyError, AxeError, SystemError, } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, } from '../../../utils/axe/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject const longPressSchema = z.object({ simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), x: z.number().int('X coordinate for the long press'), y: z.number().int('Y coordinate for the long press'), duration: z.number().positive('Duration of the long press in milliseconds'), }); // Use z.infer for type safety type LongPressParams = z.infer<typeof longPressSchema>; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record<string, string>; createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; export async function long_pressLogic( params: LongPressParams, executor: CommandExecutor, axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }, ): Promise<ToolResponse> { const toolName = 'long_press'; const { simulatorUuid, x, y, duration } = params; // AXe uses touch command with --down, --up, and --delay for long press const delayInSeconds = Number(duration) / 1000; // Convert ms to seconds const commandArgs = [ 'touch', '-x', String(x), '-y', String(y), '--down', '--up', '--delay', String(delayInSeconds), ]; log( 'info', `${LOG_PREFIX}/${toolName}: Starting for (${x}, ${y}), ${duration}ms on ${simulatorUuid}`, ); try { await executeAxeCommand(commandArgs, simulatorUuid, 'touch', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); const warning = getCoordinateWarning(simulatorUuid); const message = `Long press at (${x}, ${y}) for ${duration}ms simulated successfully.`; if (warning) { return createTextResponse(`${message}\n\n${warning}`); } return createTextResponse(message); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { return axeHelpers.createAxeNotAvailableResponse(); } else if (error instanceof AxeError) { return createErrorResponse( `Failed to simulate long press at (${x}, ${y}): ${error.message}`, error.axeOutput, ); } else if (error instanceof SystemError) { return createErrorResponse( `System error executing axe: ${error.message}`, error.originalError?.stack, ); } return createErrorResponse( `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, ); } } export default { name: 'long_press', description: "Long press at specific coordinates for given duration (ms). Use describe_ui for precise coordinates (don't guess from screenshots).", schema: longPressSchema.shape, // MCP SDK compatibility handler: createTypedTool( longPressSchema, (params: LongPressParams, executor: CommandExecutor) => { return long_pressLogic(params, executor, { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }); }, getDefaultCommandExecutor, ), }; // Session tracking for describe_ui warnings interface DescribeUISession { timestamp: number; simulatorUuid: string; } const describeUITimestamps = new Map<string, DescribeUISession>(); const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds function getCoordinateWarning(simulatorUuid: string): string | null { const session = describeUITimestamps.get(simulatorUuid); if (!session) { return 'Warning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.'; } const timeSinceDescribe = Date.now() - session.timestamp; if (timeSinceDescribe > DESCRIBE_UI_WARNING_TIMEOUT) { const secondsAgo = Math.round(timeSinceDescribe / 1000); return `Warning: describe_ui was last called ${secondsAgo} seconds ago. Consider refreshing UI coordinates with describe_ui instead of using potentially stale coordinates.`; } return null; } // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( commandArgs: string[], simulatorUuid: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, ): Promise<void> { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); if (!axeBinary) { throw new DependencyError('AXe binary not found'); } // Add --udid parameter to all commands const fullArgs = [...commandArgs, '--udid', simulatorUuid]; // Construct the full command array with the axe binary as the first element const fullCommand = [axeBinary, ...fullArgs]; try { // Determine environment variables for bundled AXe const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); if (!result.success) { throw new AxeError( `axe command '${commandName}' failed.`, commandName, result.error ?? result.output, simulatorUuid, ); } // Check for stderr output in successful commands if (result.error) { log( 'warn', `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, ); } // Function now returns void - the calling code creates its own response } catch (error) { if (error instanceof Error) { if (error instanceof AxeError) { throw error; } // Otherwise wrap it in a SystemError throw new SystemError(`Failed to execute axe command: ${error.message}`, error); } // For any other type of error throw new SystemError(`Failed to execute axe command: ${String(error)}`); } } ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/touch.ts: -------------------------------------------------------------------------------- ```typescript /** * UI Testing Plugin: Touch * * Perform touch down/up events at specific coordinates. * Use describe_ui for precise coordinates (don't guess from screenshots). */ import { z } from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, } from '../../../utils/axe-helpers.ts'; import { ToolResponse } from '../../../types/common.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject const touchSchema = z.object({ simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), x: z.number().int('X coordinate must be an integer'), y: z.number().int('Y coordinate must be an integer'), down: z.boolean().optional(), up: z.boolean().optional(), delay: z.number().min(0, 'Delay must be non-negative').optional(), }); // Use z.infer for type safety type TouchParams = z.infer<typeof touchSchema>; interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record<string, string>; } const LOG_PREFIX = '[AXe]'; export async function touchLogic( params: TouchParams, executor: CommandExecutor, axeHelpers?: AxeHelpers, ): Promise<ToolResponse> { const toolName = 'touch'; // Params are already validated by createTypedTool - use directly const { simulatorUuid, x, y, down, up, delay } = params; // Validate that at least one of down or up is specified if (!down && !up) { return createErrorResponse('At least one of "down" or "up" must be true'); } const commandArgs = ['touch', '-x', String(x), '-y', String(y)]; if (down) { commandArgs.push('--down'); } if (up) { commandArgs.push('--up'); } if (delay !== undefined) { commandArgs.push('--delay', String(delay)); } const actionText = down && up ? 'touch down+up' : down ? 'touch down' : 'touch up'; log( 'info', `${LOG_PREFIX}/${toolName}: Starting ${actionText} at (${x}, ${y}) on ${simulatorUuid}`, ); try { await executeAxeCommand(commandArgs, simulatorUuid, 'touch', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); const warning = getCoordinateWarning(simulatorUuid); const message = `Touch event (${actionText}) at (${x}, ${y}) executed successfully.`; if (warning) { return createTextResponse(`${message}\n\n${warning}`); } return createTextResponse(message); } catch (error) { log( 'error', `${LOG_PREFIX}/${toolName}: Failed - ${error instanceof Error ? error.message : String(error)}`, ); if (error instanceof DependencyError) { return createAxeNotAvailableResponse(); } else if (error instanceof AxeError) { return createErrorResponse( `Failed to execute touch event: ${error.message}`, error.axeOutput, ); } else if (error instanceof SystemError) { return createErrorResponse( `System error executing axe: ${error.message}`, error.originalError?.stack, ); } return createErrorResponse( `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, ); } } export default { name: 'touch', description: "Perform touch down/up events at specific coordinates. Use describe_ui for precise coordinates (don't guess from screenshots).", schema: touchSchema.shape, // MCP SDK compatibility handler: createTypedTool(touchSchema, touchLogic, getDefaultCommandExecutor), }; // Session tracking for describe_ui warnings interface DescribeUISession { timestamp: number; simulatorUuid: string; } const describeUITimestamps = new Map<string, DescribeUISession>(); const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds function getCoordinateWarning(simulatorUuid: string): string | null { const session = describeUITimestamps.get(simulatorUuid); if (!session) { return 'Warning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.'; } const timeSinceDescribe = Date.now() - session.timestamp; if (timeSinceDescribe > DESCRIBE_UI_WARNING_TIMEOUT) { const secondsAgo = Math.round(timeSinceDescribe / 1000); return `Warning: describe_ui was last called ${secondsAgo} seconds ago. Consider refreshing UI coordinates with describe_ui instead of using potentially stale coordinates.`; } return null; } // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( commandArgs: string[], simulatorUuid: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), axeHelpers?: AxeHelpers, ): Promise<void> { // Use injected helpers or default to imported functions const helpers = axeHelpers ?? { getAxePath, getBundledAxeEnvironment }; // Get the appropriate axe binary path const axeBinary = helpers.getAxePath(); if (!axeBinary) { throw new DependencyError('AXe binary not found'); } // Add --udid parameter to all commands const fullArgs = [...commandArgs, '--udid', simulatorUuid]; // Construct the full command array with the axe binary as the first element const fullCommand = [axeBinary, ...fullArgs]; try { // Determine environment variables for bundled AXe const axeEnv = axeBinary !== 'axe' ? helpers.getBundledAxeEnvironment() : undefined; const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); if (!result.success) { throw new AxeError( `axe command '${commandName}' failed.`, commandName, result.error ?? result.output, simulatorUuid, ); } // Check for stderr output in successful commands if (result.error) { log( 'warn', `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, ); } // Function now returns void - the calling code creates its own response } catch (error) { if (error instanceof Error) { if (error instanceof AxeError) { throw error; } // Otherwise wrap it in a SystemError throw new SystemError(`Failed to execute axe command: ${error.message}`, error); } // For any other type of error throw new SystemError(`Failed to execute axe command: ${String(error)}`); } } ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/list_sims.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject const listSimsSchema = z.object({ enabled: z.boolean().optional().describe('Optional flag to enable the listing operation.'), }); // Use z.infer for type safety type ListSimsParams = z.infer<typeof listSimsSchema>; interface SimulatorDevice { name: string; udid: string; state: string; isAvailable: boolean; runtime?: string; } interface SimulatorData { devices: Record<string, SimulatorDevice[]>; } // Parse text output as fallback for Apple simctl JSON bugs (e.g., duplicate runtime IDs) function parseTextOutput(textOutput: string): SimulatorDevice[] { const devices: SimulatorDevice[] = []; const lines = textOutput.split('\n'); let currentRuntime = ''; for (const line of lines) { // Match runtime headers like "-- iOS 26.0 --" or "-- iOS 18.6 --" const runtimeMatch = line.match(/^-- ([\w\s.]+) --$/); if (runtimeMatch) { currentRuntime = runtimeMatch[1]; continue; } // Match device lines like " iPhone 17 Pro (UUID) (Booted)" // UUID pattern is flexible to handle test UUIDs like "test-uuid-123" const deviceMatch = line.match( /^\s+(.+?)\s+\(([^)]+)\)\s+\((Booted|Shutdown|Booting|Shutting Down)\)(\s+\(unavailable.*\))?$/i, ); if (deviceMatch && currentRuntime) { const [, name, udid, state, unavailableSuffix] = deviceMatch; const isUnavailable = Boolean(unavailableSuffix); if (!isUnavailable) { devices.push({ name: name.trim(), udid, state, isAvailable: true, runtime: currentRuntime, }); } } } return devices; } function isSimulatorData(value: unknown): value is SimulatorData { if (!value || typeof value !== 'object') { return false; } const obj = value as Record<string, unknown>; if (!obj.devices || typeof obj.devices !== 'object') { return false; } const devices = obj.devices as Record<string, unknown>; for (const runtime in devices) { const deviceList = devices[runtime]; if (!Array.isArray(deviceList)) { return false; } for (const device of deviceList) { if (!device || typeof device !== 'object') { return false; } const deviceObj = device as Record<string, unknown>; if ( typeof deviceObj.name !== 'string' || typeof deviceObj.udid !== 'string' || typeof deviceObj.state !== 'string' || typeof deviceObj.isAvailable !== 'boolean' ) { return false; } } } return true; } export async function list_simsLogic( params: ListSimsParams, executor: CommandExecutor, ): Promise<ToolResponse> { log('info', 'Starting xcrun simctl list devices request'); try { // Try JSON first for structured data const jsonCommand = ['xcrun', 'simctl', 'list', 'devices', '--json']; const jsonResult = await executor(jsonCommand, 'List Simulators (JSON)', true); if (!jsonResult.success) { return { content: [ { type: 'text', text: `Failed to list simulators: ${jsonResult.error}`, }, ], }; } // Parse JSON output let jsonDevices: Record<string, SimulatorDevice[]> = {}; try { const parsedData: unknown = JSON.parse(jsonResult.output); if (isSimulatorData(parsedData)) { jsonDevices = parsedData.devices; } } catch { log('warn', 'Failed to parse JSON output, falling back to text parsing'); } // Fallback to text parsing for Apple simctl bugs (duplicate runtime IDs in iOS 26.0 beta) const textCommand = ['xcrun', 'simctl', 'list', 'devices']; const textResult = await executor(textCommand, 'List Simulators (Text)', true); const textDevices = textResult.success ? parseTextOutput(textResult.output) : []; // Merge JSON and text devices, preferring JSON but adding any missing from text const allDevices: Record<string, SimulatorDevice[]> = { ...jsonDevices }; const jsonUUIDs = new Set<string>(); // Collect all UUIDs from JSON for (const runtime in jsonDevices) { for (const device of jsonDevices[runtime]) { if (device.isAvailable) { jsonUUIDs.add(device.udid); } } } // Add devices from text that aren't in JSON (handles Apple's duplicate runtime ID bug) for (const textDevice of textDevices) { if (!jsonUUIDs.has(textDevice.udid)) { const runtime = textDevice.runtime ?? 'Unknown Runtime'; if (!allDevices[runtime]) { allDevices[runtime] = []; } allDevices[runtime].push(textDevice); log( 'info', `Added missing device from text parsing: ${textDevice.name} (${textDevice.udid})`, ); } } // Format output let responseText = 'Available iOS Simulators:\n\n'; for (const runtime in allDevices) { const devices = allDevices[runtime].filter((d) => d.isAvailable); if (devices.length === 0) continue; responseText += `${runtime}:\n`; for (const device of devices) { responseText += `- ${device.name} (${device.udid})${device.state === 'Booted' ? ' [Booted]' : ''}\n`; } responseText += '\n'; } responseText += 'Next Steps:\n'; responseText += "1. Boot a simulator: boot_sim({ simulatorUuid: 'UUID_FROM_ABOVE' })\n"; responseText += '2. Open the simulator UI: open_sim({})\n'; responseText += "3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })\n"; responseText += "4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })"; return { content: [ { type: 'text', text: responseText, }, ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error listing simulators: ${errorMessage}`); return { content: [ { type: 'text', text: `Failed to list simulators: ${errorMessage}`, }, ], }; } } export default { name: 'list_sims', description: 'Lists available iOS simulators with their UUIDs. ', schema: listSimsSchema.shape, // MCP SDK compatibility handler: createTypedTool(listSimsSchema, list_simsLogic, getDefaultCommandExecutor), }; ``` -------------------------------------------------------------------------------- /src/utils/typed-tool-factory.ts: -------------------------------------------------------------------------------- ```typescript /** * Type-safe tool factory for XcodeBuildMCP * * This module provides a factory function to create MCP tool handlers that safely * convert from the generic Record<string, unknown> signature required by the MCP SDK * to strongly-typed parameters using runtime validation with Zod. * * This eliminates the need for unsafe type assertions while maintaining full * compatibility with the MCP SDK's tool handler signature requirements. */ import { z } from 'zod'; import { ToolResponse } from '../types/common.ts'; import type { CommandExecutor } from './execution/index.ts'; import { createErrorResponse } from './responses/index.ts'; import { sessionStore, type SessionDefaults } from './session-store.ts'; /** * Creates a type-safe tool handler that validates parameters at runtime * before passing them to the typed logic function. * * This is the ONLY safe way to cross the type boundary from the generic * MCP handler signature to our typed domain logic. * * @param schema - Zod schema for parameter validation * @param logicFunction - The typed logic function to execute * @param getExecutor - Function to get the command executor (must be provided) * @returns A handler function compatible with MCP SDK requirements */ export function createTypedTool<TParams>( schema: z.ZodType<TParams>, logicFunction: (params: TParams, executor: CommandExecutor) => Promise<ToolResponse>, getExecutor: () => CommandExecutor, ) { return async (args: Record<string, unknown>): Promise<ToolResponse> => { try { // Runtime validation - the ONLY safe way to cross the type boundary // This provides both compile-time and runtime type safety const validatedParams = schema.parse(args); // Now we have guaranteed type safety - no assertions needed! return await logicFunction(validatedParams, getExecutor()); } catch (error) { if (error instanceof z.ZodError) { // Format validation errors in a user-friendly way const errorMessages = error.errors.map((e) => { const path = e.path.length > 0 ? `${e.path.join('.')}` : 'root'; return `${path}: ${e.message}`; }); return createErrorResponse( 'Parameter validation failed', `Invalid parameters:\n${errorMessages.join('\n')}`, ); } // Re-throw unexpected errors (they'll be caught by the MCP framework) throw error; } }; } export type SessionRequirement = | { allOf: (keyof SessionDefaults)[]; message?: string } | { oneOf: (keyof SessionDefaults)[]; message?: string }; function missingFromMerged( keys: (keyof SessionDefaults)[], merged: Record<string, unknown>, ): string[] { return keys.filter((k) => merged[k] == null); } export function createSessionAwareTool<TParams>(opts: { internalSchema: z.ZodType<TParams>; logicFunction: (params: TParams, executor: CommandExecutor) => Promise<ToolResponse>; getExecutor: () => CommandExecutor; sessionKeys?: (keyof SessionDefaults)[]; requirements?: SessionRequirement[]; exclusivePairs?: (keyof SessionDefaults)[][]; // when args provide one side, drop conflicting session-default side(s) }) { const { internalSchema, logicFunction, getExecutor, requirements = [], exclusivePairs = [], } = opts; return async (rawArgs: Record<string, unknown>): Promise<ToolResponse> => { try { // Sanitize args: treat null/undefined as "not provided" so they don't override session defaults const sanitizedArgs: Record<string, unknown> = {}; for (const [k, v] of Object.entries(rawArgs)) { if (v === null || v === undefined) continue; if (typeof v === 'string' && v.trim() === '') continue; sanitizedArgs[k] = v; } // Factory-level mutual exclusivity check: if user provides multiple explicit values // within an exclusive group, reject early even if tool schema doesn't enforce XOR. for (const pair of exclusivePairs) { const provided = pair.filter((k) => Object.prototype.hasOwnProperty.call(sanitizedArgs, k)); if (provided.length >= 2) { return createErrorResponse( 'Parameter validation failed', `Invalid parameters:\nMutually exclusive parameters provided: ${provided.join( ', ', )}. Provide only one.`, ); } } // Start with session defaults merged with explicit args (args override session) const merged: Record<string, unknown> = { ...sessionStore.getAll(), ...sanitizedArgs }; // Apply exclusive pair pruning: only when caller provided a concrete (non-null/undefined) value // for any key in the pair. When activated, drop other keys in the pair coming from session defaults. for (const pair of exclusivePairs) { const userProvidedConcrete = pair.some((k) => Object.prototype.hasOwnProperty.call(sanitizedArgs, k), ); if (!userProvidedConcrete) continue; for (const k of pair) { if (!Object.prototype.hasOwnProperty.call(sanitizedArgs, k) && k in merged) { delete merged[k]; } } } for (const req of requirements) { if ('allOf' in req) { const missing = missingFromMerged(req.allOf, merged); if (missing.length > 0) { return createErrorResponse( 'Missing required session defaults', `${req.message ?? `Required: ${req.allOf.join(', ')}`}\n` + `Set with: session-set-defaults { ${missing .map((k) => `"${k}": "..."`) .join(', ')} }`, ); } } else if ('oneOf' in req) { const satisfied = req.oneOf.some((k) => merged[k] != null); if (!satisfied) { const options = req.oneOf.join(', '); const setHints = req.oneOf .map((k) => `session-set-defaults { "${k}": "..." }`) .join(' OR '); return createErrorResponse( 'Missing required session defaults', `${req.message ?? `Provide one of: ${options}`}\nSet with: ${setHints}`, ); } } } const validated = internalSchema.parse(merged); return await logicFunction(validated, getExecutor()); } catch (error) { if (error instanceof z.ZodError) { const errorMessages = error.errors.map((e) => { const path = e.path.length > 0 ? `${e.path.join('.')}` : 'root'; return `${path}: ${e.message}`; }); return createErrorResponse( 'Parameter validation failed', `Invalid parameters:\n${errorMessages.join('\n')}\nTip: set session defaults via session-set-defaults`, ); } throw error; } }; } ``` -------------------------------------------------------------------------------- /src/core/dynamic-tools.ts: -------------------------------------------------------------------------------- ```typescript import { log } from '../utils/logger.ts'; import { getDefaultCommandExecutor, CommandExecutor } from '../utils/command.ts'; import { WORKFLOW_LOADERS, WorkflowName, WORKFLOW_METADATA } from './generated-plugins.ts'; import { ToolResponse } from '../types/common.ts'; import { PluginMeta } from './plugin-types.ts'; import { McpServer } from '@camsoft/mcp-sdk/server/mcp.js'; import { registerAndTrackTools, removeTrackedTools, isToolRegistered, } from '../utils/tool-registry.ts'; import { ZodRawShape } from 'zod'; // Track enabled workflows and their tools for replacement functionality const enabledWorkflows = new Set<string>(); const enabledTools = new Map<string, string>(); // toolName -> workflowName // Type for the handler function from our tools type ToolHandler = ( args: Record<string, unknown>, executor: CommandExecutor, ) => Promise<ToolResponse>; // Use the actual McpServer type from the SDK instead of a custom interface /** * Wrapper function to adapt MCP SDK handler calling convention to our dependency injection pattern * MCP SDK calls handlers with just (args), but our handlers expect (args, executor) */ function wrapHandlerWithExecutor(handler: ToolHandler) { return async (args: unknown): Promise<ToolResponse> => { return handler(args as Record<string, unknown>, getDefaultCommandExecutor()); }; } /** * Clear currently enabled workflows by actually removing registered tools */ export function clearEnabledWorkflows(): void { if (enabledTools.size === 0) { log('debug', 'No tools to clear'); return; } const clearedWorkflows = Array.from(enabledWorkflows); const toolNamesToRemove = Array.from(enabledTools.keys()); const clearedToolCount = toolNamesToRemove.length; log('info', `Removing ${clearedToolCount} tools from workflows: ${clearedWorkflows.join(', ')}`); // Actually remove the registered tools using the tool registry const removedTools = removeTrackedTools(toolNamesToRemove); // Clear our tracking enabledWorkflows.clear(); enabledTools.clear(); log('info', `✅ Removed ${removedTools.length} tools successfully`); } /** * Get currently enabled workflows */ export function getEnabledWorkflows(): string[] { return Array.from(enabledWorkflows); } /** * Enable workflows by registering their tools dynamically using generated loaders * @param server - MCP server instance * @param workflowNames - Array of workflow names to enable * @param additive - If true, add to existing workflows. If false (default), replace existing workflows */ export async function enableWorkflows( server: McpServer, workflowNames: string[], additive: boolean = false, ): Promise<void> { if (!server) { throw new Error('Server instance not available for dynamic tool registration'); } // Clear existing workflow tracking unless in additive mode if (!additive && enabledWorkflows.size > 0) { log('info', `Replacing existing workflows: ${Array.from(enabledWorkflows).join(', ')}`); clearEnabledWorkflows(); } let totalToolsAdded = 0; for (const workflowName of workflowNames) { const loader = WORKFLOW_LOADERS[workflowName as WorkflowName]; if (!loader) { log('warn', `Workflow '${workflowName}' not found in available workflows`); continue; } try { log('info', `Loading workflow '${workflowName}' with code-splitting...`); // Dynamic import with code-splitting const workflowModule = (await loader()) as Record<string, unknown>; // Get tools count from the module (excluding 'workflow' key) const toolKeys = Object.keys(workflowModule).filter((key) => key !== 'workflow'); log('info', `Enabling ${toolKeys.length} tools from '${workflowName}' workflow`); const toolsToRegister: Array<{ name: string; config: { title?: string; description?: string; inputSchema?: ZodRawShape; outputSchema?: ZodRawShape; annotations?: Record<string, unknown>; }; callback: (args: Record<string, unknown>) => Promise<ToolResponse>; }> = []; // Collect all tools from this workflow, filtering out already-registered tools for (const toolKey of toolKeys) { const tool = workflowModule[toolKey] as PluginMeta | undefined; if (tool?.name && typeof tool.handler === 'function') { // Always skip tools that are already registered (in all modes) if (isToolRegistered(tool.name)) { log('debug', `Skipping already registered tool: ${tool.name}`); continue; } toolsToRegister.push({ name: tool.name, config: { description: tool.description ?? '', inputSchema: tool.schema, }, callback: wrapHandlerWithExecutor(tool.handler as ToolHandler), }); // Track the tool and workflow enabledTools.set(tool.name, workflowName); totalToolsAdded++; } else { log('warn', `Invalid tool definition for '${toolKey}' in workflow '${workflowName}'`); } } // Register all tools using bulk registration if (toolsToRegister.length > 0) { log( 'info', `🚀 Registering ${toolsToRegister.length} tools from '${workflowName}' workflow`, ); // Convert to proper tool registration format const toolRegistrations = toolsToRegister.map((tool) => ({ name: tool.name, config: { description: tool.config.description, inputSchema: tool.config.inputSchema as unknown, }, callback: (args: unknown): Promise<ToolResponse> => tool.callback(args as Record<string, unknown>), })); // Use bulk registration - no fallback needed with proper duplicate handling const registeredTools = registerAndTrackTools(server, toolRegistrations); log('info', `✅ Registered ${registeredTools.length} tools from '${workflowName}'`); } else { log('info', `No new tools to register from '${workflowName}' (all already registered)`); } // Track the workflow as enabled enabledWorkflows.add(workflowName); } catch (error) { log( 'error', `Failed to load workflow '${workflowName}': ${error instanceof Error ? error.message : 'Unknown error'}`, ); } } // registerAndTrackTools() handles tool list change notifications automatically log( 'info', `✅ Successfully enabled ${totalToolsAdded} tools from ${workflowNames.length} workflows`, ); } /** * Get list of currently available workflows using generated metadata */ export function getAvailableWorkflows(): string[] { return Object.keys(WORKFLOW_LOADERS); } /** * Get workflow information for LLM prompt generation using generated metadata */ export function generateWorkflowDescriptions(): string { return Object.entries(WORKFLOW_METADATA) .map(([name, metadata]) => `- **${name.toUpperCase()}**: ${metadata.description}`) .join('\n'); } ``` -------------------------------------------------------------------------------- /src/utils/__tests__/session-aware-tool-factory.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import { createSessionAwareTool } from '../typed-tool-factory.ts'; import { sessionStore } from '../session-store.ts'; import { createMockExecutor } from '../../test-utils/mock-executors.ts'; describe('createSessionAwareTool', () => { beforeEach(() => { sessionStore.clear(); }); const internalSchema = z .object({ scheme: z.string(), projectPath: z.string().optional(), workspacePath: z.string().optional(), simulatorId: z.string().optional(), simulatorName: z.string().optional(), }) .refine((v) => !!v.projectPath !== !!v.workspacePath, { message: 'projectPath and workspacePath are mutually exclusive', path: ['projectPath'], }) .refine((v) => !!v.simulatorId !== !!v.simulatorName, { message: 'simulatorId and simulatorName are mutually exclusive', path: ['simulatorId'], }); type Params = z.infer<typeof internalSchema>; async function logic(_params: Params): Promise<import('../../types/common.ts').ToolResponse> { return { content: [{ type: 'text', text: 'OK' }], isError: false }; } const handler = createSessionAwareTool<Params>({ internalSchema, logicFunction: logic, getExecutor: () => createMockExecutor({ success: true }), requirements: [ { allOf: ['scheme'], message: 'scheme is required' }, { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, ], }); it('should merge session defaults and satisfy requirements', async () => { sessionStore.setDefaults({ scheme: 'App', projectPath: '/path/proj.xcodeproj', simulatorId: 'SIM-1', }); const result = await handler({}); expect(result.isError).toBe(false); expect(result.content[0].text).toBe('OK'); }); it('should prefer explicit args over session defaults (same key wins)', async () => { // Create a handler that echoes the chosen scheme const echoHandler = createSessionAwareTool<Params>({ internalSchema, logicFunction: async (params) => ({ content: [{ type: 'text', text: params.scheme }], isError: false, }), getExecutor: () => createMockExecutor({ success: true }), requirements: [ { allOf: ['scheme'], message: 'scheme is required' }, { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName', }, ], }); sessionStore.setDefaults({ scheme: 'Default', projectPath: '/a.xcodeproj', simulatorId: 'SIM-A', }); const result = await echoHandler({ scheme: 'FromArgs' }); expect(result.isError).toBe(false); expect(result.content[0].text).toBe('FromArgs'); }); it('should return friendly error when allOf requirement missing', async () => { const result = await handler({ projectPath: '/p.xcodeproj', simulatorId: 'SIM-1' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); expect(result.content[0].text).toContain('scheme is required'); }); it('should return friendly error when oneOf requirement missing', async () => { const result = await handler({ scheme: 'App', simulatorId: 'SIM-1' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); expect(result.content[0].text).toContain('Provide a project or workspace'); }); it('should surface Zod validation errors with tip when invalid', async () => { const badHandler = createSessionAwareTool<any>({ internalSchema, logicFunction: logic, getExecutor: () => createMockExecutor({ success: true }), }); const result = await badHandler({ scheme: 123 }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Parameter validation failed'); expect(result.content[0].text).toContain('Tip: set session defaults'); }); it('exclusivePairs should NOT prune session defaults when user provides null (treat as not provided)', async () => { const handlerWithExclusive = createSessionAwareTool<Params>({ internalSchema, logicFunction: logic, getExecutor: () => createMockExecutor({ success: true }), requirements: [ { allOf: ['scheme'], message: 'scheme is required' }, { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, ], exclusivePairs: [['projectPath', 'workspacePath']], }); sessionStore.setDefaults({ scheme: 'App', projectPath: '/path/proj.xcodeproj', simulatorId: 'SIM-1', }); const res = await handlerWithExclusive({ workspacePath: null as unknown as string }); expect(res.isError).toBe(false); expect(res.content[0].text).toBe('OK'); }); it('exclusivePairs should NOT prune when user provides undefined (key present)', async () => { const handlerWithExclusive = createSessionAwareTool<Params>({ internalSchema, logicFunction: logic, getExecutor: () => createMockExecutor({ success: true }), requirements: [ { allOf: ['scheme'], message: 'scheme is required' }, { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, ], exclusivePairs: [['projectPath', 'workspacePath']], }); sessionStore.setDefaults({ scheme: 'App', projectPath: '/path/proj.xcodeproj', simulatorId: 'SIM-1', }); const res = await handlerWithExclusive({ workspacePath: undefined as unknown as string }); expect(res.isError).toBe(false); expect(res.content[0].text).toBe('OK'); }); it('rejects when multiple explicit args in an exclusive pair are provided (factory-level)', async () => { const internalSchemaNoXor = z.object({ scheme: z.string(), projectPath: z.string().optional(), workspacePath: z.string().optional(), }); const handlerNoXor = createSessionAwareTool<z.infer<typeof internalSchemaNoXor>>({ internalSchema: internalSchemaNoXor, logicFunction: (async () => ({ content: [{ type: 'text', text: 'OK' }], isError: false, })) as any, getExecutor: () => createMockExecutor({ success: true }), requirements: [{ allOf: ['scheme'], message: 'scheme is required' }], exclusivePairs: [['projectPath', 'workspacePath']], }); const res = await handlerNoXor({ scheme: 'App', projectPath: '/path/a.xcodeproj', workspacePath: '/path/b.xcworkspace', }); expect(res.isError).toBe(true); const msg = res.content[0].text; expect(msg).toContain('Parameter validation failed'); expect(msg).toContain('Mutually exclusive parameters provided'); expect(msg).toContain('projectPath'); expect(msg).toContain('workspacePath'); }); }); ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/record_sim_video.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, } from '../../../utils/execution/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { areAxeToolsAvailable, isAxeAtLeastVersion, createAxeNotAvailableResponse, } from '../../../utils/axe/index.ts'; import { startSimulatorVideoCapture, stopSimulatorVideoCapture, } from '../../../utils/video-capture/index.ts'; import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import { dirname } from 'path'; // Base schema object (used for MCP schema exposure) const recordSimVideoSchemaObject = z.object({ simulatorId: z .string() .uuid('Invalid Simulator UUID format') .describe('UUID of the simulator to record'), start: z.boolean().optional().describe('Start recording if true'), stop: z.boolean().optional().describe('Stop recording if true'), fps: z.number().int().min(1).max(120).optional().describe('Frames per second (default 30)'), outputFile: z .string() .optional() .describe('Destination MP4 path to move the recorded video to on stop'), }); // Schema enforcing mutually exclusive start/stop and requiring outputFile on stop const recordSimVideoSchema = recordSimVideoSchemaObject .refine( (v) => { const s = v.start === true ? 1 : 0; const t = v.stop === true ? 1 : 0; return s + t === 1; }, { message: 'Provide exactly one of start=true or stop=true; these options are mutually exclusive', path: ['start'], }, ) .refine((v) => (v.stop ? typeof v.outputFile === 'string' && v.outputFile.length > 0 : true), { message: 'outputFile is required when stop=true', path: ['outputFile'], }); type RecordSimVideoParams = z.infer<typeof recordSimVideoSchema>; export async function record_sim_videoLogic( params: RecordSimVideoParams, executor: CommandExecutor, axe: { areAxeToolsAvailable(): boolean; isAxeAtLeastVersion(v: string, e: CommandExecutor): Promise<boolean>; createAxeNotAvailableResponse(): ToolResponse; } = { areAxeToolsAvailable, isAxeAtLeastVersion, createAxeNotAvailableResponse, }, video: { startSimulatorVideoCapture: typeof startSimulatorVideoCapture; stopSimulatorVideoCapture: typeof stopSimulatorVideoCapture; } = { startSimulatorVideoCapture, stopSimulatorVideoCapture, }, fs: FileSystemExecutor = getDefaultFileSystemExecutor(), ): Promise<ToolResponse> { // Preflight checks for AXe availability and version if (!axe.areAxeToolsAvailable()) { return axe.createAxeNotAvailableResponse(); } const hasVersion = await axe.isAxeAtLeastVersion('1.1.0', executor); if (!hasVersion) { return createTextResponse( 'AXe v1.1.0 or newer is required for simulator video capture. Please update bundled AXe artifacts.', true, ); } // using injected fs executor if (params.start) { const fpsUsed = Number.isFinite(params.fps as number) ? Number(params.fps) : 30; const startRes = await video.startSimulatorVideoCapture( { simulatorUuid: params.simulatorId, fps: fpsUsed }, executor, ); if (!startRes.started) { return createTextResponse( `Failed to start video recording: ${startRes.error ?? 'Unknown error'}`, true, ); } const notes: string[] = []; if (typeof params.outputFile === 'string' && params.outputFile.length > 0) { notes.push( 'Note: outputFile is ignored when start=true; provide it when stopping to move/rename the recorded file.', ); } if (startRes.warning) { notes.push(startRes.warning); } const nextSteps = `Next Steps: Stop and save the recording: record_sim_video({ simulatorId: "${params.simulatorId}", stop: true, outputFile: "/path/to/output.mp4" })`; return { content: [ { type: 'text', text: `🎥 Video recording started for simulator ${params.simulatorId} at ${fpsUsed} fps.\nSession: ${startRes.sessionId}`, }, ...(notes.length > 0 ? [ { type: 'text' as const, text: notes.join('\n'), }, ] : []), { type: 'text', text: nextSteps, }, ], isError: false, }; } // params.stop must be true here per schema const stopRes = await video.stopSimulatorVideoCapture( { simulatorUuid: params.simulatorId }, executor, ); if (!stopRes.stopped) { return createTextResponse( `Failed to stop video recording: ${stopRes.error ?? 'Unknown error'}`, true, ); } // Attempt to move/rename the recording if we parsed a source path and an outputFile was given const outputs: string[] = []; let finalSavedPath = params.outputFile ?? stopRes.parsedPath ?? ''; try { if (params.outputFile) { if (!stopRes.parsedPath) { return createTextResponse( `Recording stopped but could not determine the recorded file path from AXe output.\nRaw output:\n${stopRes.stdout ?? '(no output captured)'}`, true, ); } const src = stopRes.parsedPath; const dest = params.outputFile; await fs.mkdir(dirname(dest), { recursive: true }); await fs.cp(src, dest); try { await fs.rm(src, { recursive: false }); } catch { // Ignore cleanup failure } finalSavedPath = dest; outputs.push(`Original file: ${src}`); outputs.push(`Saved to: ${dest}`); } else if (stopRes.parsedPath) { outputs.push(`Saved to: ${stopRes.parsedPath}`); finalSavedPath = stopRes.parsedPath; } } catch (e) { const msg = e instanceof Error ? e.message : String(e); return createTextResponse( `Recording stopped but failed to save/move the video file: ${msg}`, true, ); } return { content: [ { type: 'text', text: `✅ Video recording stopped for simulator ${params.simulatorId}.`, }, ...(outputs.length > 0 ? [ { type: 'text' as const, text: outputs.join('\n'), }, ] : []), ...(!outputs.length && stopRes.stdout ? [ { type: 'text' as const, text: `AXe output:\n${stopRes.stdout}`, }, ] : []), ], isError: false, _meta: finalSavedPath ? { outputFile: finalSavedPath } : undefined, }; } const publicSchemaObject = recordSimVideoSchemaObject.omit({ simulatorId: true, } as const); export default { name: 'record_sim_video', description: 'Starts or stops video capture for an iOS simulator.', schema: publicSchemaObject.shape, handler: createSessionAwareTool<RecordSimVideoParams>({ internalSchema: recordSimVideoSchema as unknown as z.ZodType<RecordSimVideoParams>, logicFunction: record_sim_videoLogic, getExecutor: getDefaultCommandExecutor, requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], }), }; ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/swipe.ts: -------------------------------------------------------------------------------- ```typescript /** * UI Testing Plugin: Swipe * * Swipe from one coordinate to another on iOS simulator with customizable duration and delta. */ import { z } from 'zod'; import { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, } from '../../../utils/axe-helpers.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject const swipeSchema = z.object({ simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), x1: z.number().int('Start X coordinate'), y1: z.number().int('Start Y coordinate'), x2: z.number().int('End X coordinate'), y2: z.number().int('End Y coordinate'), duration: z.number().min(0, 'Duration must be non-negative').optional(), delta: z.number().min(0, 'Delta must be non-negative').optional(), preDelay: z.number().min(0, 'Pre-delay must be non-negative').optional(), postDelay: z.number().min(0, 'Post-delay must be non-negative').optional(), }); // Use z.infer for type safety type SwipeParams = z.infer<typeof swipeSchema>; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record<string, string>; createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; /** * Core swipe logic implementation */ export async function swipeLogic( params: SwipeParams, executor: CommandExecutor, axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }, ): Promise<ToolResponse> { const toolName = 'swipe'; const { simulatorUuid, x1, y1, x2, y2, duration, delta, preDelay, postDelay } = params; const commandArgs = [ 'swipe', '--start-x', String(x1), '--start-y', String(y1), '--end-x', String(x2), '--end-y', String(y2), ]; if (duration !== undefined) { commandArgs.push('--duration', String(duration)); } if (delta !== undefined) { commandArgs.push('--delta', String(delta)); } if (preDelay !== undefined) { commandArgs.push('--pre-delay', String(preDelay)); } if (postDelay !== undefined) { commandArgs.push('--post-delay', String(postDelay)); } const optionsText = duration ? ` duration=${duration}s` : ''; log( 'info', `${LOG_PREFIX}/${toolName}: Starting swipe (${x1},${y1})->(${x2},${y2})${optionsText} on ${simulatorUuid}`, ); try { await executeAxeCommand(commandArgs, simulatorUuid, 'swipe', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); const warning = getCoordinateWarning(simulatorUuid); const message = `Swipe from (${x1}, ${y1}) to (${x2}, ${y2})${optionsText} simulated successfully.`; if (warning) { return createTextResponse(`${message}\n\n${warning}`); } return createTextResponse(message); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { return axeHelpers.createAxeNotAvailableResponse(); } else if (error instanceof AxeError) { return createErrorResponse(`Failed to simulate swipe: ${error.message}`, error.axeOutput); } else if (error instanceof SystemError) { return createErrorResponse( `System error executing axe: ${error.message}`, error.originalError?.stack, ); } return createErrorResponse( `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, ); } } export default { name: 'swipe', description: "Swipe from one point to another. Use describe_ui for precise coordinates (don't guess from screenshots). Supports configurable timing.", schema: swipeSchema.shape, // MCP SDK compatibility handler: createTypedTool( swipeSchema, (params: SwipeParams, executor: CommandExecutor) => { return swipeLogic(params, executor, { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }); }, getDefaultCommandExecutor, ), }; // Session tracking for describe_ui warnings interface DescribeUISession { timestamp: number; simulatorUuid: string; } const describeUITimestamps = new Map<string, DescribeUISession>(); const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds function getCoordinateWarning(simulatorUuid: string): string | null { const session = describeUITimestamps.get(simulatorUuid); if (!session) { return 'Warning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.'; } const timeSinceDescribe = Date.now() - session.timestamp; if (timeSinceDescribe > DESCRIBE_UI_WARNING_TIMEOUT) { const secondsAgo = Math.round(timeSinceDescribe / 1000); return `Warning: describe_ui was last called ${secondsAgo} seconds ago. Consider refreshing UI coordinates with describe_ui instead of using potentially stale coordinates.`; } return null; } // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( commandArgs: string[], simulatorUuid: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, ): Promise<void> { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); if (!axeBinary) { throw new DependencyError('AXe binary not found'); } // Add --udid parameter to all commands const fullArgs = [...commandArgs, '--udid', simulatorUuid]; // Construct the full command array with the axe binary as the first element const fullCommand = [axeBinary, ...fullArgs]; try { // Determine environment variables for bundled AXe const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); if (!result.success) { throw new AxeError( `axe command '${commandName}' failed.`, commandName, result.error ?? result.output, simulatorUuid, ); } // Check for stderr output in successful commands if (result.error) { log( 'warn', `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, ); } // Function now returns void - the calling code creates its own response } catch (error) { if (error instanceof Error) { if (error instanceof AxeError) { throw error; } // Otherwise wrap it in a SystemError throw new SystemError(`Failed to execute axe command: ${error.message}`, error); } // For any other type of error throw new SystemError(`Failed to execute axe command: ${String(error)}`); } } ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/gesture.ts: -------------------------------------------------------------------------------- ```typescript /** * UI Testing Plugin: Gesture * * Perform gesture on iOS simulator using preset gestures: scroll-up, scroll-down, scroll-left, scroll-right, * swipe-from-left-edge, swipe-from-right-edge, swipe-from-top-edge, swipe-from-bottom-edge. */ import { z } from 'zod'; import { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, createErrorResponse, DependencyError, AxeError, SystemError, } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createAxeNotAvailableResponse, getAxePath, getBundledAxeEnvironment, } from '../../../utils/axe/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject const gestureSchema = z.object({ simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), preset: z .enum([ 'scroll-up', 'scroll-down', 'scroll-left', 'scroll-right', 'swipe-from-left-edge', 'swipe-from-right-edge', 'swipe-from-top-edge', 'swipe-from-bottom-edge', ]) .describe( '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.', ), screenWidth: z .number() .int() .min(1) .optional() .describe( 'Optional: Screen width in pixels. Used for gesture calculations. Auto-detected if not provided.', ), screenHeight: z .number() .int() .min(1) .optional() .describe( 'Optional: Screen height in pixels. Used for gesture calculations. Auto-detected if not provided.', ), duration: z .number() .min(0, 'Duration must be non-negative') .optional() .describe('Optional: Duration of the gesture in seconds.'), delta: z .number() .min(0, 'Delta must be non-negative') .optional() .describe('Optional: Distance to move in pixels.'), preDelay: z .number() .min(0, 'Pre-delay must be non-negative') .optional() .describe('Optional: Delay before starting the gesture in seconds.'), postDelay: z .number() .min(0, 'Post-delay must be non-negative') .optional() .describe('Optional: Delay after completing the gesture in seconds.'), }); // Use z.infer for type safety type GestureParams = z.infer<typeof gestureSchema>; export interface AxeHelpers { getAxePath: () => string | null; getBundledAxeEnvironment: () => Record<string, string>; createAxeNotAvailableResponse: () => ToolResponse; } const LOG_PREFIX = '[AXe]'; export async function gestureLogic( params: GestureParams, executor: CommandExecutor, axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }, ): Promise<ToolResponse> { const toolName = 'gesture'; const { simulatorUuid, preset, screenWidth, screenHeight, duration, delta, preDelay, postDelay } = params; const commandArgs = ['gesture', preset]; if (screenWidth !== undefined) { commandArgs.push('--screen-width', String(screenWidth)); } if (screenHeight !== undefined) { commandArgs.push('--screen-height', String(screenHeight)); } if (duration !== undefined) { commandArgs.push('--duration', String(duration)); } if (delta !== undefined) { commandArgs.push('--delta', String(delta)); } if (preDelay !== undefined) { commandArgs.push('--pre-delay', String(preDelay)); } if (postDelay !== undefined) { commandArgs.push('--post-delay', String(postDelay)); } log('info', `${LOG_PREFIX}/${toolName}: Starting gesture '${preset}' on ${simulatorUuid}`); try { await executeAxeCommand(commandArgs, simulatorUuid, 'gesture', executor, axeHelpers); log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); return createTextResponse(`Gesture '${preset}' executed successfully.`); } catch (error) { log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); if (error instanceof DependencyError) { return axeHelpers.createAxeNotAvailableResponse(); } else if (error instanceof AxeError) { return createErrorResponse( `Failed to execute gesture '${preset}': ${error.message}`, error.axeOutput, ); } else if (error instanceof SystemError) { return createErrorResponse( `System error executing axe: ${error.message}`, error.originalError?.stack, ); } return createErrorResponse( `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, ); } } export default { name: 'gesture', description: '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', schema: gestureSchema.shape, // MCP SDK compatibility handler: createTypedTool( gestureSchema, (params: GestureParams, executor: CommandExecutor) => { return gestureLogic(params, executor, { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse, }); }, getDefaultCommandExecutor, ), }; // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( commandArgs: string[], simulatorUuid: string, commandName: string, executor: CommandExecutor = getDefaultCommandExecutor(), axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, ): Promise<void> { // Get the appropriate axe binary path const axeBinary = axeHelpers.getAxePath(); if (!axeBinary) { throw new DependencyError('AXe binary not found'); } // Add --udid parameter to all commands const fullArgs = [...commandArgs, '--udid', simulatorUuid]; // Construct the full command array with the axe binary as the first element const fullCommand = [axeBinary, ...fullArgs]; try { // Determine environment variables for bundled AXe const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); if (!result.success) { throw new AxeError( `axe command '${commandName}' failed.`, commandName, result.error ?? result.output, simulatorUuid, ); } // Check for stderr output in successful commands if (result.error) { log( 'warn', `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, ); } // Function now returns void - the calling code creates its own response } catch (error) { if (error instanceof Error) { if (error instanceof AxeError) { throw error; } // Otherwise wrap it in a SystemError throw new SystemError(`Failed to execute axe command: ${error.message}`, error); } // For any other type of error throw new SystemError(`Failed to execute axe command: ${String(error)}`); } } ``` -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- ```yaml name: Release on: push: tags: - 'v*' workflow_dispatch: inputs: version: description: 'Test version (e.g., 1.9.1-test)' required: true type: string permissions: contents: write id-token: write jobs: release: runs-on: macos-latest steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '24' registry-url: 'https://registry.npmjs.org' - name: Clear npm cache and install dependencies run: | npm cache clean --force rm -rf node_modules package-lock.json npm install --ignore-scripts - name: Check formatting run: npm run format:check - name: Bundle AXe artifacts run: npm run bundle:axe - name: Build TypeScript run: npm run build - name: Run tests run: npm test - name: Get version from tag or input id: get_version run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION="${{ github.event.inputs.version }}" echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "IS_TEST=true" >> $GITHUB_OUTPUT echo "📝 Test version: $VERSION" # Update package.json version for test releases only npm version $VERSION --no-git-tag-version else VERSION=${GITHUB_REF#refs/tags/v} echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "IS_TEST=false" >> $GITHUB_OUTPUT echo "🚀 Release version: $VERSION" # For tag-based releases, package.json was already updated by release script fi - name: Create package run: npm pack - name: Test publish (dry run for manual triggers) if: github.event_name == 'workflow_dispatch' run: | echo "🧪 Testing package creation (dry run)" npm publish --dry-run --access public - name: Publish to NPM (production releases only) if: github.event_name == 'push' run: | VERSION="${{ steps.get_version.outputs.VERSION }}" # Skip if this exact version is already published (idempotent reruns) if npm view xcodebuildmcp@"$VERSION" version >/dev/null 2>&1; then echo "✅ xcodebuildmcp@$VERSION already on NPM. Skipping publish." exit 0 fi # Determine the appropriate npm tag based on version if [[ "$VERSION" == *"-beta"* ]]; then NPM_TAG="beta" elif [[ "$VERSION" == *"-alpha"* ]]; then NPM_TAG="alpha" elif [[ "$VERSION" == *"-rc"* ]]; then NPM_TAG="rc" else # For stable releases, explicitly use latest tag NPM_TAG="latest" fi echo "📦 Publishing to NPM with tag: $NPM_TAG" npm publish --access public --tag "$NPM_TAG" env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create GitHub Release (production releases only) if: github.event_name == 'push' uses: softprops/action-gh-release@v1 with: tag_name: v${{ steps.get_version.outputs.VERSION }} name: Release v${{ steps.get_version.outputs.VERSION }} body: | ## Release v${{ steps.get_version.outputs.VERSION }} ### Features - Bundled AXe binary and frameworks for zero-setup UI automation - No manual installation required - works out of the box ### Installation ```bash npm install -g xcodebuildmcp@${{ steps.get_version.outputs.VERSION }} ``` Or use with npx: ```bash npx xcodebuildmcp@${{ steps.get_version.outputs.VERSION }} ``` 📦 **NPM Package**: https://www.npmjs.com/package/xcodebuildmcp/v/${{ steps.get_version.outputs.VERSION }} ### What's Included - Latest AXe binary from [cameroncooke/axe](https://github.com/cameroncooke/axe) - All required frameworks (FBControlCore, FBDeviceControl, FBSimulatorControl, XCTestBootstrap) - Full XcodeBuildMCP functionality with UI automation support files: | xcodebuildmcp-${{ steps.get_version.outputs.VERSION }}.tgz draft: false prerelease: false - name: Summary run: | if [ "${{ steps.get_version.outputs.IS_TEST }}" = "true" ]; then echo "🧪 Test completed for version: ${{ steps.get_version.outputs.VERSION }}" echo "Ready for production release!" else echo "🎉 Production release completed!" echo "Version: ${{ steps.get_version.outputs.VERSION }}" echo "📦 NPM: https://www.npmjs.com/package/xcodebuildmcp/v/${{ steps.get_version.outputs.VERSION }}" echo "📚 MCP Registry: publish attempted in separate job (mcp_registry)" fi mcp_registry: if: github.event_name == 'push' needs: release runs-on: ubuntu-latest env: MCP_DNS_PRIVATE_KEY: ${{ secrets.MCP_DNS_PRIVATE_KEY }} steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Get version from tag id: get_version_mcp run: | VERSION=${GITHUB_REF#refs/tags/v} echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "🚢 MCP publish for version: $VERSION" - name: Missing secret — skip MCP publish if: env.MCP_DNS_PRIVATE_KEY == '' run: | echo "⚠️ Skipping MCP Registry publish: secrets.MCP_DNS_PRIVATE_KEY is not set." echo "This is optional and does not affect the release." - name: Setup Go (for MCP Publisher) if: env.MCP_DNS_PRIVATE_KEY != '' uses: actions/setup-go@v5 with: go-version: '1.22' - name: Install MCP Publisher if: env.MCP_DNS_PRIVATE_KEY != '' run: | echo "📥 Fetching MCP Publisher" git clone https://github.com/modelcontextprotocol/registry publisher-repo cd publisher-repo make publisher cp bin/mcp-publisher ../mcp-publisher cd .. chmod +x mcp-publisher - name: Login to MCP Registry (DNS) if: env.MCP_DNS_PRIVATE_KEY != '' run: | echo "🔐 Using DNS authentication for com.xcodebuildmcp/* namespace" ./mcp-publisher login dns --domain xcodebuildmcp.com --private-key "${MCP_DNS_PRIVATE_KEY}" - name: Publish to MCP Registry (best-effort) if: env.MCP_DNS_PRIVATE_KEY != '' run: | echo "🚢 Publishing to MCP Registry with retries..." attempts=0 max_attempts=5 delay=5 until ./mcp-publisher publish; do rc=$? attempts=$((attempts+1)) if [ $attempts -ge $max_attempts ]; then echo "⚠️ MCP Registry publish failed after $attempts attempts (exit $rc). Skipping without failing workflow." exit 0 fi echo "⚠️ Publish failed (exit $rc). Retrying in ${delay}s... (attempt ${attempts}/${max_attempts})" sleep $delay delay=$((delay*2)) done echo "✅ MCP Registry publish succeeded." ``` -------------------------------------------------------------------------------- /src/utils/__tests__/build-utils.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Tests for build-utils Sentry classification logic */ import { describe, it, expect } from 'vitest'; import { createMockExecutor } from '../../test-utils/mock-executors.ts'; import { executeXcodeBuildCommand } from '../build-utils.ts'; import { XcodePlatform } from '../xcode.ts'; describe('build-utils Sentry Classification', () => { const mockPlatformOptions = { platform: XcodePlatform.macOS, logPrefix: 'Test Build', }; const mockParams = { scheme: 'TestScheme', configuration: 'Debug', projectPath: '/path/to/project.xcodeproj', }; describe('Exit Code 64 Classification (MCP Error)', () => { it('should trigger Sentry logging for exit code 64 (invalid arguments)', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'xcodebuild: error: invalid option', exitCode: 64, }); const result = await executeXcodeBuildCommand( mockParams, mockPlatformOptions, false, 'build', mockExecutor, ); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('❌ [stderr] xcodebuild: error: invalid option'); expect(result.content[1].text).toContain('❌ Test Build build failed for scheme TestScheme'); }); }); describe('Other Exit Codes Classification (User Error)', () => { it('should not trigger Sentry logging for exit code 65 (user error)', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Scheme TestScheme was not found', exitCode: 65, }); const result = await executeXcodeBuildCommand( mockParams, mockPlatformOptions, false, 'build', mockExecutor, ); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('❌ [stderr] Scheme TestScheme was not found'); expect(result.content[1].text).toContain('❌ Test Build build failed for scheme TestScheme'); }); it('should not trigger Sentry logging for exit code 66 (file not found)', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'project.xcodeproj cannot be opened', exitCode: 66, }); const result = await executeXcodeBuildCommand( mockParams, mockPlatformOptions, false, 'build', mockExecutor, ); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('❌ [stderr] project.xcodeproj cannot be opened'); }); it('should not trigger Sentry logging for exit code 70 (destination error)', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Unable to find a destination matching the provided destination specifier', exitCode: 70, }); const result = await executeXcodeBuildCommand( mockParams, mockPlatformOptions, false, 'build', mockExecutor, ); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('❌ [stderr] Unable to find a destination matching'); }); it('should not trigger Sentry logging for exit code 1 (general build failure)', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Build failed with errors', exitCode: 1, }); const result = await executeXcodeBuildCommand( mockParams, mockPlatformOptions, false, 'build', mockExecutor, ); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('❌ [stderr] Build failed with errors'); }); }); describe('Spawn Error Classification (Environment Error)', () => { it('should not trigger Sentry logging for ENOENT spawn error', async () => { const spawnError = new Error('spawn xcodebuild ENOENT') as NodeJS.ErrnoException; spawnError.code = 'ENOENT'; const mockExecutor = createMockExecutor({ success: false, error: '', shouldThrow: spawnError, }); const result = await executeXcodeBuildCommand( mockParams, mockPlatformOptions, false, 'build', mockExecutor, ); expect(result.isError).toBe(true); expect(result.content[0].text).toContain( 'Error during Test Build build: spawn xcodebuild ENOENT', ); }); it('should not trigger Sentry logging for EACCES spawn error', async () => { const spawnError = new Error('spawn xcodebuild EACCES') as NodeJS.ErrnoException; spawnError.code = 'EACCES'; const mockExecutor = createMockExecutor({ success: false, error: '', shouldThrow: spawnError, }); const result = await executeXcodeBuildCommand( mockParams, mockPlatformOptions, false, 'build', mockExecutor, ); expect(result.isError).toBe(true); expect(result.content[0].text).toContain( 'Error during Test Build build: spawn xcodebuild EACCES', ); }); it('should not trigger Sentry logging for EPERM spawn error', async () => { const spawnError = new Error('spawn xcodebuild EPERM') as NodeJS.ErrnoException; spawnError.code = 'EPERM'; const mockExecutor = createMockExecutor({ success: false, error: '', shouldThrow: spawnError, }); const result = await executeXcodeBuildCommand( mockParams, mockPlatformOptions, false, 'build', mockExecutor, ); expect(result.isError).toBe(true); expect(result.content[0].text).toContain( 'Error during Test Build build: spawn xcodebuild EPERM', ); }); it('should trigger Sentry logging for non-spawn exceptions', async () => { const otherError = new Error('Unexpected internal error'); const mockExecutor = createMockExecutor({ success: false, error: '', shouldThrow: otherError, }); const result = await executeXcodeBuildCommand( mockParams, mockPlatformOptions, false, 'build', mockExecutor, ); expect(result.isError).toBe(true); expect(result.content[0].text).toContain( 'Error during Test Build build: Unexpected internal error', ); }); }); describe('Success Case (No Sentry Logging)', () => { it('should not trigger any error logging for successful builds', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED', exitCode: 0, }); const result = await executeXcodeBuildCommand( mockParams, mockPlatformOptions, false, 'build', mockExecutor, ); expect(result.isError).toBeFalsy(); expect(result.content[0].text).toContain( '✅ Test Build build succeeded for scheme TestScheme', ); }); }); describe('Exit Code Undefined Cases', () => { it('should not trigger Sentry logging when exitCode is undefined', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Some error without exit code', exitCode: undefined, }); const result = await executeXcodeBuildCommand( mockParams, mockPlatformOptions, false, 'build', mockExecutor, ); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('❌ [stderr] Some error without exit code'); }); }); }); ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- ```markdown # Changelog ## [1.14.0] - 2025-09-22 - Add video capture tool for simulators ## [1.13.1] - 2025-09-21 - Add simulator erase content and settings tool ## [1.12.3] - 2025-08-22 - Pass environment variables to test runs on device, simulator, and macOS via an optional testRunnerEnv input (auto-prefixed as TEST_RUNNER_). ## [1.12.2] - 2025-08-21 ### Fixed - **Clean tool**: Fixed issue where clean would fail for simulators ## [1.12.1] - 2025-08-18 ### Improved - **Sentry Logging**: No longer logs domain errors to Sentry, now only logs MCP server errors. ## [1.12.0] - 2025-08-17 ### Added - 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%! - **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!) - Rename `diagnosics` tool and cli to `doctor` - Add Sentry instrumentation to track MCP usage statistics (can be disabled by setting `XCODEBUILDMCP_SENTRY_DISABLED=true`) - Add support for MCP setLevel handler to allow clients to control the log level of the MCP server ## [v1.11.2] - 2025-08-08 - Fixed "registerTools is not a function" errors during package upgrades ## [v1.11.1] - 2025-08-07 - Improved tool discovery to be more accurate and context-aware ## [v1.11.0] - 2025-08-07 - Major refactor/rewrite to improve code quality and maintainability in preparation for future development - Added support for dynamic tools (VSCode only for now) - Added support for MCP Resources (devices, simulators, environment info) - 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 ## [v1.10.0] - 2025-06-10 ### Added - **App Lifecycle Management**: New tools for stopping running applications - `stop_app_device`: Stop apps running on physical Apple devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) - `stop_app_sim`: Stop apps running on iOS/watchOS/tvOS/visionOS simulators - `stop_mac_app`: Stop macOS applications by name or process ID - **Enhanced Launch Tools**: Device launch tools now return process IDs for better app management - **Bundled AXe Distribution**: AXe binary and frameworks now included in npm package for zero-setup UI automation ### Fixed - **WiFi Device Detection**: Improved detection of Apple devices connected over WiFi networks - **Device Connectivity**: Better handling of paired devices with different connection states ### Improved - **Simplified Installation**: No separate AXe installation required - everything works out of the box ## [v1.9.0] - 2025-06-09 - Added support for hardware devices over USB and Wi-Fi - New tools for Apple device deployment: - `install_app_device` - `launch_app_device` - Updated all simulator and device tools to be platform-agnostic, supporting all Apple platforms (iOS, iPadOS, watchOS, tvOS, visionOS) - Changed `get_ios_bundle_id` to `get_app_bundle_id` with support for all Apple platforms ## [v1.8.0] - 2025-06-07 - Added support for running tests on macOS, iOS simulators, and iOS devices - New tools for testing: - `test_macos_workspace` - `test_macos_project` - `test_ios_simulator_name_workspace` - `test_ios_simulator_name_project` - `test_ios_simulator_id_workspace` - `test_ios_simulator_id_project` - `test_ios_device_workspace` - `test_ios_device_project` ## [v1.7.0] - 2025-06-04 - Added support for Swift Package Manager (SPM) - New tools for Swift Package Manager: - `swift_package_build` - `swift_package_clean` - `swift_package_test` - `swift_package_run` - `swift_package_list` - `swift_package_stop` ## [v1.6.1] - 2025-06-03 - Improve UI tool hints ## [v1.6.0] - 2025-06-03 - Moved project templates to external GitHub repositories for independent versioning - Added support for downloading templates from GitHub releases - Added local template override support via environment variables - Added `scaffold_ios_project` and `scaffold_macos_project` tools for creating new projects - Centralized template version management in package.json for easier updates ## [v1.5.0] - 2025-06-01 - UI automation is no longer in beta! - Added support for AXe UI automation - Revised default installation instructions to prefer npx instead of mise ## [v1.4.0] - 2025-05-11 - Merge the incremental build beta branch into main - 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. ## [v1.3.7] - 2025-05-08 - Fix Claude Code issue due to long tool names ## [v1.4.0-beta.3] - 2025-05-07 - Fixed issue where incremental builds would only work for "Debug" build configurations - ## [v1.4.0-beta.2] - 2025-05-07 - Same as beta 1 but has the latest features from the main release channel ## [v1.4.0-beta.1] - 2025-05-05 - Added experimental support for incremental builds (requires opt-in) ## [v1.3.6] - 2025-05-07 - Added support for enabling/disabling tools via environment variables ## [v1.3.5] - 2025-05-05 - Fixed the text input UI automation tool - Improve the UI automation tool hints to reduce agent tool call errors - Improved the project discovery tool to reduce agent tool call errors - Added instructions for installing idb client manually ## [v1.3.4] - 2025-05-04 - Improved Sentry integration ## [v1.3.3] - 2025-05-04 - Added Sentry opt-out functionality ## [v1.3.1] - 2025-05-03 - Added Sentry integration for error reporting ## [v1.3.0] - 2025-04-28 - Added support for interacting with the simulator (tap, swipe etc.) - Added support for capturing simulator screenshots Please note that the UI automation features are an early preview and currently in beta your mileage may vary. ## [v1.2.4] - 2025-04-24 - Improved xcodebuild reporting of warnings and errors in tool response - Refactor build utils and remove redundant code ## [v1.2.3] - 2025-04-23 - Added support for skipping macro validation ## [v1.2.2] - 2025-04-23 - Improved log readability with version information for easier debugging - Enhanced overall stability and performance ## [v1.2.1] - 2025-04-23 - General stability improvements and bug fixes ## [v1.2.0] - 2025-04-14 ### Added - New simulator log capture feature: Easily view and debug your app's logs while running in the simulator - Automatic project discovery: XcodeBuildMCP now finds your Xcode projects and workspaces automatically - Support for both Intel and Apple Silicon Macs in macOS builds ### Improved - Cleaner, more readable build output with better error messages - Faster build times and more reliable build process - Enhanced documentation with clearer usage examples ## [v1.1.0] - 2025-04-05 ### Added - Real-time build progress reporting - Separate tools for iOS and macOS builds - Better workspace and project support ### Improved - Simplified build commands with better parameter handling - More reliable clean operations for both projects and workspaces ## [v1.0.2] - 2025-04-02 - Improved documentation with better examples and clearer instructions - Easier version tracking for compatibility checks ## [v1.0.1] - 2025-04-02 - Initial release of XcodeBuildMCP - Basic support for building iOS and macOS applications ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import plugin, { stop_app_simLogic } from '../stop_app_sim.ts'; describe('stop_app_sim tool', () => { beforeEach(() => { sessionStore.clear(); }); describe('Export Field Validation (Literal)', () => { it('should expose correct metadata', () => { expect(plugin.name).toBe('stop_app_sim'); expect(plugin.description).toBe('Stops an app running in an iOS simulator.'); }); it('should expose public schema with only bundleId', () => { const schema = z.object(plugin.schema); expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); expect(schema.safeParse({}).success).toBe(false); expect(schema.safeParse({ bundleId: 42 }).success).toBe(false); expect(Object.keys(plugin.schema)).toEqual(['bundleId']); }); }); describe('Handler Requirements', () => { it('should require simulator identifier when not provided', async () => { const result = await plugin.handler({ bundleId: 'com.example.app' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); expect(result.content[0].text).toContain('session-set-defaults'); }); it('should validate bundleId when simulatorId default exists', async () => { sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); const result = await plugin.handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Parameter validation failed'); expect(result.content[0].text).toContain('bundleId: Required'); expect(result.content[0].text).toContain( 'Tip: set session defaults via session-set-defaults', ); }); it('should reject mutually exclusive simulator parameters', async () => { const result = await plugin.handler({ simulatorId: 'SIM-UUID', simulatorName: 'iPhone 16', bundleId: 'com.example.app', }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); expect(result.content[0].text).toContain('simulatorId'); expect(result.content[0].text).toContain('simulatorName'); }); }); describe('Logic Behavior (Literal Returns)', () => { it('should stop app successfully with simulatorId', async () => { const mockExecutor = createMockExecutor({ success: true, output: '' }); const result = await stop_app_simLogic( { simulatorId: 'test-uuid', bundleId: 'com.example.App', }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: '✅ App com.example.App stopped successfully in simulator test-uuid', }, ], }); }); it('should stop app successfully when resolving simulatorName', async () => { let callCount = 0; const sequencedExecutor = async (command: string[]) => { callCount++; if (callCount === 1) { return { success: true, output: JSON.stringify({ devices: { 'iOS 17.0': [ { name: 'iPhone 16', udid: 'resolved-uuid', isAvailable: true, state: 'Booted' }, ], }, }), error: '', process: {} as any, }; } return { success: true, output: '', error: '', process: {} as any, }; }; const result = await stop_app_simLogic( { simulatorName: 'iPhone 16', bundleId: 'com.example.App', }, sequencedExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: '✅ App com.example.App stopped successfully in simulator "iPhone 16" (resolved-uuid)', }, ], }); }); it('should handle simulator lookup failure', async () => { const listExecutor = createMockExecutor({ success: true, output: JSON.stringify({ devices: {} }), error: '', }); const result = await stop_app_simLogic( { simulatorName: 'Unknown Simulator', bundleId: 'com.example.App', }, listExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Simulator named "Unknown Simulator" not found. Use list_sims to see available simulators.', }, ], isError: true, }); }); it('should handle simulator list command failure', async () => { const listExecutor = createMockExecutor({ success: false, output: '', error: 'simctl list failed', }); const result = await stop_app_simLogic( { simulatorName: 'iPhone 16', bundleId: 'com.example.App', }, listExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Failed to list simulators: simctl list failed', }, ], isError: true, }); }); it('should surface terminate failures', async () => { const terminateExecutor = createMockExecutor({ success: false, output: '', error: 'Simulator not found', }); const result = await stop_app_simLogic( { simulatorId: 'invalid-uuid', bundleId: 'com.example.App', }, terminateExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Stop app in simulator operation failed: Simulator not found', }, ], isError: true, }); }); it('should handle unexpected exceptions', async () => { const throwingExecutor = async () => { throw new Error('Unexpected error'); }; const result = await stop_app_simLogic( { simulatorId: 'test-uuid', bundleId: 'com.example.App', }, throwingExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Stop app in simulator operation failed: Unexpected error', }, ], isError: true, }); }); it('should call correct terminate command', async () => { const calls: Array<{ command: string[]; description: string; suppressErrorLogging: boolean; timeout?: number; }> = []; const trackingExecutor = async ( command: string[], description: string, suppressErrorLogging: boolean, timeout?: number, ) => { calls.push({ command, description, suppressErrorLogging, timeout }); return { success: true, output: '', error: undefined, process: { pid: 12345 }, }; }; await stop_app_simLogic( { simulatorId: 'test-uuid', bundleId: 'com.example.App', }, trackingExecutor, ); expect(calls).toEqual([ { command: ['xcrun', 'simctl', 'terminate', 'test-uuid', 'com.example.App'], description: 'Stop App in Simulator', suppressErrorLogging: true, timeout: undefined, }, ]); }); }); }); ``` -------------------------------------------------------------------------------- /src/utils/xcodemake.ts: -------------------------------------------------------------------------------- ```typescript /** * xcodemake Utilities - Support for using xcodemake as an alternative build strategy * * This utility module provides functions for using xcodemake (https://github.com/johnno1962/xcodemake) * as an alternative build strategy for Xcode projects. xcodemake logs xcodebuild output to generate * a Makefile for an Xcode project, allowing for faster incremental builds using the "make" command. * * Responsibilities: * - Checking if xcodemake is enabled via environment variable * - Executing xcodemake commands with proper argument handling * - Converting xcodebuild arguments to xcodemake arguments * - Handling xcodemake-specific output and error reporting * - Auto-downloading xcodemake if enabled but not found */ import { log } from './logger.ts'; import { CommandResponse, getDefaultCommandExecutor } from './command.ts'; import { existsSync, readdirSync } from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs/promises'; // Environment variable to control xcodemake usage export const XCODEMAKE_ENV_VAR = 'INCREMENTAL_BUILDS_ENABLED'; // Store the overridden path for xcodemake if needed let overriddenXcodemakePath: string | null = null; /** * Check if xcodemake is enabled via environment variable * @returns boolean indicating if xcodemake should be used */ export function isXcodemakeEnabled(): boolean { const envValue = process.env[XCODEMAKE_ENV_VAR]; return envValue === '1' || envValue === 'true' || envValue === 'yes'; } /** * Get the xcodemake command to use * @returns The command string for xcodemake */ function getXcodemakeCommand(): string { return overriddenXcodemakePath ?? 'xcodemake'; } /** * Override the xcodemake command path * @param path Path to the xcodemake executable */ function overrideXcodemakeCommand(path: string): void { overriddenXcodemakePath = path; log('info', `Using overridden xcodemake path: ${path}`); } /** * Install xcodemake by downloading it from GitHub * @returns Promise resolving to boolean indicating if installation was successful */ async function installXcodemake(): Promise<boolean> { const tempDir = os.tmpdir(); const xcodemakeDir = path.join(tempDir, 'xcodebuildmcp'); const xcodemakePath = path.join(xcodemakeDir, 'xcodemake'); log('info', `Attempting to install xcodemake to ${xcodemakePath}`); try { // Create directory if it doesn't exist await fs.mkdir(xcodemakeDir, { recursive: true }); // Download the script log('info', 'Downloading xcodemake from GitHub...'); const response = await fetch( 'https://raw.githubusercontent.com/cameroncooke/xcodemake/main/xcodemake', ); if (!response.ok) { throw new Error(`Failed to download xcodemake: ${response.status} ${response.statusText}`); } const scriptContent = await response.text(); await fs.writeFile(xcodemakePath, scriptContent, 'utf8'); // Make executable await fs.chmod(xcodemakePath, 0o755); log('info', 'Made xcodemake executable'); // Override the command to use the direct path overrideXcodemakeCommand(xcodemakePath); return true; } catch (error) { log( 'error', `Error installing xcodemake: ${error instanceof Error ? error.message : String(error)}`, ); return false; } } /** * Check if xcodemake is installed and available. If enabled but not available, attempts to download it. * @returns Promise resolving to boolean indicating if xcodemake is available */ export async function isXcodemakeAvailable(): Promise<boolean> { // First check if xcodemake is enabled, if not, no need to check or install if (!isXcodemakeEnabled()) { log('debug', 'xcodemake is not enabled, skipping availability check'); return false; } try { // Check if we already have an overridden path if (overriddenXcodemakePath && existsSync(overriddenXcodemakePath)) { log('debug', `xcodemake found at overridden path: ${overriddenXcodemakePath}`); return true; } // Check if xcodemake is available in PATH const result = await getDefaultCommandExecutor()(['which', 'xcodemake']); if (result.success) { log('debug', 'xcodemake found in PATH'); return true; } // If not found, download and install it log('info', 'xcodemake not found in PATH, attempting to download...'); const installed = await installXcodemake(); if (installed) { log('info', 'xcodemake installed successfully'); return true; } else { log('warn', 'xcodemake installation failed'); return false; } } catch (error) { log( 'error', `Error checking for xcodemake: ${error instanceof Error ? error.message : String(error)}`, ); return false; } } /** * Check if a Makefile exists in the current directory * @returns boolean indicating if a Makefile exists */ export function doesMakefileExist(projectDir: string): boolean { return existsSync(`${projectDir}/Makefile`); } /** * Check if a Makefile log exists in the current directory * @param projectDir Directory containing the Makefile * @param command Command array to check for log file * @returns boolean indicating if a Makefile log exists */ export function doesMakeLogFileExist(projectDir: string, command: string[]): boolean { // Change to the project directory as xcodemake requires being in the project dir const originalDir = process.cwd(); try { process.chdir(projectDir); // Construct the expected log filename const xcodemakeCommand = ['xcodemake', ...command.slice(1)]; const escapedCommand = xcodemakeCommand.map((arg) => { // Remove projectDir from arguments if present at the start const prefix = projectDir + '/'; if (arg.startsWith(prefix)) { return arg.substring(prefix.length); } return arg; }); const commandString = escapedCommand.join(' '); const logFileName = `${commandString}.log`; log('debug', `Checking for Makefile log: ${logFileName} in directory: ${process.cwd()}`); // Read directory contents and check if the file exists const files = readdirSync('.'); const exists = files.includes(logFileName); log('debug', `Makefile log ${exists ? 'exists' : 'does not exist'}: ${logFileName}`); return exists; } catch (error) { // Log potential errors like directory not found, permissions issues, etc. log( 'error', `Error checking for Makefile log: ${error instanceof Error ? error.message : String(error)}`, ); return false; } finally { // Always restore the original directory process.chdir(originalDir); } } /** * Execute an xcodemake command to generate a Makefile * @param buildArgs Build arguments to pass to xcodemake (without the 'xcodebuild' command) * @param logPrefix Prefix for logging * @returns Promise resolving to command response */ export async function executeXcodemakeCommand( projectDir: string, buildArgs: string[], logPrefix: string, ): Promise<CommandResponse> { // Change directory to project directory, this is needed for xcodemake to work process.chdir(projectDir); const xcodemakeCommand = [getXcodemakeCommand(), ...buildArgs]; // Remove projectDir from arguments const command = xcodemakeCommand.map((arg) => arg.replace(projectDir + '/', '')); return getDefaultCommandExecutor()(command, logPrefix); } /** * Execute a make command for incremental builds * @param projectDir Directory containing the Makefile * @param logPrefix Prefix for logging * @returns Promise resolving to command response */ export async function executeMakeCommand( projectDir: string, logPrefix: string, ): Promise<CommandResponse> { const command = ['cd', projectDir, '&&', 'make']; return getDefaultCommandExecutor()(command, logPrefix); } ``` -------------------------------------------------------------------------------- /src/utils/validation.ts: -------------------------------------------------------------------------------- ```typescript /** * Validation Utilities - Input validation and error response generation * * This utility module provides a comprehensive set of validation functions to ensure * that tool inputs meet expected requirements. It centralizes validation logic, * error message formatting, and response generation for consistent error handling * across the application. * * Responsibilities: * - Validating required parameters (validateRequiredParam) * - Checking parameters against allowed values (validateAllowedValues, validateEnumParam) * - Verifying file existence (validateFileExists) * - Validating logical conditions (validateCondition) * - Ensuring at least one of multiple parameters is provided (validateAtLeastOneParam) * - Creating standardized response objects for tools (createTextResponse) * * Using these validation utilities ensures consistent error messaging and helps * provide clear feedback to users when their inputs don't meet requirements. * The functions return ValidationResult objects that make it easy to chain * validations and generate appropriate responses. */ import * as fs from 'fs'; import { log } from './logger.ts'; import { ToolResponse, ValidationResult } from '../types/common.ts'; import { FileSystemExecutor } from './FileSystemExecutor.ts'; import { getDefaultEnvironmentDetector } from './environment.ts'; /** * Creates a text response with the given message * @param message The message to include in the response * @param isError Whether this is an error response * @returns A ToolResponse object with the message */ export function createTextResponse(message: string, isError = false): ToolResponse { return { content: [ { type: 'text', text: message, }, ], isError, }; } /** * Validates that a required parameter is present * @param paramName Name of the parameter * @param paramValue Value of the parameter * @param helpfulMessage Optional helpful message to include in the error response * @returns Validation result */ export function validateRequiredParam( paramName: string, paramValue: unknown, helpfulMessage = `Required parameter '${paramName}' is missing. Please provide a value for this parameter.`, ): ValidationResult { if (paramValue === undefined || paramValue === null) { log('warning', `Required parameter '${paramName}' is missing`); return { isValid: false, errorResponse: createTextResponse(helpfulMessage, true), }; } return { isValid: true }; } /** * Validates that a parameter value is one of the allowed values * @param paramName Name of the parameter * @param paramValue Value of the parameter * @param allowedValues Array of allowed values * @returns Validation result */ export function validateAllowedValues<T>( paramName: string, paramValue: T, allowedValues: T[], ): ValidationResult { if (!allowedValues.includes(paramValue)) { log( 'warning', `Parameter '${paramName}' has invalid value '${paramValue}'. Allowed values: ${allowedValues.join( ', ', )}`, ); return { isValid: false, errorResponse: createTextResponse( `Parameter '${paramName}' must be one of: ${allowedValues.join(', ')}. You provided: '${paramValue}'.`, true, ), }; } return { isValid: true }; } /** * Validates that a condition is true * @param condition Condition to validate * @param message Message to include in the warning response * @param logWarning Whether to log a warning message * @returns Validation result */ export function validateCondition( condition: boolean, message: string, logWarning: boolean = true, ): ValidationResult { if (!condition) { if (logWarning) { log('warning', message); } return { isValid: false, warningResponse: createTextResponse(message), }; } return { isValid: true }; } /** * Validates that a file exists * @param filePath Path to check * @returns Validation result */ export function validateFileExists( filePath: string, fileSystem?: FileSystemExecutor, ): ValidationResult { const exists = fileSystem ? fileSystem.existsSync(filePath) : fs.existsSync(filePath); if (!exists) { return { isValid: false, errorResponse: createTextResponse( `File not found: '${filePath}'. Please check the path and try again.`, true, ), }; } return { isValid: true }; } /** * Validates that at least one of two parameters is provided * @param param1Name Name of the first parameter * @param param1Value Value of the first parameter * @param param2Name Name of the second parameter * @param param2Value Value of the second parameter * @returns Validation result */ export function validateAtLeastOneParam( param1Name: string, param1Value: unknown, param2Name: string, param2Value: unknown, ): ValidationResult { if ( (param1Value === undefined || param1Value === null) && (param2Value === undefined || param2Value === null) ) { log('warning', `At least one of '${param1Name}' or '${param2Name}' must be provided`); return { isValid: false, errorResponse: createTextResponse( `At least one of '${param1Name}' or '${param2Name}' must be provided.`, true, ), }; } return { isValid: true }; } /** * Validates that a parameter value is one of the allowed enum values * @param paramName Name of the parameter * @param paramValue Value of the parameter * @param allowedValues Array of allowed enum values * @returns Validation result */ export function validateEnumParam<T>( paramName: string, paramValue: T, allowedValues: T[], ): ValidationResult { if (!allowedValues.includes(paramValue)) { log( 'warning', `Parameter '${paramName}' has invalid value '${paramValue}'. Allowed values: ${allowedValues.join( ', ', )}`, ); return { isValid: false, errorResponse: createTextResponse( `Parameter '${paramName}' must be one of: ${allowedValues.join(', ')}. You provided: '${paramValue}'.`, true, ), }; } return { isValid: true }; } /** * Consolidates multiple content blocks into a single text response for Claude Code compatibility * * Claude Code violates the MCP specification by only showing the first content block. * This function provides a workaround by concatenating all text content into a single block. * Detection is automatic - no environment variable configuration required. * * @param response The original ToolResponse with multiple content blocks * @returns A new ToolResponse with consolidated content */ export function consolidateContentForClaudeCode(response: ToolResponse): ToolResponse { // Automatically detect if running under Claude Code const shouldConsolidate = getDefaultEnvironmentDetector().isRunningUnderClaudeCode(); if (!shouldConsolidate || !response.content || response.content.length <= 1) { return response; } // Extract all text content and concatenate with separators const textParts: string[] = []; response.content.forEach((item, index) => { if (item.type === 'text') { // Add a separator between content blocks (except for the first one) if (index > 0 && textParts.length > 0) { textParts.push('\n---\n'); } textParts.push(item.text); } // Note: Image content is not handled in this workaround as it requires special formatting }); // If no text content was found, return the original response to preserve non-text content if (textParts.length === 0) { return response; } const consolidatedText = textParts.join(''); return { ...response, content: [ { type: 'text', text: consolidatedText, }, ], }; } // Export the ToolResponse type for use in other files export { ToolResponse, ValidationResult }; ``` -------------------------------------------------------------------------------- /src/utils/command.ts: -------------------------------------------------------------------------------- ```typescript /** * Command Utilities - Generic command execution utilities * * This utility module provides functions for executing shell commands. * It serves as a foundation for other utility modules that need to execute commands. * * Responsibilities: * - Executing shell commands with proper argument handling * - Managing process spawning, output capture, and error handling */ import { spawn } from 'child_process'; import { existsSync } from 'fs'; import { tmpdir as osTmpdir } from 'os'; import { log } from './logger.ts'; import { FileSystemExecutor } from './FileSystemExecutor.ts'; import { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; // Re-export types for backward compatibility export { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; export { FileSystemExecutor } from './FileSystemExecutor.ts'; /** * Default executor implementation using spawn (current production behavior) * Private instance - use getDefaultCommandExecutor() for access * @param command An array of command and arguments * @param logPrefix Prefix for logging * @param useShell Whether to use shell execution (true) or direct execution (false) * @param opts Optional execution options (env: environment variables to merge with process.env, cwd: working directory) * @param detached Whether to spawn process without waiting for completion (for streaming/background processes) * @returns Promise resolving to command response with the process */ async function defaultExecutor( command: string[], logPrefix?: string, useShell: boolean = true, opts?: CommandExecOptions, detached: boolean = false, ): Promise<CommandResponse> { // Properly escape arguments for shell let escapedCommand = command; if (useShell) { // For shell execution, we need to format as ['sh', '-c', 'full command string'] const commandString = command .map((arg) => { // Shell metacharacters that require quoting: space, quotes, equals, dollar, backticks, semicolons, pipes, etc. if (/[\s,"'=$`;&|<>(){}[\]\\*?~]/.test(arg) && !/^".*"$/.test(arg)) { // Escape all quotes and backslashes, then wrap in double quotes return `"${arg.replace(/(["\\])/g, '\\$1')}"`; } return arg; }) .join(' '); escapedCommand = ['sh', '-c', commandString]; } // Log the actual command that will be executed const displayCommand = useShell && escapedCommand.length === 3 ? escapedCommand[2] : escapedCommand.join(' '); log('info', `Executing ${logPrefix ?? ''} command: ${displayCommand}`); return new Promise((resolve, reject) => { const executable = escapedCommand[0]; const args = escapedCommand.slice(1); const spawnOpts: Parameters<typeof spawn>[2] = { stdio: ['ignore', 'pipe', 'pipe'], // ignore stdin, pipe stdout/stderr env: { ...process.env, ...(opts?.env ?? {}) }, cwd: opts?.cwd, }; const childProcess = spawn(executable, args, spawnOpts); let stdout = ''; let stderr = ''; childProcess.stdout?.on('data', (data: Buffer) => { stdout += data.toString(); }); childProcess.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); }); // For detached processes, handle differently to avoid race conditions if (detached) { // For detached processes, only wait for spawn success/failure let resolved = false; childProcess.on('error', (err) => { if (!resolved) { resolved = true; reject(err); } }); // Give a small delay to ensure the process starts successfully setTimeout(() => { if (!resolved) { resolved = true; if (childProcess.pid) { resolve({ success: true, output: '', // No output for detached processes process: childProcess, }); } else { resolve({ success: false, output: '', error: 'Failed to start detached process', process: childProcess, }); } } }, 100); } else { // For non-detached processes, handle normally childProcess.on('close', (code) => { const success = code === 0; const response: CommandResponse = { success, output: stdout, error: success ? undefined : stderr, process: childProcess, exitCode: code ?? undefined, }; resolve(response); }); childProcess.on('error', (err) => { reject(err); }); } }); } /** * Default file system executor implementation using Node.js fs/promises * Private instance - use getDefaultFileSystemExecutor() for access */ const defaultFileSystemExecutor: FileSystemExecutor = { async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> { const fs = await import('fs/promises'); await fs.mkdir(path, options); }, async readFile(path: string, encoding: BufferEncoding = 'utf8'): Promise<string> { const fs = await import('fs/promises'); const content = await fs.readFile(path, encoding); return content; }, async writeFile(path: string, content: string, encoding: BufferEncoding = 'utf8'): Promise<void> { const fs = await import('fs/promises'); await fs.writeFile(path, content, encoding); }, async cp(source: string, destination: string, options?: { recursive?: boolean }): Promise<void> { const fs = await import('fs/promises'); await fs.cp(source, destination, options); }, async readdir(path: string, options?: { withFileTypes?: boolean }): Promise<unknown[]> { const fs = await import('fs/promises'); return await fs.readdir(path, options as Record<string, unknown>); }, async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> { const fs = await import('fs/promises'); await fs.rm(path, options); }, existsSync(path: string): boolean { return existsSync(path); }, async stat(path: string): Promise<{ isDirectory(): boolean }> { const fs = await import('fs/promises'); return await fs.stat(path); }, async mkdtemp(prefix: string): Promise<string> { const fs = await import('fs/promises'); return await fs.mkdtemp(prefix); }, tmpdir(): string { return osTmpdir(); }, }; /** * Get default command executor with test safety * Throws error if used in test environment to ensure proper mocking */ export function getDefaultCommandExecutor(): CommandExecutor { if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') { throw new Error( `🚨 REAL SYSTEM EXECUTOR DETECTED IN TEST! 🚨\n` + `This test is trying to use the default command executor instead of a mock.\n` + `Fix: Pass createMockExecutor() as the commandExecutor parameter in your test.\n` + `Example: await plugin.handler(args, createMockExecutor({success: true}), mockFileSystem)\n` + `See docs/TESTING.md for proper testing patterns.`, ); } return defaultExecutor; } /** * Get default file system executor with test safety * Throws error if used in test environment to ensure proper mocking */ export function getDefaultFileSystemExecutor(): FileSystemExecutor { if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') { throw new Error( `🚨 REAL FILESYSTEM EXECUTOR DETECTED IN TEST! 🚨\n` + `This test is trying to use the default filesystem executor instead of a mock.\n` + `Fix: Pass createMockFileSystemExecutor() as the fileSystemExecutor parameter in your test.\n` + `Example: await plugin.handler(args, mockCmd, createMockFileSystemExecutor())\n` + `See docs/TESTING.md for proper testing patterns.`, ); } return defaultFileSystemExecutor; } ``` -------------------------------------------------------------------------------- /src/mcp/tools/doctor/lib/doctor.deps.ts: -------------------------------------------------------------------------------- ```typescript import * as os from 'os'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { loadWorkflowGroups, loadPlugins, getEnabledWorkflows, } from '../../../../utils/plugin-registry/index.ts'; import { areAxeToolsAvailable } from '../../../../utils/axe/index.ts'; import { isXcodemakeEnabled, isXcodemakeAvailable, doesMakefileExist, } from '../../../../utils/xcodemake/index.ts'; import { getTrackedToolNames } from '../../../../utils/tool-registry.ts'; export interface BinaryChecker { checkBinaryAvailability(binary: string): Promise<{ available: boolean; version?: string }>; } export interface XcodeInfoProvider { getXcodeInfo(): Promise< | { version: string; path: string; selectedXcode: string; xcrunVersion: string } | { error: string } >; } export interface EnvironmentInfoProvider { getEnvironmentVariables(): Record<string, string | undefined>; getSystemInfo(): { platform: string; release: string; arch: string; cpus: string; memory: string; hostname: string; username: string; homedir: string; tmpdir: string; }; getNodeInfo(): { version: string; execPath: string; pid: string; ppid: string; platform: string; arch: string; cwd: string; argv: string; }; } export interface PluginInfoProvider { getPluginSystemInfo(): Promise< | { totalPlugins: number; pluginDirectories: number; pluginsByDirectory: Record<string, string[]>; systemMode: string; } | { error: string; systemMode: string } >; } export interface RuntimeInfoProvider { getRuntimeToolInfo(): Promise< | { mode: 'dynamic'; enabledWorkflows: string[]; enabledTools: string[]; totalRegistered: number; } | { mode: 'static'; enabledWorkflows: string[]; enabledTools: string[]; totalRegistered: number; } >; } export interface FeatureDetector { areAxeToolsAvailable(): boolean; isXcodemakeEnabled(): boolean; isXcodemakeAvailable(): Promise<boolean>; doesMakefileExist(path: string): boolean; } export interface DoctorDependencies { binaryChecker: BinaryChecker; xcode: XcodeInfoProvider; env: EnvironmentInfoProvider; plugins: PluginInfoProvider; runtime: RuntimeInfoProvider; features: FeatureDetector; } export function createDoctorDependencies(executor: CommandExecutor): DoctorDependencies { const binaryChecker: BinaryChecker = { async checkBinaryAvailability(binary: string) { // If bundled axe is available, reflect that in dependencies even if not on PATH if (binary === 'axe' && areAxeToolsAvailable()) { return { available: true, version: 'Bundled' }; } try { const which = await executor(['which', binary], 'Check Binary Availability'); if (!which.success) { return { available: false }; } } catch { return { available: false }; } let version: string | undefined; const versionCommands: Record<string, string> = { axe: 'axe --version', mise: 'mise --version', }; if (binary in versionCommands) { try { const res = await executor(versionCommands[binary]!.split(' '), 'Get Binary Version'); if (res.success && res.output) { version = res.output.trim(); } } catch { // ignore } } return { available: true, version: version ?? 'Available (version info not available)' }; }, }; const xcode: XcodeInfoProvider = { async getXcodeInfo() { try { const xcodebuild = await executor(['xcodebuild', '-version'], 'Get Xcode Version'); if (!xcodebuild.success) throw new Error('xcodebuild command failed'); const version = xcodebuild.output.trim().split('\n').slice(0, 2).join(' - '); const pathRes = await executor(['xcode-select', '-p'], 'Get Xcode Path'); if (!pathRes.success) throw new Error('xcode-select command failed'); const path = pathRes.output.trim(); const selected = await executor(['xcrun', '--find', 'xcodebuild'], 'Find Xcodebuild'); if (!selected.success) throw new Error('xcrun --find command failed'); const selectedXcode = selected.output.trim(); const xcrun = await executor(['xcrun', '--version'], 'Get Xcrun Version'); if (!xcrun.success) throw new Error('xcrun --version command failed'); const xcrunVersion = xcrun.output.trim(); return { version, path, selectedXcode, xcrunVersion }; } catch (error) { return { error: error instanceof Error ? error.message : String(error) }; } }, }; const env: EnvironmentInfoProvider = { getEnvironmentVariables() { const relevantVars = [ 'INCREMENTAL_BUILDS_ENABLED', 'PATH', 'DEVELOPER_DIR', 'HOME', 'USER', 'TMPDIR', 'NODE_ENV', 'SENTRY_DISABLED', ]; const envVars: Record<string, string | undefined> = {}; for (const varName of relevantVars) { envVars[varName] = process.env[varName]; } Object.keys(process.env).forEach((key) => { if (key.startsWith('XCODEBUILDMCP_')) { envVars[key] = process.env[key]; } }); return envVars; }, getSystemInfo() { return { platform: os.platform(), release: os.release(), arch: os.arch(), cpus: `${os.cpus().length} x ${os.cpus()[0]?.model ?? 'Unknown'}`, memory: `${Math.round(os.totalmem() / (1024 * 1024 * 1024))} GB`, hostname: os.hostname(), username: os.userInfo().username, homedir: os.homedir(), tmpdir: os.tmpdir(), }; }, getNodeInfo() { return { version: process.version, execPath: process.execPath, pid: process.pid.toString(), ppid: process.ppid.toString(), platform: process.platform, arch: process.arch, cwd: process.cwd(), argv: process.argv.join(' '), }; }, }; const plugins: PluginInfoProvider = { async getPluginSystemInfo() { try { const workflows = await loadWorkflowGroups(); const pluginsByDirectory: Record<string, string[]> = {}; let totalPlugins = 0; for (const [dirName, wf] of workflows.entries()) { const toolNames = wf.tools.map((t) => t.name).filter(Boolean) as string[]; totalPlugins += toolNames.length; pluginsByDirectory[dirName] = toolNames; } return { totalPlugins, pluginDirectories: workflows.size, pluginsByDirectory, systemMode: 'plugin-based', }; } catch (error) { return { error: `Failed to load plugins: ${error instanceof Error ? error.message : 'Unknown error'}`, systemMode: 'error', }; } }, }; const runtime: RuntimeInfoProvider = { async getRuntimeToolInfo() { const dynamic = process.env.XCODEBUILDMCP_DYNAMIC_TOOLS === 'true'; if (dynamic) { const enabledWf = getEnabledWorkflows(); const enabledTools = getTrackedToolNames(); return { mode: 'dynamic', enabledWorkflows: enabledWf, enabledTools, totalRegistered: enabledTools.length, }; } // Static mode: all tools are registered const workflows = await loadWorkflowGroups(); const enabledWorkflows = Array.from(workflows.keys()); const plugins = await loadPlugins(); const enabledTools = Array.from(plugins.keys()); return { mode: 'static', enabledWorkflows, enabledTools, totalRegistered: enabledTools.length, }; }, }; const features: FeatureDetector = { areAxeToolsAvailable, isXcodemakeEnabled, isXcodemakeAvailable, doesMakefileExist, }; return { binaryChecker, xcode, env, plugins, runtime, features }; } export type { CommandExecutor }; export default {} as const; ``` -------------------------------------------------------------------------------- /src/mcp/tools/macos/build_run_macos.ts: -------------------------------------------------------------------------------- ```typescript /** * macOS Shared Plugin: Build and Run macOS (Unified) * * Builds and runs a macOS app from a project or workspace in one step. * Accepts mutually exclusive `projectPath` or `workspacePath`. */ import { z } from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; // Unified schema: XOR between projectPath and workspacePath const baseSchemaObject = z.object({ projectPath: z.string().optional().describe('Path to the .xcodeproj file'), workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), scheme: z.string().describe('The scheme to use'), configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), derivedDataPath: z .string() .optional() .describe('Path where build products and other derived data will go'), arch: z .enum(['arm64', 'x86_64']) .optional() .describe('Architecture to build for (arm64 or x86_64). For macOS only.'), extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), preferXcodebuild: z .boolean() .optional() .describe('If true, prefers xcodebuild over the experimental incremental build system'), }); const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); const publicSchemaObject = baseSchemaObject.omit({ projectPath: true, workspacePath: true, scheme: true, configuration: true, arch: true, } as const); const buildRunMacOSSchema = baseSchema .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { message: 'Either projectPath or workspacePath is required.', }) .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', }); export type BuildRunMacOSParams = z.infer<typeof buildRunMacOSSchema>; /** * Internal logic for building macOS apps. */ async function _handleMacOSBuildLogic( params: BuildRunMacOSParams, executor: CommandExecutor, ): Promise<ToolResponse> { log('info', `Starting macOS build for scheme ${params.scheme} (internal)`); return executeXcodeBuildCommand( { ...params, configuration: params.configuration ?? 'Debug', }, { platform: XcodePlatform.macOS, arch: params.arch, logPrefix: 'macOS Build', }, params.preferXcodebuild ?? false, 'build', executor, ); } async function _getAppPathFromBuildSettings( params: BuildRunMacOSParams, executor: CommandExecutor, ): Promise<{ success: true; appPath: string } | { success: false; error: string }> { try { // Create the command array for xcodebuild const command = ['xcodebuild', '-showBuildSettings']; // Add the project or workspace if (params.projectPath) { command.push('-project', params.projectPath); } else if (params.workspacePath) { command.push('-workspace', params.workspacePath); } // Add the scheme and configuration command.push('-scheme', params.scheme); command.push('-configuration', params.configuration ?? 'Debug'); // Add derived data path if provided if (params.derivedDataPath) { command.push('-derivedDataPath', params.derivedDataPath); } // Add extra args if provided if (params.extraArgs && params.extraArgs.length > 0) { command.push(...params.extraArgs); } // Execute the command directly const result = await executor(command, 'Get Build Settings for Launch', true, undefined); if (!result.success) { return { success: false, error: result.error ?? 'Failed to get build settings', }; } // Parse the output to extract the app path const buildSettingsOutput = result.output; const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); if (!builtProductsDirMatch || !fullProductNameMatch) { return { success: false, error: 'Could not extract app path from build settings' }; } const appPath = `${builtProductsDirMatch[1].trim()}/${fullProductNameMatch[1].trim()}`; return { success: true, appPath }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, error: errorMessage }; } } /** * Business logic for building and running macOS apps. */ export async function buildRunMacOSLogic( params: BuildRunMacOSParams, executor: CommandExecutor, ): Promise<ToolResponse> { log('info', 'Handling macOS build & run logic...'); try { // First, build the app const buildResult = await _handleMacOSBuildLogic(params, executor); // 1. Check if the build itself failed if (buildResult.isError) { return buildResult; // Return build failure directly } const buildWarningMessages = buildResult.content?.filter((c) => c.type === 'text') ?? []; // 2. Build succeeded, now get the app path using the helper const appPathResult = await _getAppPathFromBuildSettings(params, executor); // 3. Check if getting the app path failed if (!appPathResult.success) { log('error', 'Build succeeded, but failed to get app path to launch.'); const response = createTextResponse( `✅ Build succeeded, but failed to get app path to launch: ${appPathResult.error}`, false, // Build succeeded, so not a full error ); if (response.content) { response.content.unshift(...buildWarningMessages); } return response; } const appPath = appPathResult.appPath; // success === true narrows to string log('info', `App path determined as: ${appPath}`); // 4. Launch the app using CommandExecutor const launchResult = await executor(['open', appPath], 'Launch macOS App', true); if (!launchResult.success) { log('error', `Build succeeded, but failed to launch app ${appPath}: ${launchResult.error}`); const errorResponse = createTextResponse( `✅ Build succeeded, but failed to launch app ${appPath}. Error: ${launchResult.error}`, false, // Build succeeded ); if (errorResponse.content) { errorResponse.content.unshift(...buildWarningMessages); } return errorResponse; } log('info', `✅ macOS app launched successfully: ${appPath}`); const successResponse: ToolResponse = { content: [ ...buildWarningMessages, { type: 'text', text: `✅ macOS build and run succeeded for scheme ${params.scheme}. App launched: ${appPath}`, }, ], isError: false, }; return successResponse; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during macOS build & run logic: ${errorMessage}`); const errorResponse = createTextResponse( `Error during macOS build and run: ${errorMessage}`, true, ); return errorResponse; } } export default { name: 'build_run_macos', description: 'Builds and runs a macOS app.', schema: publicSchemaObject.shape, handler: createSessionAwareTool<BuildRunMacOSParams>({ internalSchema: buildRunMacOSSchema as unknown as z.ZodType<BuildRunMacOSParams>, logicFunction: buildRunMacOSLogic, getExecutor: getDefaultCommandExecutor, requirements: [ { allOf: ['scheme'], message: 'scheme is required' }, { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, ], exclusivePairs: [['projectPath', 'workspacePath']], }), }; ``` -------------------------------------------------------------------------------- /scripts/update-tools-docs.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node /** * XcodeBuildMCP Tools Documentation Updater * * Automatically updates docs/TOOLS.md with current tool and workflow information * using static AST analysis. Ensures documentation always reflects the actual codebase. * * Usage: * npx tsx scripts/update-tools-docs.ts [--dry-run] [--verbose] * * Options: * --dry-run, -d Show what would be updated without making changes * --verbose, -v Show detailed information about the update process * --help, -h Show this help message */ import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { getStaticToolAnalysis, type StaticAnalysisResult, type WorkflowInfo, } from './analysis/tools-analysis.js'; // Get project paths const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..'); const docsPath = path.join(projectRoot, 'docs', 'TOOLS.md'); // CLI options const args = process.argv.slice(2); const options = { dryRun: args.includes('--dry-run') || args.includes('-d'), verbose: args.includes('--verbose') || args.includes('-v'), help: args.includes('--help') || args.includes('-h'), }; const colors = { reset: '\x1b[0m', bright: '\x1b[1m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', magenta: '\x1b[35m', } as const; if (options.help) { console.log(` ${colors.bright}${colors.blue}XcodeBuildMCP Tools Documentation Updater${colors.reset} Automatically updates docs/TOOLS.md with current tool and workflow information. ${colors.bright}Usage:${colors.reset} npx tsx scripts/update-tools-docs.ts [options] ${colors.bright}Options:${colors.reset} --dry-run, -d Show what would be updated without making changes --verbose, -v Show detailed information about the update process --help, -h Show this help message ${colors.bright}Examples:${colors.reset} ${colors.cyan}npx tsx scripts/update-tools-docs.ts${colors.reset} # Update docs/TOOLS.md ${colors.cyan}npx tsx scripts/update-tools-docs.ts --dry-run${colors.reset} # Preview changes ${colors.cyan}npx tsx scripts/update-tools-docs.ts --verbose${colors.reset} # Show detailed progress `); process.exit(0); } /** * Generate the workflow section content */ function generateWorkflowSection(workflow: WorkflowInfo): string { const canonicalTools = workflow.tools.filter((tool) => tool.isCanonical); const toolCount = canonicalTools.length; let content = `### ${workflow.displayName} (\`${workflow.name}\`)\n`; content += `**Purpose**: ${workflow.description} (${toolCount} tools)\n\n`; // List each tool with its description for (const tool of canonicalTools.sort((a, b) => a.name.localeCompare(b.name))) { // Clean up the description for documentation const cleanDescription = tool.description .replace(/IMPORTANT:.*?Example:.*?\)/g, '') // Remove IMPORTANT sections .replace(/\s+/g, ' ') // Normalize whitespace .trim(); content += `- \`${tool.name}\` - ${cleanDescription}\n`; } return content; } /** * Generate the complete TOOLS.md content */ function generateToolsDocumentation(analysis: StaticAnalysisResult): string { const { workflows, stats } = analysis; // Sort workflows by display name for consistent ordering const sortedWorkflows = workflows.sort((a, b) => a.displayName.localeCompare(b.displayName)); const content = `# XcodeBuildMCP Tools Reference XcodeBuildMCP provides ${stats.canonicalTools} tools organized into ${stats.workflowCount} workflow groups for comprehensive Apple development workflows. ## Workflow Groups ${sortedWorkflows.map((workflow) => generateWorkflowSection(workflow)).join('')} ## Summary Statistics - **Total Tools**: ${stats.canonicalTools} canonical tools + ${stats.reExportTools} re-exports = ${stats.totalTools} total - **Workflow Groups**: ${stats.workflowCount} --- *This documentation is automatically generated by \`scripts/update-tools-docs.ts\` using static analysis. Last updated: ${new Date().toISOString().split('T')[0]}* `; return content; } /** * Compare old and new content to show what changed */ function showDiff(oldContent: string, newContent: string): void { if (!options.verbose) return; console.log(`${colors.bright}${colors.cyan}📄 Content Comparison:${colors.reset}`); console.log('─'.repeat(50)); const oldLines = oldContent.split('\n'); const newLines = newContent.split('\n'); const maxLength = Math.max(oldLines.length, newLines.length); let changes = 0; for (let i = 0; i < maxLength; i++) { const oldLine = oldLines[i] || ''; const newLine = newLines[i] || ''; if (oldLine !== newLine) { changes++; if (changes <= 10) { // Show first 10 changes console.log(`${colors.red}- Line ${i + 1}: ${oldLine}${colors.reset}`); console.log(`${colors.green}+ Line ${i + 1}: ${newLine}${colors.reset}`); } } } if (changes > 10) { console.log(`${colors.yellow}... and ${changes - 10} more changes${colors.reset}`); } console.log(`${colors.blue}Total changes: ${changes} lines${colors.reset}\n`); } /** * Main execution function */ async function main(): Promise<void> { try { console.log( `${colors.bright}${colors.blue}🔧 XcodeBuildMCP Tools Documentation Updater${colors.reset}`, ); if (options.dryRun) { console.log( `${colors.yellow}🔍 Running in dry-run mode - no files will be modified${colors.reset}`, ); } console.log(`${colors.cyan}📊 Analyzing tools...${colors.reset}`); // Get current tool analysis const analysis = await getStaticToolAnalysis(); if (options.verbose) { console.log( `${colors.green}✓ Found ${analysis.stats.canonicalTools} canonical tools in ${analysis.stats.workflowCount} workflows${colors.reset}`, ); console.log( `${colors.green}✓ Found ${analysis.stats.reExportTools} re-export files${colors.reset}`, ); } // Generate new documentation content console.log(`${colors.cyan}📝 Generating documentation...${colors.reset}`); const newContent = generateToolsDocumentation(analysis); // Read current content for comparison let oldContent = ''; if (fs.existsSync(docsPath)) { oldContent = fs.readFileSync(docsPath, 'utf-8'); } // Check if content has changed if (oldContent === newContent) { console.log(`${colors.green}✅ Documentation is already up to date!${colors.reset}`); return; } // Show differences if verbose if (oldContent && options.verbose) { showDiff(oldContent, newContent); } if (options.dryRun) { console.log( `${colors.yellow}📋 Dry run completed. Documentation would be updated with:${colors.reset}`, ); console.log(` - ${analysis.stats.canonicalTools} canonical tools`); console.log(` - ${analysis.stats.workflowCount} workflow groups`); console.log(` - ${newContent.split('\n').length} lines total`); if (!options.verbose) { console.log(`\n${colors.cyan}💡 Use --verbose to see detailed changes${colors.reset}`); } return; } // Write new content console.log(`${colors.cyan}✏️ Writing updated documentation...${colors.reset}`); fs.writeFileSync(docsPath, newContent, 'utf-8'); console.log( `${colors.green}✅ Successfully updated ${path.relative(projectRoot, docsPath)}!${colors.reset}`, ); if (options.verbose) { console.log(`\n${colors.bright}📈 Update Summary:${colors.reset}`); console.log( ` Tools: ${analysis.stats.canonicalTools} canonical + ${analysis.stats.reExportTools} re-exports = ${analysis.stats.totalTools} total`, ); console.log(` Workflows: ${analysis.stats.workflowCount}`); console.log(` File size: ${(newContent.length / 1024).toFixed(1)}KB`); console.log(` Lines: ${newContent.split('\n').length}`); } } catch (error) { console.error(`${colors.red}❌ Error: ${(error as Error).message}${colors.reset}`); process.exit(1); } } // Run the updater main(); ``` -------------------------------------------------------------------------------- /docs/CODE_QUALITY.md: -------------------------------------------------------------------------------- ```markdown # XcodeBuildMCP Code Quality Guide This guide consolidates all code quality, linting, and architectural compliance information for the XcodeBuildMCP project. ## Table of Contents 1. [Overview](#overview) 2. [ESLint Configuration](#eslint-configuration) 3. [Architectural Rules](#architectural-rules) 4. [Development Scripts](#development-scripts) 5. [Code Pattern Violations](#code-pattern-violations) 6. [Type Safety Migration](#type-safety-migration) 7. [Best Practices](#best-practices) ## Overview XcodeBuildMCP enforces code quality through multiple layers: 1. **ESLint**: Handles general code quality, TypeScript rules, and stylistic consistency 2. **TypeScript**: Enforces type safety with strict mode 3. **Pattern Checker**: Enforces XcodeBuildMCP-specific architectural rules 4. **Migration Scripts**: Track progress on type safety improvements ## ESLint Configuration ### Current Configuration The project uses a comprehensive ESLint setup that covers: - TypeScript type safety rules - Code style consistency - Import ordering - Unused variable detection - Testing best practices ### ESLint Rules For detailed ESLint rules and rationale, see [ESLINT_RULES.md](./ESLINT_RULES.md). ### Running ESLint ```bash # Check for linting issues npm run lint # Auto-fix linting issues npm run lint:fix ``` ## Architectural Rules XcodeBuildMCP enforces several architectural patterns that cannot be expressed through ESLint: ### 1. Dependency Injection Pattern **Rule**: All tools must use dependency injection for external interactions. ✅ **Allowed**: - `createMockExecutor()` for command execution mocking - `createMockFileSystemExecutor()` for file system mocking - Logic functions accepting `executor?: CommandExecutor` parameter ❌ **Forbidden**: - Direct use of `vi.mock()`, `vi.fn()`, or any Vitest mocking - Direct calls to `execSync`, `spawn`, or `exec` in production code - Testing handler functions directly ### 2. Handler Signature Compliance **Rule**: MCP handlers must have exact signatures as required by the SDK. ✅ **Tool Handler Signature**: ```typescript async handler(args: Record<string, unknown>): Promise<ToolResponse> ``` ✅ **Resource Handler Signature**: ```typescript async handler(uri: URL): Promise<{ contents: Array<{ text: string }> }> ``` ❌ **Forbidden**: - Multiple parameters in handlers - Optional parameters - Dependency injection parameters in handlers ### 3. Testing Architecture **Rule**: Tests must only call logic functions, never handlers directly. ✅ **Correct Pattern**: ```typescript const result = await myToolLogic(params, mockExecutor); ``` ❌ **Forbidden Pattern**: ```typescript const result = await myTool.handler(params); ``` ### 4. Server Type Safety **Rule**: MCP server instances must use proper SDK types, not generic casts. ✅ **Correct Pattern**: ```typescript import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; const server = (globalThis as { mcpServer?: McpServer }).mcpServer; server.server.createMessage({...}); ``` ❌ **Forbidden Pattern**: ```typescript const server = (globalThis as { mcpServer?: Record<string, unknown> }).mcpServer; const serverInstance = (server.server ?? server) as Record<string, unknown> & {...}; ``` ## Development Scripts ### Core Scripts ```bash # Build the project npm run build # Run type checking npm run typecheck # Run tests npm run test # Check code patterns (architectural compliance) node scripts/check-code-patterns.js # Check type safety migration progress npm run check-migration ``` ### Pattern Checker Usage The pattern checker enforces XcodeBuildMCP-specific architectural rules: ```bash # Check all patterns node scripts/check-code-patterns.js # Check specific pattern type node scripts/check-code-patterns.js --pattern=vitest node scripts/check-code-patterns.js --pattern=execsync node scripts/check-code-patterns.js --pattern=handler node scripts/check-code-patterns.js --pattern=handler-testing node scripts/check-code-patterns.js --pattern=server-typing # Get help node scripts/check-code-patterns.js --help ``` ### Tool Summary Scripts ```bash # Show tool and resource summary npm run tools # List all tools npm run tools:list # List both tools and resources npm run tools:all ``` ## Code Pattern Violations The pattern checker identifies the following violations: ### 1. Vitest Mocking Violations **What**: Any use of Vitest mocking functions **Why**: Breaks dependency injection architecture **Fix**: Use `createMockExecutor()` instead ### 2. ExecSync Violations **What**: Direct use of Node.js child_process functions in production code **Why**: Bypasses CommandExecutor dependency injection **Fix**: Accept `CommandExecutor` parameter and use it ### 3. Handler Signature Violations **What**: Handlers with incorrect parameter signatures **Why**: MCP SDK requires exact signatures **Fix**: Move dependencies inside handler body ### 4. Handler Testing Violations **What**: Tests calling `.handler()` directly **Why**: Violates dependency injection principle **Fix**: Test logic functions instead ### 5. Improper Server Typing Violations **What**: Casting MCP server instances to `Record<string, unknown>` or using custom interfaces instead of SDK types **Why**: Breaks type safety and prevents proper API usage **Fix**: Import `McpServer` from SDK and use proper typing instead of generic casts ## Type Safety Migration The project is migrating to improved type safety using the `createTypedTool` factory: ### Check Migration Status ```bash # Show summary npm run check-migration # Show detailed analysis npm run check-migration:verbose # Show only unmigrated tools npm run check-migration:unfixed ``` ### Migration Benefits 1. **Compile-time type safety** for tool parameters 2. **Automatic Zod schema validation** 3. **Better IDE support** and autocomplete 4. **Consistent error handling** ## Best Practices ### 1. Before Committing Always run these checks before committing: ```bash npm run build # Ensure code compiles npm run typecheck # Check TypeScript types npm run lint # Check linting rules npm run test # Run tests node scripts/check-code-patterns.js # Check architectural compliance ``` ### 2. Adding New Tools 1. Use dependency injection pattern 2. Follow handler signature requirements 3. Create comprehensive tests (test logic, not handlers) 4. Use `createTypedTool` factory for type safety 5. Document parameter schemas clearly ### 3. Writing Tests 1. Import the logic function, not the default export 2. Use `createMockExecutor()` for mocking 3. Test three dimensions: validation, command generation, output processing 4. Never test handlers directly ### 4. Code Organization 1. Keep tools in appropriate workflow directories 2. Share common tools via `-shared` directories 3. Re-export shared tools, don't duplicate 4. Follow naming conventions for tools ## Automated Enforcement The project uses multiple layers of automated enforcement: 1. **Pre-commit**: ESLint and TypeScript checks (if configured) 2. **CI Pipeline**: All checks run on every PR 3. **PR Blocking**: Checks must pass before merge 4. **Code Review**: Automated and manual review processes ## Troubleshooting ### ESLint False Positives If ESLint reports false positives in test files, check that: 1. Test files are properly configured in `.eslintrc.json` 2. Test-specific rules are applied correctly 3. File patterns match your test file locations ### Pattern Checker Issues If the pattern checker reports unexpected violations: 1. Check if it's a legitimate architectural violation 2. Verify the file is in the correct directory 3. Ensure you're using the latest pattern definitions ### Type Safety Migration If migration tooling reports incorrect status: 1. Ensure the tool exports follow standard patterns 2. Check that schema definitions are properly typed 3. Verify the handler uses the schema correctly ## Future Improvements 1. **Automated Fixes**: Add auto-fix capability to pattern checker 2. **IDE Integration**: Create VS Code extension for real-time checking 3. **Performance Metrics**: Add build and test performance tracking 4. **Complexity Analysis**: Add code complexity metrics 5. **Documentation Linting**: Add documentation quality checks ``` -------------------------------------------------------------------------------- /.github/workflows/droid-code-review.yml: -------------------------------------------------------------------------------- ```yaml name: Droid Code Review on: pull_request: types: [opened, synchronize, reopened, ready_for_review] concurrency: group: droid-review-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: pull-requests: write contents: read issues: write jobs: code-review: runs-on: ubuntu-latest timeout-minutes: 15 # Skip automated code review for draft PRs if: github.event.pull_request.draft == false steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} - name: Install Droid CLI run: | curl -fsSL https://app.factory.ai/cli | sh echo "$HOME/.local/bin" >> $GITHUB_PATH "$HOME/.local/bin/droid" --version - name: Configure git identity run: | git config user.name "Droid Agent" git config user.email "[email protected]" - name: Prepare review context run: | # Get the PR diff git fetch origin ${{ github.event.pull_request.base.ref }} git diff origin/${{ github.event.pull_request.base.ref }}...${{ github.event.pull_request.head.sha }} > diff.txt # Get existing comments using GitHub API curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ -H "Accept: application/vnd.github.v3+json" \ "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \ > existing_comments.json # Get changed files with patches for positioning curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ -H "Accept: application/vnd.github.v3+json" \ "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ | jq '[.[] | {filename: .filename, patch: .patch}]' > files.json - name: Perform automated code review env: FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | cat > prompt.txt << 'EOF' You are an automated code review system. Review the PR diff and identify clear issues that need to be fixed. Input files (already in current directory): - diff.txt: the code changes to review - files.json: file patches with line numbers for positioning comments - existing_comments.json: skip issues already mentioned here Task: Create a file called comments.json with this exact format: [{ "path": "path/to/file.js", "position": 42, "body": "Your comment here" }] Focus on these types of issues: - Dead/unreachable code (if (false), while (false), code after return/throw/break) - Broken control flow (missing break in switch, fallthrough bugs) - Async/await mistakes (missing await, .then without return, unhandled promise rejections) - Array/object mutations in React components or reducers - UseEffect dependency array problems (missing deps, incorrect deps) - Incorrect operator usage (== vs ===, && vs ||, = in conditions) - Off-by-one errors in loops or array indexing - Integer overflow/underflow in calculations - Regex catastrophic backtracking vulnerabilities - Missing base cases in recursive functions - Incorrect type coercion that changes behavior - Environment variable access without defaults or validation - Null/undefined dereferences - Resource leaks (unclosed files or connections) - SQL/XSS injection vulnerabilities - Concurrency/race conditions - Missing error handling for critical operations Comment format: - Clearly describe the issue: "This code block is unreachable due to the if (false) condition" - Provide a concrete fix: "Remove this entire if block as it will never execute" - When possible, suggest the exact code change: ```suggestion // Remove the unreachable code ``` - Be specific about why it's a problem: "This will cause a TypeError if input is null" - No emojis, just clear technical language Skip commenting on: - Code style, formatting, or naming conventions - Minor performance optimizations - Architectural decisions or design patterns - Features or functionality (unless broken) - Test coverage (unless tests are clearly broken) Position calculation: - Use the "position" field from files.json patches - This is the line number in the diff, not the file - Comments must align with exact changed lines only Output: - Empty array [] if no issues found - Otherwise array of comment objects with path, position, body - Each comment should be actionable and clear about what needs to be fixed - Maximum 10 comments total; prioritize the most critical issues EOF # Run droid exec with the prompt echo "Running code review analysis..." droid exec --auto high -f prompt.txt # Check if comments.json was created if [ ! -f comments.json ]; then echo "❌ ERROR: droid exec did not create comments.json" echo "This usually indicates the review run failed (e.g. missing FACTORY_API_KEY or runtime error)." exit 1 fi echo "=== Review Results ===" cat comments.json - name: Submit inline review comments uses: actions/github-script@v7 with: script: | const fs = require('fs'); const prNumber = context.payload.pull_request.number; if (!fs.existsSync('comments.json')) { core.info('comments.json missing; skipping review submission'); return; } const comments = JSON.parse(fs.readFileSync('comments.json', 'utf8')); if (!Array.isArray(comments) || comments.length === 0) { // Check if we already have a "no issues" comment const existing = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, per_page: 100 }); const hasNoIssuesComment = existing.some(c => c.user.login.includes('[bot]') && /no issues found|lgtm|✅/i.test(c.body || '') ); if (!hasNoIssuesComment) { await github.rest.pulls.createReview({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, event: 'COMMENT', body: '✅ No issues found in the current changes.' }); } return; } // Submit review with inline comments const summary = `Found ${comments.length} potential issue${comments.length === 1 ? '' : 's'} that should be addressed.`; await github.rest.pulls.createReview({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, event: 'COMMENT', body: summary, comments: comments }); core.info(`Submitted review with ${comments.length} inline comments`); - name: Upload debug artifacts on failure if: ${{ failure() }} uses: actions/upload-artifact@v4 with: name: droid-review-debug-${{ github.run_id }} path: | diff.txt files.json existing_comments.json prompt.txt comments.json ${{ runner.home }}/.factory/logs/droid-log-single.log ${{ runner.home }}/.factory/logs/console.log if-no-files-found: ignore retention-days: 7 ``` -------------------------------------------------------------------------------- /src/mcp/tools/device/__tests__/stop_app_device.test.ts: -------------------------------------------------------------------------------- ```typescript /** * Tests for stop_app_device plugin (device-shared) * Following CLAUDE.md testing standards with literal validation * Using dependency injection for deterministic testing */ import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import stopAppDevice, { stop_app_deviceLogic } from '../stop_app_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; describe('stop_app_device plugin', () => { beforeEach(() => { sessionStore.clear(); }); describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(stopAppDevice.name).toBe('stop_app_device'); }); it('should have correct description', () => { expect(stopAppDevice.description).toBe('Stops a running app on a connected device.'); }); it('should have handler function', () => { expect(typeof stopAppDevice.handler).toBe('function'); }); it('should require processId in public schema', () => { const schema = z.object(stopAppDevice.schema).strict(); expect(schema.safeParse({ processId: 12345 }).success).toBe(true); expect(schema.safeParse({}).success).toBe(false); expect(schema.safeParse({ deviceId: 'test-device-123' }).success).toBe(false); expect(Object.keys(stopAppDevice.schema)).toEqual(['processId']); }); }); describe('Handler Requirements', () => { it('should require deviceId when not provided', async () => { const result = await stopAppDevice.handler({ processId: 12345 }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('deviceId is required'); }); }); describe('Command Generation', () => { it('should generate correct devicectl command with basic parameters', async () => { let capturedCommand: unknown[] = []; let capturedDescription: string = ''; let capturedUseShell: boolean = false; let capturedEnv: unknown = undefined; const mockExecutor = createMockExecutor({ success: true, output: 'App terminated successfully', process: { pid: 12345 }, }); const trackingExecutor = async ( command: unknown[], description: string, useShell: boolean, env: unknown, ) => { capturedCommand = command; capturedDescription = description; capturedUseShell = useShell; capturedEnv = env; return mockExecutor(command, description, useShell, env); }; await stop_app_deviceLogic( { deviceId: 'test-device-123', processId: 12345, }, trackingExecutor, ); expect(capturedCommand).toEqual([ 'xcrun', 'devicectl', 'device', 'process', 'terminate', '--device', 'test-device-123', '--pid', '12345', ]); expect(capturedDescription).toBe('Stop app on device'); expect(capturedUseShell).toBe(true); expect(capturedEnv).toBe(undefined); }); it('should generate correct command with different device ID and process ID', async () => { let capturedCommand: unknown[] = []; const mockExecutor = createMockExecutor({ success: true, output: 'Process terminated', process: { pid: 12345 }, }); const trackingExecutor = async (command: unknown[]) => { capturedCommand = command; return mockExecutor(command); }; await stop_app_deviceLogic( { deviceId: 'different-device-uuid', processId: 99999, }, trackingExecutor, ); expect(capturedCommand).toEqual([ 'xcrun', 'devicectl', 'device', 'process', 'terminate', '--device', 'different-device-uuid', '--pid', '99999', ]); }); it('should generate correct command with large process ID', async () => { let capturedCommand: unknown[] = []; const mockExecutor = createMockExecutor({ success: true, output: 'Process terminated', process: { pid: 12345 }, }); const trackingExecutor = async (command: unknown[]) => { capturedCommand = command; return mockExecutor(command); }; await stop_app_deviceLogic( { deviceId: 'test-device-123', processId: 2147483647, }, trackingExecutor, ); expect(capturedCommand).toEqual([ 'xcrun', 'devicectl', 'device', 'process', 'terminate', '--device', 'test-device-123', '--pid', '2147483647', ]); }); }); describe('Success Path Tests', () => { it('should return successful stop response', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'App terminated successfully', }); const result = await stop_app_deviceLogic( { deviceId: 'test-device-123', processId: 12345, }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: '✅ App stopped successfully\n\nApp terminated successfully', }, ], }); }); it('should return successful stop with detailed output', async () => { const mockExecutor = createMockExecutor({ success: true, output: 'Terminating process...\nProcess ID: 12345\nTermination completed successfully', }); const result = await stop_app_deviceLogic( { deviceId: 'device-456', processId: 67890, }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: '✅ App stopped successfully\n\nTerminating process...\nProcess ID: 12345\nTermination completed successfully', }, ], }); }); it('should return successful stop with empty output', async () => { const mockExecutor = createMockExecutor({ success: true, output: '', }); const result = await stop_app_deviceLogic( { deviceId: 'empty-output-device', processId: 54321, }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: '✅ App stopped successfully\n\n', }, ], }); }); }); describe('Error Handling', () => { it('should return stop failure response', async () => { const mockExecutor = createMockExecutor({ success: false, error: 'Terminate failed: Process not found', }); const result = await stop_app_deviceLogic( { deviceId: 'test-device-123', processId: 99999, }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Failed to stop app: Terminate failed: Process not found', }, ], isError: true, }); }); it('should return exception handling response', async () => { const mockExecutor = createMockExecutor(new Error('Network error')); const result = await stop_app_deviceLogic( { deviceId: 'test-device-123', processId: 12345, }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Failed to stop app on device: Network error', }, ], isError: true, }); }); it('should return string error handling response', async () => { const mockExecutor = createMockExecutor('String error'); const result = await stop_app_deviceLogic( { deviceId: 'test-device-123', processId: 12345, }, mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', text: 'Failed to stop app on device: String error', }, ], isError: true, }); }); }); }); ``` -------------------------------------------------------------------------------- /src/utils/test-common.ts: -------------------------------------------------------------------------------- ```typescript /** * Common Test Utilities - Shared logic for test tools * * This module provides shared functionality for all test-related tools across different platforms. * It includes common test execution logic, xcresult parsing, and utility functions used by * platform-specific test tools. * * Responsibilities: * - Parsing xcresult bundles into human-readable format * - Shared test execution logic with platform-specific handling * - Common error handling and cleanup for test operations * - Temporary directory management for xcresult files */ import { promisify } from 'util'; import { exec } from 'child_process'; import { mkdtemp, rm } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import { log } from './logger.ts'; import { XcodePlatform } from './xcode.ts'; import { executeXcodeBuildCommand } from './build/index.ts'; import { createTextResponse, consolidateContentForClaudeCode } from './validation.ts'; import { normalizeTestRunnerEnv } from './environment.ts'; import { ToolResponse } from '../types/common.ts'; import { CommandExecutor, CommandExecOptions, getDefaultCommandExecutor } from './command.ts'; /** * Type definition for test summary structure from xcresulttool */ interface TestSummary { title?: string; result?: string; totalTestCount?: number; passedTests?: number; failedTests?: number; skippedTests?: number; expectedFailures?: number; environmentDescription?: string; devicesAndConfigurations?: Array<{ device?: { deviceName?: string; platform?: string; osVersion?: string; }; }>; testFailures?: Array<{ testName?: string; targetName?: string; failureText?: string; }>; topInsights?: Array<{ impact?: string; text?: string; }>; } /** * Parse xcresult bundle using xcrun xcresulttool */ export async function parseXcresultBundle(resultBundlePath: string): Promise<string> { try { const execAsync = promisify(exec); const { stdout } = await execAsync( `xcrun xcresulttool get test-results summary --path "${resultBundlePath}"`, ); // Parse JSON response and format as human-readable const summary = JSON.parse(stdout) as TestSummary; return formatTestSummary(summary); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error parsing xcresult bundle: ${errorMessage}`); throw error; } } /** * Format test summary JSON into human-readable text */ function formatTestSummary(summary: TestSummary): string { const lines: string[] = []; lines.push(`Test Summary: ${summary.title ?? 'Unknown'}`); lines.push(`Overall Result: ${summary.result ?? 'Unknown'}`); lines.push(''); lines.push('Test Counts:'); lines.push(` Total: ${summary.totalTestCount ?? 0}`); lines.push(` Passed: ${summary.passedTests ?? 0}`); lines.push(` Failed: ${summary.failedTests ?? 0}`); lines.push(` Skipped: ${summary.skippedTests ?? 0}`); lines.push(` Expected Failures: ${summary.expectedFailures ?? 0}`); lines.push(''); if (summary.environmentDescription) { lines.push(`Environment: ${summary.environmentDescription}`); lines.push(''); } if ( summary.devicesAndConfigurations && Array.isArray(summary.devicesAndConfigurations) && summary.devicesAndConfigurations.length > 0 ) { const device = summary.devicesAndConfigurations[0].device; if (device) { lines.push( `Device: ${device.deviceName ?? 'Unknown'} (${device.platform ?? 'Unknown'} ${device.osVersion ?? 'Unknown'})`, ); lines.push(''); } } if ( summary.testFailures && Array.isArray(summary.testFailures) && summary.testFailures.length > 0 ) { lines.push('Test Failures:'); summary.testFailures.forEach((failure, index: number) => { lines.push( ` ${index + 1}. ${failure.testName ?? 'Unknown Test'} (${failure.targetName ?? 'Unknown Target'})`, ); if (failure.failureText) { lines.push(` ${failure.failureText}`); } }); lines.push(''); } if (summary.topInsights && Array.isArray(summary.topInsights) && summary.topInsights.length > 0) { lines.push('Insights:'); summary.topInsights.forEach((insight, index: number) => { lines.push( ` ${index + 1}. [${insight.impact ?? 'Unknown'}] ${insight.text ?? 'No description'}`, ); }); } return lines.join('\n'); } /** * Internal logic for running tests with platform-specific handling */ export async function handleTestLogic( params: { workspacePath?: string; projectPath?: string; scheme: string; configuration: string; simulatorName?: string; simulatorId?: string; deviceId?: string; useLatestOS?: boolean; derivedDataPath?: string; extraArgs?: string[]; preferXcodebuild?: boolean; platform: XcodePlatform; testRunnerEnv?: Record<string, string>; }, executor?: CommandExecutor, ): Promise<ToolResponse> { log( 'info', `Starting test run for scheme ${params.scheme} on platform ${params.platform} (internal)`, ); try { // Create temporary directory for xcresult bundle const tempDir = await mkdtemp(join(tmpdir(), 'xcodebuild-test-')); const resultBundlePath = join(tempDir, 'TestResults.xcresult'); // Add resultBundlePath to extraArgs const extraArgs = [...(params.extraArgs ?? []), `-resultBundlePath`, resultBundlePath]; // Prepare execution options with TEST_RUNNER_ environment variables const execOpts: CommandExecOptions | undefined = params.testRunnerEnv ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) } : undefined; // Run the test command const testResult = await executeXcodeBuildCommand( { ...params, extraArgs, }, { platform: params.platform, simulatorName: params.simulatorName, simulatorId: params.simulatorId, deviceId: params.deviceId, useLatestOS: params.useLatestOS, logPrefix: 'Test Run', }, params.preferXcodebuild, 'test', executor ?? getDefaultCommandExecutor(), execOpts, ); // Parse xcresult bundle if it exists, regardless of whether tests passed or failed // Test failures are expected and should not prevent xcresult parsing try { log('info', `Attempting to parse xcresult bundle at: ${resultBundlePath}`); // Check if the file exists try { const { stat } = await import('fs/promises'); await stat(resultBundlePath); log('info', `xcresult bundle exists at: ${resultBundlePath}`); } catch { log('warn', `xcresult bundle does not exist at: ${resultBundlePath}`); throw new Error(`xcresult bundle not found at ${resultBundlePath}`); } const testSummary = await parseXcresultBundle(resultBundlePath); log('info', 'Successfully parsed xcresult bundle'); // Clean up temporary directory await rm(tempDir, { recursive: true, force: true }); // Return combined result - preserve isError from testResult (test failures should be marked as errors) const combinedResponse: ToolResponse = { content: [ ...(testResult.content || []), { type: 'text', text: '\nTest Results Summary:\n' + testSummary, }, ], isError: testResult.isError, }; // Apply Claude Code workaround if enabled return consolidateContentForClaudeCode(combinedResponse); } catch (parseError) { // If parsing fails, return original test result log('warn', `Failed to parse xcresult bundle: ${parseError}`); // Clean up temporary directory even if parsing fails try { await rm(tempDir, { recursive: true, force: true }); } catch (cleanupError) { log('warn', `Failed to clean up temporary directory: ${cleanupError}`); } return consolidateContentForClaudeCode(testResult); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Error during test run: ${errorMessage}`); return consolidateContentForClaudeCode( createTextResponse(`Error during test run: ${errorMessage}`, true), ); } } ```