This is page 3 of 14. Use http://codebase.md/cameroncooke/xcodebuildmcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .axe-version ├── .claude │ └── agents │ └── xcodebuild-mcp-qa-tester.md ├── .cursor │ ├── BUGBOT.md │ └── environment.json ├── .cursorrules ├── .github │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ └── workflows │ ├── ci.yml │ ├── claude-code-review.yml │ ├── claude-dispatch.yml │ ├── claude.yml │ ├── droid-code-review.yml │ ├── README.md │ ├── release.yml │ └── sentry.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .vscode │ ├── extensions.json │ ├── launch.json │ ├── mcp.json │ ├── settings.json │ └── tasks.json ├── AGENTS.md ├── banner.png ├── build-plugins │ ├── plugin-discovery.js │ ├── plugin-discovery.ts │ └── tsconfig.json ├── CHANGELOG.md ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── docs │ ├── ARCHITECTURE.md │ ├── CODE_QUALITY.md │ ├── CONTRIBUTING.md │ ├── ESLINT_TYPE_SAFETY.md │ ├── MANUAL_TESTING.md │ ├── NODEJS_2025.md │ ├── PLUGIN_DEVELOPMENT.md │ ├── RELEASE_PROCESS.md │ ├── RELOADEROO_FOR_XCODEBUILDMCP.md │ ├── RELOADEROO_XCODEBUILDMCP_PRIMER.md │ ├── RELOADEROO.md │ ├── session_management_plan.md │ ├── session-aware-migration-todo.md │ ├── TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md │ ├── TESTING.md │ └── TOOLS.md ├── eslint.config.js ├── example_projects │ ├── .vscode │ │ └── launch.json │ ├── iOS │ │ ├── .cursor │ │ │ └── rules │ │ │ └── errors.mdc │ │ ├── .vscode │ │ │ └── settings.json │ │ ├── Makefile │ │ ├── MCPTest │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── MCPTestApp.swift │ │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ │ ├── MCPTest.xcodeproj │ │ │ ├── project.pbxproj │ │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── MCPTest.xcscheme │ │ └── MCPTestUITests │ │ └── MCPTestUITests.swift │ ├── iOS_Calculator │ │ ├── CalculatorApp │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── CalculatorApp.swift │ │ │ └── CalculatorApp.xctestplan │ │ ├── CalculatorApp.xcodeproj │ │ │ ├── project.pbxproj │ │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── CalculatorApp.xcscheme │ │ ├── CalculatorApp.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ ├── CalculatorAppPackage │ │ │ ├── .gitignore │ │ │ ├── Package.swift │ │ │ ├── Sources │ │ │ │ └── CalculatorAppFeature │ │ │ │ ├── BackgroundEffect.swift │ │ │ │ ├── CalculatorButton.swift │ │ │ │ ├── CalculatorDisplay.swift │ │ │ │ ├── CalculatorInputHandler.swift │ │ │ │ ├── CalculatorService.swift │ │ │ │ └── ContentView.swift │ │ │ └── Tests │ │ │ └── CalculatorAppFeatureTests │ │ │ └── CalculatorServiceTests.swift │ │ ├── CalculatorAppTests │ │ │ └── CalculatorAppTests.swift │ │ └── Config │ │ ├── Debug.xcconfig │ │ ├── Release.xcconfig │ │ ├── Shared.xcconfig │ │ └── Tests.xcconfig │ ├── macOS │ │ ├── MCPTest │ │ │ ├── Assets.xcassets │ │ │ │ ├── AccentColor.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── MCPTest.entitlements │ │ │ ├── MCPTestApp.swift │ │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ │ └── MCPTest.xcodeproj │ │ ├── project.pbxproj │ │ └── xcshareddata │ │ └── xcschemes │ │ └── MCPTest.xcscheme │ └── spm │ ├── .gitignore │ ├── Package.resolved │ ├── Package.swift │ ├── Sources │ │ ├── long-server │ │ │ └── main.swift │ │ ├── quick-task │ │ │ └── main.swift │ │ ├── spm │ │ │ └── main.swift │ │ └── TestLib │ │ └── TaskManager.swift │ └── Tests │ └── TestLibTests │ └── SimpleTests.swift ├── LICENSE ├── mcp-install-dark.png ├── package-lock.json ├── package.json ├── README.md ├── scripts │ ├── analysis │ │ └── tools-analysis.ts │ ├── bundle-axe.sh │ ├── check-code-patterns.js │ ├── release.sh │ ├── tools-cli.ts │ └── update-tools-docs.ts ├── server.json ├── smithery.yaml ├── src │ ├── core │ │ ├── __tests__ │ │ │ └── resources.test.ts │ │ ├── dynamic-tools.ts │ │ ├── plugin-registry.ts │ │ ├── plugin-types.ts │ │ └── resources.ts │ ├── doctor-cli.ts │ ├── index.ts │ ├── mcp │ │ ├── resources │ │ │ ├── __tests__ │ │ │ │ ├── devices.test.ts │ │ │ │ ├── doctor.test.ts │ │ │ │ └── simulators.test.ts │ │ │ ├── devices.ts │ │ │ ├── doctor.ts │ │ │ └── simulators.ts │ │ └── tools │ │ ├── device │ │ │ ├── __tests__ │ │ │ │ ├── build_device.test.ts │ │ │ │ ├── get_device_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── install_app_device.test.ts │ │ │ │ ├── launch_app_device.test.ts │ │ │ │ ├── list_devices.test.ts │ │ │ │ ├── re-exports.test.ts │ │ │ │ ├── stop_app_device.test.ts │ │ │ │ └── test_device.test.ts │ │ │ ├── build_device.ts │ │ │ ├── clean.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_device_app_path.ts │ │ │ ├── index.ts │ │ │ ├── install_app_device.ts │ │ │ ├── launch_app_device.ts │ │ │ ├── list_devices.ts │ │ │ ├── list_schemes.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── start_device_log_cap.ts │ │ │ ├── stop_app_device.ts │ │ │ ├── stop_device_log_cap.ts │ │ │ └── test_device.ts │ │ ├── discovery │ │ │ ├── __tests__ │ │ │ │ └── discover_tools.test.ts │ │ │ ├── discover_tools.ts │ │ │ └── index.ts │ │ ├── doctor │ │ │ ├── __tests__ │ │ │ │ ├── doctor.test.ts │ │ │ │ └── index.test.ts │ │ │ ├── doctor.ts │ │ │ ├── index.ts │ │ │ └── lib │ │ │ └── doctor.deps.ts │ │ ├── logging │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── start_device_log_cap.test.ts │ │ │ │ ├── start_sim_log_cap.test.ts │ │ │ │ ├── stop_device_log_cap.test.ts │ │ │ │ └── stop_sim_log_cap.test.ts │ │ │ ├── index.ts │ │ │ ├── start_device_log_cap.ts │ │ │ ├── start_sim_log_cap.ts │ │ │ ├── stop_device_log_cap.ts │ │ │ └── stop_sim_log_cap.ts │ │ ├── macos │ │ │ ├── __tests__ │ │ │ │ ├── build_macos.test.ts │ │ │ │ ├── build_run_macos.test.ts │ │ │ │ ├── get_mac_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── launch_mac_app.test.ts │ │ │ │ ├── re-exports.test.ts │ │ │ │ ├── stop_mac_app.test.ts │ │ │ │ └── test_macos.test.ts │ │ │ ├── build_macos.ts │ │ │ ├── build_run_macos.ts │ │ │ ├── clean.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_mac_app_path.ts │ │ │ ├── get_mac_bundle_id.ts │ │ │ ├── index.ts │ │ │ ├── launch_mac_app.ts │ │ │ ├── list_schemes.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── stop_mac_app.ts │ │ │ └── test_macos.ts │ │ ├── project-discovery │ │ │ ├── __tests__ │ │ │ │ ├── discover_projs.test.ts │ │ │ │ ├── get_app_bundle_id.test.ts │ │ │ │ ├── get_mac_bundle_id.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── list_schemes.test.ts │ │ │ │ └── show_build_settings.test.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_mac_bundle_id.ts │ │ │ ├── index.ts │ │ │ ├── list_schemes.ts │ │ │ └── show_build_settings.ts │ │ ├── project-scaffolding │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── scaffold_ios_project.test.ts │ │ │ │ └── scaffold_macos_project.test.ts │ │ │ ├── index.ts │ │ │ ├── scaffold_ios_project.ts │ │ │ └── scaffold_macos_project.ts │ │ ├── session-management │ │ │ ├── __tests__ │ │ │ │ ├── index.test.ts │ │ │ │ ├── session_clear_defaults.test.ts │ │ │ │ ├── session_set_defaults.test.ts │ │ │ │ └── session_show_defaults.test.ts │ │ │ ├── index.ts │ │ │ ├── session_clear_defaults.ts │ │ │ ├── session_set_defaults.ts │ │ │ └── session_show_defaults.ts │ │ ├── simulator │ │ │ ├── __tests__ │ │ │ │ ├── boot_sim.test.ts │ │ │ │ ├── build_run_sim.test.ts │ │ │ │ ├── build_sim.test.ts │ │ │ │ ├── get_sim_app_path.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── install_app_sim.test.ts │ │ │ │ ├── launch_app_logs_sim.test.ts │ │ │ │ ├── launch_app_sim.test.ts │ │ │ │ ├── list_sims.test.ts │ │ │ │ ├── open_sim.test.ts │ │ │ │ ├── record_sim_video.test.ts │ │ │ │ ├── screenshot.test.ts │ │ │ │ ├── stop_app_sim.test.ts │ │ │ │ └── test_sim.test.ts │ │ │ ├── boot_sim.ts │ │ │ ├── build_run_sim.ts │ │ │ ├── build_sim.ts │ │ │ ├── clean.ts │ │ │ ├── describe_ui.ts │ │ │ ├── discover_projs.ts │ │ │ ├── get_app_bundle_id.ts │ │ │ ├── get_sim_app_path.ts │ │ │ ├── index.ts │ │ │ ├── install_app_sim.ts │ │ │ ├── launch_app_logs_sim.ts │ │ │ ├── launch_app_sim.ts │ │ │ ├── list_schemes.ts │ │ │ ├── list_sims.ts │ │ │ ├── open_sim.ts │ │ │ ├── record_sim_video.ts │ │ │ ├── screenshot.ts │ │ │ ├── show_build_settings.ts │ │ │ ├── stop_app_sim.ts │ │ │ └── test_sim.ts │ │ ├── simulator-management │ │ │ ├── __tests__ │ │ │ │ ├── erase_sims.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── reset_sim_location.test.ts │ │ │ │ ├── set_sim_appearance.test.ts │ │ │ │ ├── set_sim_location.test.ts │ │ │ │ └── sim_statusbar.test.ts │ │ │ ├── boot_sim.ts │ │ │ ├── erase_sims.ts │ │ │ ├── index.ts │ │ │ ├── list_sims.ts │ │ │ ├── open_sim.ts │ │ │ ├── reset_sim_location.ts │ │ │ ├── set_sim_appearance.ts │ │ │ ├── set_sim_location.ts │ │ │ └── sim_statusbar.ts │ │ ├── swift-package │ │ │ ├── __tests__ │ │ │ │ ├── active-processes.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── swift_package_build.test.ts │ │ │ │ ├── swift_package_clean.test.ts │ │ │ │ ├── swift_package_list.test.ts │ │ │ │ ├── swift_package_run.test.ts │ │ │ │ ├── swift_package_stop.test.ts │ │ │ │ └── swift_package_test.test.ts │ │ │ ├── active-processes.ts │ │ │ ├── index.ts │ │ │ ├── swift_package_build.ts │ │ │ ├── swift_package_clean.ts │ │ │ ├── swift_package_list.ts │ │ │ ├── swift_package_run.ts │ │ │ ├── swift_package_stop.ts │ │ │ └── swift_package_test.ts │ │ ├── ui-testing │ │ │ ├── __tests__ │ │ │ │ ├── button.test.ts │ │ │ │ ├── describe_ui.test.ts │ │ │ │ ├── gesture.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── key_press.test.ts │ │ │ │ ├── key_sequence.test.ts │ │ │ │ ├── long_press.test.ts │ │ │ │ ├── screenshot.test.ts │ │ │ │ ├── swipe.test.ts │ │ │ │ ├── tap.test.ts │ │ │ │ ├── touch.test.ts │ │ │ │ └── type_text.test.ts │ │ │ ├── button.ts │ │ │ ├── describe_ui.ts │ │ │ ├── gesture.ts │ │ │ ├── index.ts │ │ │ ├── key_press.ts │ │ │ ├── key_sequence.ts │ │ │ ├── long_press.ts │ │ │ ├── screenshot.ts │ │ │ ├── swipe.ts │ │ │ ├── tap.ts │ │ │ ├── touch.ts │ │ │ └── type_text.ts │ │ └── utilities │ │ ├── __tests__ │ │ │ ├── clean.test.ts │ │ │ └── index.test.ts │ │ ├── clean.ts │ │ └── index.ts │ ├── server │ │ └── server.ts │ ├── test-utils │ │ └── mock-executors.ts │ ├── types │ │ └── common.ts │ └── utils │ ├── __tests__ │ │ ├── build-utils.test.ts │ │ ├── environment.test.ts │ │ ├── session-aware-tool-factory.test.ts │ │ ├── session-store.test.ts │ │ ├── simulator-utils.test.ts │ │ ├── test-runner-env-integration.test.ts │ │ └── typed-tool-factory.test.ts │ ├── axe │ │ └── index.ts │ ├── axe-helpers.ts │ ├── build │ │ └── index.ts │ ├── build-utils.ts │ ├── capabilities.ts │ ├── command.ts │ ├── CommandExecutor.ts │ ├── environment.ts │ ├── errors.ts │ ├── execution │ │ └── index.ts │ ├── FileSystemExecutor.ts │ ├── log_capture.ts │ ├── log-capture │ │ └── index.ts │ ├── logger.ts │ ├── logging │ │ └── index.ts │ ├── plugin-registry │ │ └── index.ts │ ├── responses │ │ └── index.ts │ ├── schema-helpers.ts │ ├── sentry.ts │ ├── session-store.ts │ ├── simulator-utils.ts │ ├── template │ │ └── index.ts │ ├── template-manager.ts │ ├── test │ │ └── index.ts │ ├── test-common.ts │ ├── tool-registry.ts │ ├── typed-tool-factory.ts │ ├── validation │ │ └── index.ts │ ├── validation.ts │ ├── version │ │ └── index.ts │ ├── video_capture.ts │ ├── video-capture │ │ └── index.ts │ ├── xcode.ts │ ├── xcodemake │ │ └── index.ts │ └── xcodemake.ts ├── tsconfig.json ├── tsconfig.test.json ├── tsup.config.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorButton.swift: -------------------------------------------------------------------------------- ```swift 1 | import SwiftUI 2 | 3 | // MARK: - Calculator Button Component 4 | struct CalculatorButton: View { 5 | let title: String 6 | let buttonType: CalculatorButtonType 7 | let isWideButton: Bool 8 | let action: () -> Void 9 | 10 | @State private var isPressed = false 11 | 12 | var body: some View { 13 | if buttonType == .hidden { 14 | // Empty space for layout 15 | Color.clear 16 | .frame(height: 80) 17 | } else { 18 | Button(action: { 19 | withAnimation(.easeInOut(duration: 0.1)) { 20 | isPressed = true 21 | } 22 | action() 23 | 24 | Task { 25 | try await Task.sleep(for: .seconds(0.1)) 26 | await MainActor.run { 27 | withAnimation(.easeInOut(duration: 0.1)) { 28 | isPressed = false 29 | } 30 | } 31 | } 32 | }) { 33 | ZStack { 34 | // Frosted glass background 35 | RoundedRectangle(cornerRadius: 20) 36 | .fill(.ultraThinMaterial) 37 | .overlay( 38 | RoundedRectangle(cornerRadius: 20) 39 | .stroke(buttonType.borderColor, lineWidth: 1) 40 | ) 41 | .overlay( 42 | // Subtle inner glow 43 | RoundedRectangle(cornerRadius: 20) 44 | .fill( 45 | RadialGradient( 46 | colors: [buttonType.glowColor.opacity(0.3), Color.clear], 47 | center: .topLeading, 48 | startRadius: 0, 49 | endRadius: 50 50 | ) 51 | ) 52 | ) 53 | .scaleEffect(isPressed ? 0.95 : 1.0) 54 | .shadow(color: buttonType.shadowColor.opacity(0.3), radius: isPressed ? 2 : 8, x: 0, y: isPressed ? 1 : 4) 55 | 56 | // Button text 57 | Text(title) 58 | .font(.system(size: 32, weight: .medium, design: .rounded)) 59 | .foregroundColor(buttonType.textColor) 60 | .scaleEffect(isPressed ? 0.9 : 1.0) 61 | } 62 | } 63 | .frame(height: 80) 64 | .gridCellColumns(isWideButton ? 2 : 1) 65 | .buttonStyle(PlainButtonStyle()) 66 | } 67 | } 68 | } 69 | 70 | // MARK: - Button Type Configuration 71 | enum CalculatorButtonType { 72 | case number, operation, function, hidden 73 | 74 | var textColor: Color { 75 | switch self { 76 | case .number: 77 | return .white 78 | case .operation: 79 | return .white 80 | case .function: 81 | return .white 82 | case .hidden: 83 | return .clear 84 | } 85 | } 86 | 87 | var borderColor: Color { 88 | switch self { 89 | case .number: 90 | return .white.opacity(0.3) 91 | case .operation: 92 | return .orange.opacity(0.6) 93 | case .function: 94 | return .gray.opacity(0.5) 95 | case .hidden: 96 | return .clear 97 | } 98 | } 99 | 100 | var glowColor: Color { 101 | switch self { 102 | case .number: 103 | return .blue 104 | case .operation: 105 | return .orange 106 | case .function: 107 | return .gray 108 | case .hidden: 109 | return .clear 110 | } 111 | } 112 | 113 | var shadowColor: Color { 114 | switch self { 115 | case .number: 116 | return .blue 117 | case .operation: 118 | return .orange 119 | case .function: 120 | return .gray 121 | case .hidden: 122 | return .clear 123 | } 124 | } 125 | } ``` -------------------------------------------------------------------------------- /src/mcp/tools/project-discovery/get_mac_bundle_id.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Project Discovery Plugin: Get macOS Bundle ID 3 | * 4 | * Extracts the bundle identifier from a macOS app bundle (.app). 5 | */ 6 | 7 | import { z } from 'zod'; 8 | import { log } from '../../../utils/logging/index.ts'; 9 | import { ToolResponse } from '../../../types/common.ts'; 10 | import { 11 | CommandExecutor, 12 | getDefaultFileSystemExecutor, 13 | getDefaultCommandExecutor, 14 | } from '../../../utils/command.ts'; 15 | import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; 16 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 17 | 18 | /** 19 | * Sync wrapper for CommandExecutor to handle synchronous commands 20 | */ 21 | async function executeSyncCommand(command: string, executor: CommandExecutor): Promise<string> { 22 | const result = await executor(['/bin/sh', '-c', command], 'macOS Bundle ID Extraction'); 23 | if (!result.success) { 24 | throw new Error(result.error ?? 'Command failed'); 25 | } 26 | return result.output || ''; 27 | } 28 | 29 | // Define schema as ZodObject 30 | const getMacBundleIdSchema = z.object({ 31 | appPath: z 32 | .string() 33 | .describe( 34 | 'Path to the macOS .app bundle to extract bundle ID from (full path to the .app directory)', 35 | ), 36 | }); 37 | 38 | // Use z.infer for type safety 39 | type GetMacBundleIdParams = z.infer<typeof getMacBundleIdSchema>; 40 | 41 | /** 42 | * Business logic for extracting macOS bundle ID 43 | */ 44 | export async function get_mac_bundle_idLogic( 45 | params: GetMacBundleIdParams, 46 | executor: CommandExecutor, 47 | fileSystemExecutor: FileSystemExecutor, 48 | ): Promise<ToolResponse> { 49 | const appPath = params.appPath; 50 | 51 | if (!fileSystemExecutor.existsSync(appPath)) { 52 | return { 53 | content: [ 54 | { 55 | type: 'text', 56 | text: `File not found: '${appPath}'. Please check the path and try again.`, 57 | }, 58 | ], 59 | isError: true, 60 | }; 61 | } 62 | 63 | log('info', `Starting bundle ID extraction for macOS app: ${appPath}`); 64 | 65 | try { 66 | let bundleId; 67 | 68 | try { 69 | bundleId = await executeSyncCommand( 70 | `defaults read "${appPath}/Contents/Info" CFBundleIdentifier`, 71 | executor, 72 | ); 73 | } catch { 74 | try { 75 | bundleId = await executeSyncCommand( 76 | `/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${appPath}/Contents/Info.plist"`, 77 | executor, 78 | ); 79 | } catch (innerError) { 80 | throw new Error( 81 | `Could not extract bundle ID from Info.plist: ${innerError instanceof Error ? innerError.message : String(innerError)}`, 82 | ); 83 | } 84 | } 85 | 86 | log('info', `Extracted macOS bundle ID: ${bundleId}`); 87 | 88 | return { 89 | content: [ 90 | { 91 | type: 'text', 92 | text: `✅ Bundle ID: ${bundleId}`, 93 | }, 94 | { 95 | type: 'text', 96 | text: `Next Steps: 97 | - Launch: launch_mac_app({ appPath: "${appPath}" }) 98 | - Build again: build_macos({ scheme: "SCHEME_NAME" })`, 99 | }, 100 | ], 101 | isError: false, 102 | }; 103 | } catch (error) { 104 | const errorMessage = error instanceof Error ? error.message : String(error); 105 | log('error', `Error extracting macOS bundle ID: ${errorMessage}`); 106 | 107 | return { 108 | content: [ 109 | { 110 | type: 'text', 111 | text: `Error extracting macOS bundle ID: ${errorMessage}`, 112 | }, 113 | { 114 | type: 'text', 115 | text: `Make sure the path points to a valid macOS app bundle (.app directory).`, 116 | }, 117 | ], 118 | isError: true, 119 | }; 120 | } 121 | } 122 | 123 | export default { 124 | name: 'get_mac_bundle_id', 125 | description: 126 | "Extracts the bundle identifier from a macOS app bundle (.app). IMPORTANT: You MUST provide the appPath parameter. Example: get_mac_bundle_id({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_get_macos_bundle_id.", 127 | schema: getMacBundleIdSchema.shape, // MCP SDK compatibility 128 | handler: createTypedTool( 129 | getMacBundleIdSchema, 130 | (params: GetMacBundleIdParams) => 131 | get_mac_bundle_idLogic(params, getDefaultCommandExecutor(), getDefaultFileSystemExecutor()), 132 | getDefaultCommandExecutor, 133 | ), 134 | }; 135 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator-management/set_sim_location.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { ToolResponse } from '../../../types/common.ts'; 3 | import { log } from '../../../utils/logging/index.ts'; 4 | import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 5 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 6 | 7 | // Define schema as ZodObject 8 | const setSimulatorLocationSchema = z.object({ 9 | simulatorUuid: z 10 | .string() 11 | .describe('UUID of the simulator to use (obtained from list_simulators)'), 12 | latitude: z.number().describe('The latitude for the custom location.'), 13 | longitude: z.number().describe('The longitude for the custom location.'), 14 | }); 15 | 16 | // Use z.infer for type safety 17 | type SetSimulatorLocationParams = z.infer<typeof setSimulatorLocationSchema>; 18 | 19 | // Helper function to execute simctl commands and handle responses 20 | async function executeSimctlCommandAndRespond( 21 | params: SetSimulatorLocationParams, 22 | simctlSubCommand: string[], 23 | operationDescriptionForXcodeCommand: string, 24 | successMessage: string, 25 | failureMessagePrefix: string, 26 | operationLogContext: string, 27 | executor: CommandExecutor = getDefaultCommandExecutor(), 28 | extraValidation?: () => ToolResponse | null, 29 | ): Promise<ToolResponse> { 30 | if (extraValidation) { 31 | const validationResult = extraValidation(); 32 | if (validationResult) { 33 | return validationResult; 34 | } 35 | } 36 | 37 | try { 38 | const command = ['xcrun', 'simctl', ...simctlSubCommand]; 39 | const result = await executor(command, operationDescriptionForXcodeCommand, true, {}); 40 | 41 | if (!result.success) { 42 | const fullFailureMessage = `${failureMessagePrefix}: ${result.error}`; 43 | log( 44 | 'error', 45 | `${fullFailureMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorUuid})`, 46 | ); 47 | return { 48 | content: [{ type: 'text', text: fullFailureMessage }], 49 | }; 50 | } 51 | 52 | log( 53 | 'info', 54 | `${successMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorUuid})`, 55 | ); 56 | return { 57 | content: [{ type: 'text', text: successMessage }], 58 | }; 59 | } catch (error) { 60 | const errorMessage = error instanceof Error ? error.message : String(error); 61 | const fullFailureMessage = `${failureMessagePrefix}: ${errorMessage}`; 62 | log( 63 | 'error', 64 | `Error during ${operationLogContext} for simulator ${params.simulatorUuid}: ${errorMessage}`, 65 | ); 66 | return { 67 | content: [{ type: 'text', text: fullFailureMessage }], 68 | }; 69 | } 70 | } 71 | 72 | export async function set_sim_locationLogic( 73 | params: SetSimulatorLocationParams, 74 | executor: CommandExecutor, 75 | ): Promise<ToolResponse> { 76 | const extraValidation = (): ToolResponse | null => { 77 | if (params.latitude < -90 || params.latitude > 90) { 78 | return { 79 | content: [ 80 | { 81 | type: 'text', 82 | text: 'Latitude must be between -90 and 90 degrees', 83 | }, 84 | ], 85 | }; 86 | } 87 | if (params.longitude < -180 || params.longitude > 180) { 88 | return { 89 | content: [ 90 | { 91 | type: 'text', 92 | text: 'Longitude must be between -180 and 180 degrees', 93 | }, 94 | ], 95 | }; 96 | } 97 | return null; 98 | }; 99 | 100 | log( 101 | 'info', 102 | `Setting simulator ${params.simulatorUuid} location to ${params.latitude},${params.longitude}`, 103 | ); 104 | 105 | return executeSimctlCommandAndRespond( 106 | params, 107 | ['location', params.simulatorUuid, 'set', `${params.latitude},${params.longitude}`], 108 | 'Set Simulator Location', 109 | `Successfully set simulator ${params.simulatorUuid} location to ${params.latitude},${params.longitude}`, 110 | 'Failed to set simulator location', 111 | 'set simulator location', 112 | executor, 113 | extraValidation, 114 | ); 115 | } 116 | 117 | export default { 118 | name: 'set_sim_location', 119 | description: 'Sets a custom GPS location for the simulator.', 120 | schema: setSimulatorLocationSchema.shape, // MCP SDK compatibility 121 | handler: createTypedTool( 122 | setSimulatorLocationSchema, 123 | set_sim_locationLogic, 124 | getDefaultCommandExecutor, 125 | ), 126 | }; 127 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/macos/build_macos.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * macOS Shared Plugin: Build macOS (Unified) 3 | * 4 | * Builds a macOS app using xcodebuild from a project or workspace. 5 | * Accepts mutually exclusive `projectPath` or `workspacePath`. 6 | */ 7 | 8 | import { z } from 'zod'; 9 | import { log } from '../../../utils/logging/index.ts'; 10 | import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; 11 | import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; 12 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 13 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 14 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; 15 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; 16 | 17 | // Types for dependency injection 18 | export interface BuildUtilsDependencies { 19 | executeXcodeBuildCommand: typeof executeXcodeBuildCommand; 20 | } 21 | 22 | // Default implementations 23 | const defaultBuildUtilsDependencies: BuildUtilsDependencies = { 24 | executeXcodeBuildCommand, 25 | }; 26 | 27 | // Unified schema: XOR between projectPath and workspacePath 28 | const baseSchemaObject = z.object({ 29 | projectPath: z.string().optional().describe('Path to the .xcodeproj file'), 30 | workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), 31 | scheme: z.string().describe('The scheme to use'), 32 | configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), 33 | derivedDataPath: z 34 | .string() 35 | .optional() 36 | .describe('Path where build products and other derived data will go'), 37 | arch: z 38 | .enum(['arm64', 'x86_64']) 39 | .optional() 40 | .describe('Architecture to build for (arm64 or x86_64). For macOS only.'), 41 | extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), 42 | preferXcodebuild: z 43 | .boolean() 44 | .optional() 45 | .describe('If true, prefers xcodebuild over the experimental incremental build system'), 46 | }); 47 | 48 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); 49 | 50 | const publicSchemaObject = baseSchemaObject.omit({ 51 | projectPath: true, 52 | workspacePath: true, 53 | scheme: true, 54 | configuration: true, 55 | arch: true, 56 | } as const); 57 | 58 | const buildMacOSSchema = baseSchema 59 | .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { 60 | message: 'Either projectPath or workspacePath is required.', 61 | }) 62 | .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { 63 | message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', 64 | }); 65 | 66 | export type BuildMacOSParams = z.infer<typeof buildMacOSSchema>; 67 | 68 | /** 69 | * Business logic for building macOS apps from project or workspace with dependency injection. 70 | * Exported for direct testing and reuse. 71 | */ 72 | export async function buildMacOSLogic( 73 | params: BuildMacOSParams, 74 | executor: CommandExecutor, 75 | buildUtilsDeps: BuildUtilsDependencies = defaultBuildUtilsDependencies, 76 | ): Promise<ToolResponse> { 77 | log('info', `Starting macOS build for scheme ${params.scheme} (internal)`); 78 | 79 | const processedParams = { 80 | ...params, 81 | configuration: params.configuration ?? 'Debug', 82 | preferXcodebuild: params.preferXcodebuild ?? false, 83 | }; 84 | 85 | return buildUtilsDeps.executeXcodeBuildCommand( 86 | processedParams, 87 | { 88 | platform: XcodePlatform.macOS, 89 | arch: params.arch, 90 | logPrefix: 'macOS Build', 91 | }, 92 | processedParams.preferXcodebuild ?? false, 93 | 'build', 94 | executor, 95 | ); 96 | } 97 | 98 | export default { 99 | name: 'build_macos', 100 | description: 'Builds a macOS app.', 101 | schema: publicSchemaObject.shape, 102 | handler: createSessionAwareTool<BuildMacOSParams>({ 103 | internalSchema: buildMacOSSchema as unknown as z.ZodType<BuildMacOSParams>, 104 | logicFunction: buildMacOSLogic, 105 | getExecutor: getDefaultCommandExecutor, 106 | requirements: [ 107 | { allOf: ['scheme'], message: 'scheme is required' }, 108 | { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, 109 | ], 110 | exclusivePairs: [['projectPath', 'workspacePath']], 111 | }), 112 | }; 113 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/project-discovery/get_app_bundle_id.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Project Discovery Plugin: Get App Bundle ID 3 | * 4 | * Extracts the bundle identifier from an app bundle (.app) for any Apple platform 5 | * (iOS, iPadOS, watchOS, tvOS, visionOS). 6 | */ 7 | 8 | import { z } from 'zod'; 9 | import { log } from '../../../utils/logging/index.ts'; 10 | import { ToolResponse } from '../../../types/common.ts'; 11 | import { 12 | CommandExecutor, 13 | getDefaultFileSystemExecutor, 14 | getDefaultCommandExecutor, 15 | } from '../../../utils/command.ts'; 16 | import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; 17 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 18 | 19 | // Define schema as ZodObject 20 | const getAppBundleIdSchema = z.object({ 21 | appPath: z 22 | .string() 23 | .describe( 24 | 'Path to the .app bundle to extract bundle ID from (full path to the .app directory)', 25 | ), 26 | }); 27 | 28 | // Use z.infer for type safety 29 | type GetAppBundleIdParams = z.infer<typeof getAppBundleIdSchema>; 30 | 31 | /** 32 | * Sync wrapper for CommandExecutor to handle synchronous commands 33 | */ 34 | async function executeSyncCommand(command: string, executor: CommandExecutor): Promise<string> { 35 | const result = await executor(['/bin/sh', '-c', command], 'Bundle ID Extraction'); 36 | if (!result.success) { 37 | throw new Error(result.error ?? 'Command failed'); 38 | } 39 | return result.output || ''; 40 | } 41 | 42 | /** 43 | * Business logic for extracting bundle ID from app. 44 | * Separated for testing and reusability. 45 | */ 46 | export async function get_app_bundle_idLogic( 47 | params: GetAppBundleIdParams, 48 | executor: CommandExecutor, 49 | fileSystemExecutor: FileSystemExecutor, 50 | ): Promise<ToolResponse> { 51 | // Zod validation is handled by createTypedTool, so params.appPath is guaranteed to be a string 52 | const appPath = params.appPath; 53 | 54 | if (!fileSystemExecutor.existsSync(appPath)) { 55 | return { 56 | content: [ 57 | { 58 | type: 'text', 59 | text: `File not found: '${appPath}'. Please check the path and try again.`, 60 | }, 61 | ], 62 | isError: true, 63 | }; 64 | } 65 | 66 | log('info', `Starting bundle ID extraction for app: ${appPath}`); 67 | 68 | try { 69 | let bundleId; 70 | 71 | try { 72 | bundleId = await executeSyncCommand( 73 | `defaults read "${appPath}/Info" CFBundleIdentifier`, 74 | executor, 75 | ); 76 | } catch { 77 | try { 78 | bundleId = await executeSyncCommand( 79 | `/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${appPath}/Info.plist"`, 80 | executor, 81 | ); 82 | } catch (innerError) { 83 | throw new Error( 84 | `Could not extract bundle ID from Info.plist: ${innerError instanceof Error ? innerError.message : String(innerError)}`, 85 | ); 86 | } 87 | } 88 | 89 | log('info', `Extracted app bundle ID: ${bundleId}`); 90 | 91 | return { 92 | content: [ 93 | { 94 | type: 'text', 95 | text: `✅ Bundle ID: ${bundleId}`, 96 | }, 97 | { 98 | type: 'text', 99 | text: `Next Steps: 100 | - Simulator: install_app_sim + launch_app_sim 101 | - Device: install_app_device + launch_app_device`, 102 | }, 103 | ], 104 | isError: false, 105 | }; 106 | } catch (error) { 107 | const errorMessage = error instanceof Error ? error.message : String(error); 108 | log('error', `Error extracting app bundle ID: ${errorMessage}`); 109 | 110 | return { 111 | content: [ 112 | { 113 | type: 'text', 114 | text: `Error extracting app bundle ID: ${errorMessage}`, 115 | }, 116 | { 117 | type: 'text', 118 | text: `Make sure the path points to a valid app bundle (.app directory).`, 119 | }, 120 | ], 121 | isError: true, 122 | }; 123 | } 124 | } 125 | 126 | export default { 127 | name: 'get_app_bundle_id', 128 | description: 129 | "Extracts the bundle identifier from an app bundle (.app) for any Apple platform (iOS, iPadOS, watchOS, tvOS, visionOS). IMPORTANT: You MUST provide the appPath parameter. Example: get_app_bundle_id({ appPath: '/path/to/your/app.app' })", 130 | schema: getAppBundleIdSchema.shape, // MCP SDK compatibility 131 | handler: createTypedTool( 132 | getAppBundleIdSchema, 133 | (params: GetAppBundleIdParams) => 134 | get_app_bundle_idLogic(params, getDefaultCommandExecutor(), getDefaultFileSystemExecutor()), 135 | getDefaultCommandExecutor, 136 | ), 137 | }; 138 | ``` -------------------------------------------------------------------------------- /src/mcp/resources/__tests__/doctor.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import doctorResource, { doctorResourceLogic } from '../doctor.ts'; 4 | import { createMockExecutor } from '../../../test-utils/mock-executors.ts'; 5 | 6 | describe('doctor resource', () => { 7 | describe('Export Field Validation', () => { 8 | it('should export correct uri', () => { 9 | expect(doctorResource.uri).toBe('xcodebuildmcp://doctor'); 10 | }); 11 | 12 | it('should export correct description', () => { 13 | expect(doctorResource.description).toBe( 14 | 'Comprehensive development environment diagnostic information and configuration status', 15 | ); 16 | }); 17 | 18 | it('should export correct mimeType', () => { 19 | expect(doctorResource.mimeType).toBe('text/plain'); 20 | }); 21 | 22 | it('should export handler function', () => { 23 | expect(typeof doctorResource.handler).toBe('function'); 24 | }); 25 | }); 26 | 27 | describe('Handler Functionality', () => { 28 | it('should handle successful environment data retrieval', async () => { 29 | const mockExecutor = createMockExecutor({ 30 | success: true, 31 | output: 'Mock command output', 32 | }); 33 | 34 | const result = await doctorResourceLogic(mockExecutor); 35 | 36 | expect(result.contents).toHaveLength(1); 37 | expect(result.contents[0].text).toContain('XcodeBuildMCP Doctor'); 38 | expect(result.contents[0].text).toContain('## System Information'); 39 | expect(result.contents[0].text).toContain('## Node.js Information'); 40 | expect(result.contents[0].text).toContain('## Dependencies'); 41 | expect(result.contents[0].text).toContain('## Environment Variables'); 42 | expect(result.contents[0].text).toContain('## Feature Status'); 43 | }); 44 | 45 | it('should handle spawn errors by showing doctor info', async () => { 46 | const mockExecutor = createMockExecutor(new Error('spawn xcrun ENOENT')); 47 | 48 | const result = await doctorResourceLogic(mockExecutor); 49 | 50 | expect(result.contents).toHaveLength(1); 51 | expect(result.contents[0].text).toContain('XcodeBuildMCP Doctor'); 52 | expect(result.contents[0].text).toContain('Error: spawn xcrun ENOENT'); 53 | }); 54 | 55 | it('should include required doctor sections', async () => { 56 | // Set dynamic tools environment variable to include discover_tools text 57 | const originalValue = process.env.XCODEBUILDMCP_DYNAMIC_TOOLS; 58 | process.env.XCODEBUILDMCP_DYNAMIC_TOOLS = 'true'; 59 | 60 | try { 61 | const mockExecutor = createMockExecutor({ 62 | success: true, 63 | output: 'Mock output', 64 | }); 65 | 66 | const result = await doctorResourceLogic(mockExecutor); 67 | 68 | expect(result.contents[0].text).toContain('## Troubleshooting Tips'); 69 | expect(result.contents[0].text).toContain('brew tap cameroncooke/axe'); 70 | expect(result.contents[0].text).toContain('INCREMENTAL_BUILDS_ENABLED=1'); 71 | expect(result.contents[0].text).toContain('discover_tools'); 72 | } finally { 73 | // Restore original environment variable 74 | if (originalValue === undefined) { 75 | delete process.env.XCODEBUILDMCP_DYNAMIC_TOOLS; 76 | } else { 77 | process.env.XCODEBUILDMCP_DYNAMIC_TOOLS = originalValue; 78 | } 79 | } 80 | }); 81 | 82 | it('should provide feature status information', async () => { 83 | const mockExecutor = createMockExecutor({ 84 | success: true, 85 | output: 'Mock output', 86 | }); 87 | 88 | const result = await doctorResourceLogic(mockExecutor); 89 | 90 | expect(result.contents[0].text).toContain('### UI Automation (axe)'); 91 | expect(result.contents[0].text).toContain('### Incremental Builds'); 92 | expect(result.contents[0].text).toContain('### Mise Integration'); 93 | expect(result.contents[0].text).toContain('## Tool Availability Summary'); 94 | }); 95 | 96 | it('should handle error conditions gracefully', async () => { 97 | const mockExecutor = createMockExecutor({ 98 | success: false, 99 | output: '', 100 | error: 'Command failed', 101 | }); 102 | 103 | const result = await doctorResourceLogic(mockExecutor); 104 | 105 | expect(result.contents).toHaveLength(1); 106 | expect(result.contents[0].text).toContain('XcodeBuildMCP Doctor'); 107 | }); 108 | }); 109 | }); 110 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * XcodeBuildMCP - Main entry point 5 | * 6 | * This file serves as the entry point for the XcodeBuildMCP server, importing and registering 7 | * all tool modules with the MCP server. It follows the platform-specific approach for Xcode tools. 8 | * 9 | * Responsibilities: 10 | * - Creating and starting the MCP server 11 | * - Registering all platform-specific tool modules 12 | * - Configuring server options and logging 13 | * - Handling server lifecycle events 14 | */ 15 | 16 | // Import Sentry instrumentation 17 | import './utils/sentry.ts'; 18 | 19 | // Import server components 20 | import { createServer, startServer } from './server/server.ts'; 21 | import { McpServer } from '@camsoft/mcp-sdk/server/mcp.js'; 22 | 23 | // Import MCP types for logging 24 | import { SetLevelRequestSchema } from '@camsoft/mcp-sdk/types.js'; 25 | 26 | // Import utilities 27 | import { log, setLogLevel, type LogLevel } from './utils/logger.ts'; 28 | 29 | // Import version 30 | import { version } from './version.ts'; 31 | 32 | // Import xcodemake utilities 33 | import { isXcodemakeEnabled, isXcodemakeAvailable } from './utils/xcodemake.ts'; 34 | 35 | // Import process for stdout configuration 36 | import process from 'node:process'; 37 | 38 | // Import resource management 39 | import { registerResources } from './core/resources.ts'; 40 | import { 41 | registerDiscoveryTools, 42 | registerAllToolsStatic, 43 | registerSelectedWorkflows, 44 | } from './utils/tool-registry.ts'; 45 | 46 | /** 47 | * Main function to start the server 48 | */ 49 | async function main(): Promise<void> { 50 | try { 51 | // Check if xcodemake is enabled and available 52 | if (isXcodemakeEnabled()) { 53 | log('info', 'xcodemake is enabled, checking if available...'); 54 | const available = await isXcodemakeAvailable(); 55 | if (available) { 56 | log('info', 'xcodemake is available and will be used for builds'); 57 | } else { 58 | log( 59 | 'warn', 60 | 'xcodemake is enabled but could not be made available, falling back to xcodebuild', 61 | ); 62 | } 63 | } else { 64 | log('debug', 'xcodemake is disabled, using standard xcodebuild'); 65 | } 66 | 67 | // Create the server 68 | const server = createServer(); 69 | 70 | // Register logging/setLevel handler 71 | server.server.setRequestHandler(SetLevelRequestSchema, async (request) => { 72 | const { level } = request.params; 73 | setLogLevel(level as LogLevel); 74 | log('info', `Client requested log level: ${level}`); 75 | return {}; // Empty result as per MCP spec 76 | }); 77 | 78 | // Make server available globally for dynamic tools 79 | (globalThis as { mcpServer?: McpServer }).mcpServer = server; 80 | 81 | // Check if dynamic tools mode is explicitly disabled 82 | const isDynamicModeEnabled = process.env.XCODEBUILDMCP_DYNAMIC_TOOLS === 'true'; 83 | 84 | if (isDynamicModeEnabled) { 85 | // DYNAMIC MODE: Start with discovery tools only 86 | log('info', '🚀 Initializing server in dynamic tools mode...'); 87 | await registerDiscoveryTools(server); 88 | log('info', '💡 Use discover_tools to enable additional workflows based on your task.'); 89 | } else { 90 | // STATIC MODE: Check for selective workflows 91 | const enabledWorkflows = process.env.XCODEBUILDMCP_ENABLED_WORKFLOWS; 92 | 93 | if (enabledWorkflows) { 94 | const workflowNames = enabledWorkflows.split(','); 95 | log('info', `🚀 Initializing server with selected workflows: ${workflowNames.join(', ')}`); 96 | await registerSelectedWorkflows(server, workflowNames); 97 | } else { 98 | log('info', '🚀 Initializing server in static tools mode...'); 99 | await registerAllToolsStatic(server); 100 | } 101 | } 102 | 103 | await registerResources(server); 104 | 105 | // Start the server 106 | await startServer(server); 107 | 108 | // Clean up on exit 109 | process.on('SIGTERM', async () => { 110 | await server.close(); 111 | process.exit(0); 112 | }); 113 | 114 | process.on('SIGINT', async () => { 115 | await server.close(); 116 | process.exit(0); 117 | }); 118 | 119 | // Log successful startup 120 | log('info', `XcodeBuildMCP server (version ${version}) started successfully`); 121 | } catch (error) { 122 | console.error('Fatal error in main():', error); 123 | process.exit(1); 124 | } 125 | } 126 | 127 | // Start the server 128 | main().catch((error) => { 129 | console.error('Unhandled exception:', error); 130 | // Give Sentry a moment to send the error before exiting 131 | setTimeout(() => process.exit(1), 1000); 132 | }); 133 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { sessionStore } from '../../../../utils/session-store.ts'; 3 | import plugin, { sessionSetDefaultsLogic } from '../session_set_defaults.ts'; 4 | 5 | describe('session-set-defaults tool', () => { 6 | beforeEach(() => { 7 | sessionStore.clear(); 8 | }); 9 | 10 | describe('Export Field Validation (Literal)', () => { 11 | it('should have correct name', () => { 12 | expect(plugin.name).toBe('session-set-defaults'); 13 | }); 14 | 15 | it('should have correct description', () => { 16 | expect(plugin.description).toBe( 17 | 'Set the session defaults needed by many tools. Most tools require one or more session defaults to be set before they can be used. Agents should set the relevant defaults at the beginning of a session.', 18 | ); 19 | }); 20 | 21 | it('should have handler function', () => { 22 | expect(typeof plugin.handler).toBe('function'); 23 | }); 24 | 25 | it('should have schema object', () => { 26 | expect(plugin.schema).toBeDefined(); 27 | expect(typeof plugin.schema).toBe('object'); 28 | }); 29 | }); 30 | 31 | describe('Handler Behavior', () => { 32 | it('should set provided defaults and return updated state', async () => { 33 | const result = await sessionSetDefaultsLogic({ 34 | scheme: 'MyScheme', 35 | simulatorName: 'iPhone 16', 36 | useLatestOS: true, 37 | arch: 'arm64', 38 | }); 39 | 40 | expect(result.isError).toBe(false); 41 | expect(result.content[0].text).toContain('Defaults updated:'); 42 | 43 | const current = sessionStore.getAll(); 44 | expect(current.scheme).toBe('MyScheme'); 45 | expect(current.simulatorName).toBe('iPhone 16'); 46 | expect(current.useLatestOS).toBe(true); 47 | expect(current.arch).toBe('arm64'); 48 | }); 49 | 50 | it('should validate parameter types via Zod', async () => { 51 | const result = await plugin.handler({ 52 | useLatestOS: 'yes' as unknown as boolean, 53 | }); 54 | 55 | expect(result.isError).toBe(true); 56 | expect(result.content[0].text).toContain('Parameter validation failed'); 57 | expect(result.content[0].text).toContain('useLatestOS'); 58 | }); 59 | 60 | it('should clear workspacePath when projectPath is set', async () => { 61 | sessionStore.setDefaults({ workspacePath: '/old/App.xcworkspace' }); 62 | await sessionSetDefaultsLogic({ projectPath: '/new/App.xcodeproj' }); 63 | const current = sessionStore.getAll(); 64 | expect(current.projectPath).toBe('/new/App.xcodeproj'); 65 | expect(current.workspacePath).toBeUndefined(); 66 | }); 67 | 68 | it('should clear projectPath when workspacePath is set', async () => { 69 | sessionStore.setDefaults({ projectPath: '/old/App.xcodeproj' }); 70 | await sessionSetDefaultsLogic({ workspacePath: '/new/App.xcworkspace' }); 71 | const current = sessionStore.getAll(); 72 | expect(current.workspacePath).toBe('/new/App.xcworkspace'); 73 | expect(current.projectPath).toBeUndefined(); 74 | }); 75 | 76 | it('should clear simulatorName when simulatorId is set', async () => { 77 | sessionStore.setDefaults({ simulatorName: 'iPhone 16' }); 78 | await sessionSetDefaultsLogic({ simulatorId: 'SIM-UUID' }); 79 | const current = sessionStore.getAll(); 80 | expect(current.simulatorId).toBe('SIM-UUID'); 81 | expect(current.simulatorName).toBeUndefined(); 82 | }); 83 | 84 | it('should clear simulatorId when simulatorName is set', async () => { 85 | sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); 86 | await sessionSetDefaultsLogic({ simulatorName: 'iPhone 16' }); 87 | const current = sessionStore.getAll(); 88 | expect(current.simulatorName).toBe('iPhone 16'); 89 | expect(current.simulatorId).toBeUndefined(); 90 | }); 91 | 92 | it('should reject when both projectPath and workspacePath are provided', async () => { 93 | const res = await plugin.handler({ 94 | projectPath: '/app/App.xcodeproj', 95 | workspacePath: '/app/App.xcworkspace', 96 | }); 97 | expect(res.isError).toBe(true); 98 | expect(res.content[0].text).toContain('Parameter validation failed'); 99 | expect(res.content[0].text).toContain('projectPath and workspacePath are mutually exclusive'); 100 | }); 101 | 102 | it('should reject when both simulatorId and simulatorName are provided', async () => { 103 | const res = await plugin.handler({ 104 | simulatorId: 'SIM-1', 105 | simulatorName: 'iPhone 16', 106 | }); 107 | expect(res.isError).toBe(true); 108 | expect(res.content[0].text).toContain('Parameter validation failed'); 109 | expect(res.content[0].text).toContain('simulatorId and simulatorName are mutually exclusive'); 110 | }); 111 | }); 112 | }); 113 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/device/launch_app_device.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Device Workspace Plugin: Launch App Device 3 | * 4 | * Launches an app on a physical Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). 5 | * Requires deviceId and bundleId. 6 | */ 7 | 8 | import { z } from 'zod'; 9 | import { ToolResponse } from '../../../types/common.ts'; 10 | import { log } from '../../../utils/logging/index.ts'; 11 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 12 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 13 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; 14 | import { promises as fs } from 'fs'; 15 | import { tmpdir } from 'os'; 16 | import { join } from 'path'; 17 | 18 | // Type for the launch JSON response 19 | type LaunchDataResponse = { 20 | result?: { 21 | process?: { 22 | processIdentifier?: number; 23 | }; 24 | }; 25 | }; 26 | 27 | // Define schema as ZodObject 28 | const launchAppDeviceSchema = z.object({ 29 | deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), 30 | bundleId: z 31 | .string() 32 | .describe('Bundle identifier of the app to launch (e.g., "com.example.MyApp")'), 33 | }); 34 | 35 | // Use z.infer for type safety 36 | type LaunchAppDeviceParams = z.infer<typeof launchAppDeviceSchema>; 37 | 38 | export async function launch_app_deviceLogic( 39 | params: LaunchAppDeviceParams, 40 | executor: CommandExecutor, 41 | ): Promise<ToolResponse> { 42 | const { deviceId, bundleId } = params; 43 | 44 | log('info', `Launching app ${bundleId} on device ${deviceId}`); 45 | 46 | try { 47 | // Use JSON output to capture process ID 48 | const tempJsonPath = join(tmpdir(), `launch-${Date.now()}.json`); 49 | 50 | const result = await executor( 51 | [ 52 | 'xcrun', 53 | 'devicectl', 54 | 'device', 55 | 'process', 56 | 'launch', 57 | '--device', 58 | deviceId, 59 | '--json-output', 60 | tempJsonPath, 61 | '--terminate-existing', 62 | bundleId, 63 | ], 64 | 'Launch app on device', 65 | true, // useShell 66 | undefined, // env 67 | ); 68 | 69 | if (!result.success) { 70 | return { 71 | content: [ 72 | { 73 | type: 'text', 74 | text: `Failed to launch app: ${result.error}`, 75 | }, 76 | ], 77 | isError: true, 78 | }; 79 | } 80 | 81 | // Parse JSON to extract process ID 82 | let processId: number | undefined; 83 | try { 84 | const jsonContent = await fs.readFile(tempJsonPath, 'utf8'); 85 | const parsedData: unknown = JSON.parse(jsonContent); 86 | 87 | // Type guard to validate the parsed data structure 88 | if ( 89 | parsedData && 90 | typeof parsedData === 'object' && 91 | 'result' in parsedData && 92 | parsedData.result && 93 | typeof parsedData.result === 'object' && 94 | 'process' in parsedData.result && 95 | parsedData.result.process && 96 | typeof parsedData.result.process === 'object' && 97 | 'processIdentifier' in parsedData.result.process && 98 | typeof parsedData.result.process.processIdentifier === 'number' 99 | ) { 100 | const launchData = parsedData as LaunchDataResponse; 101 | processId = launchData.result?.process?.processIdentifier; 102 | } 103 | 104 | // Clean up temp file 105 | await fs.unlink(tempJsonPath).catch(() => {}); 106 | } catch (error) { 107 | log('warn', `Failed to parse launch JSON output: ${error}`); 108 | } 109 | 110 | let responseText = `✅ App launched successfully\n\n${result.output}`; 111 | 112 | if (processId) { 113 | responseText += `\n\nProcess ID: ${processId}`; 114 | responseText += `\n\nNext Steps:`; 115 | responseText += `\n1. Interact with your app on the device`; 116 | responseText += `\n2. Stop the app: stop_app_device({ deviceId: "${deviceId}", processId: ${processId} })`; 117 | } 118 | 119 | return { 120 | content: [ 121 | { 122 | type: 'text', 123 | text: responseText, 124 | }, 125 | ], 126 | }; 127 | } catch (error) { 128 | const errorMessage = error instanceof Error ? error.message : String(error); 129 | log('error', `Error launching app on device: ${errorMessage}`); 130 | return { 131 | content: [ 132 | { 133 | type: 'text', 134 | text: `Failed to launch app on device: ${errorMessage}`, 135 | }, 136 | ], 137 | isError: true, 138 | }; 139 | } 140 | } 141 | 142 | export default { 143 | name: 'launch_app_device', 144 | description: 'Launches an app on a connected device.', 145 | schema: launchAppDeviceSchema.omit({ deviceId: true } as const).shape, 146 | handler: createSessionAwareTool<LaunchAppDeviceParams>({ 147 | internalSchema: launchAppDeviceSchema as unknown as z.ZodType<LaunchAppDeviceParams>, 148 | logicFunction: launch_app_deviceLogic, 149 | getExecutor: getDefaultCommandExecutor, 150 | requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], 151 | }), 152 | }; 153 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from 'vitest'; 2 | import { z } from 'zod'; 3 | import setSimAppearancePlugin, { set_sim_appearanceLogic } from '../set_sim_appearance.ts'; 4 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 5 | 6 | describe('set_sim_appearance plugin', () => { 7 | describe('Export Field Validation (Literal)', () => { 8 | it('should have correct name field', () => { 9 | expect(setSimAppearancePlugin.name).toBe('set_sim_appearance'); 10 | }); 11 | 12 | it('should have correct description field', () => { 13 | expect(setSimAppearancePlugin.description).toBe( 14 | 'Sets the appearance mode (dark/light) of an iOS simulator.', 15 | ); 16 | }); 17 | 18 | it('should have handler function', () => { 19 | expect(typeof setSimAppearancePlugin.handler).toBe('function'); 20 | }); 21 | 22 | it('should have correct schema validation', () => { 23 | const schema = z.object(setSimAppearancePlugin.schema); 24 | 25 | expect( 26 | schema.safeParse({ 27 | simulatorUuid: 'abc123', 28 | mode: 'dark', 29 | }).success, 30 | ).toBe(true); 31 | 32 | expect( 33 | schema.safeParse({ 34 | simulatorUuid: 'abc123', 35 | mode: 'light', 36 | }).success, 37 | ).toBe(true); 38 | 39 | expect( 40 | schema.safeParse({ 41 | simulatorUuid: 'abc123', 42 | mode: 'invalid', 43 | }).success, 44 | ).toBe(false); 45 | 46 | expect( 47 | schema.safeParse({ 48 | simulatorUuid: 123, 49 | mode: 'dark', 50 | }).success, 51 | ).toBe(false); 52 | }); 53 | }); 54 | 55 | describe('Handler Behavior (Complete Literal Returns)', () => { 56 | it('should handle successful appearance change', async () => { 57 | const mockExecutor = createMockExecutor({ 58 | success: true, 59 | output: '', 60 | error: '', 61 | }); 62 | 63 | const result = await set_sim_appearanceLogic( 64 | { 65 | simulatorUuid: 'test-uuid-123', 66 | mode: 'dark', 67 | }, 68 | mockExecutor, 69 | ); 70 | 71 | expect(result).toEqual({ 72 | content: [ 73 | { 74 | type: 'text', 75 | text: 'Successfully set simulator test-uuid-123 appearance to dark mode', 76 | }, 77 | ], 78 | }); 79 | }); 80 | 81 | it('should handle appearance change failure', async () => { 82 | const mockExecutor = createMockExecutor({ 83 | success: false, 84 | error: 'Invalid device: invalid-uuid', 85 | }); 86 | 87 | const result = await set_sim_appearanceLogic( 88 | { 89 | simulatorUuid: 'invalid-uuid', 90 | mode: 'light', 91 | }, 92 | mockExecutor, 93 | ); 94 | 95 | expect(result).toEqual({ 96 | content: [ 97 | { 98 | type: 'text', 99 | text: 'Failed to set simulator appearance: Invalid device: invalid-uuid', 100 | }, 101 | ], 102 | }); 103 | }); 104 | 105 | it('should handle missing simulatorUuid via Zod validation', async () => { 106 | const mockExecutor = createMockExecutor({ 107 | success: true, 108 | output: '', 109 | error: '', 110 | }); 111 | 112 | // Test the handler directly to trigger Zod validation 113 | const result = await setSimAppearancePlugin.handler({ mode: 'dark' }); 114 | 115 | expect(result).toEqual({ 116 | content: [ 117 | { 118 | type: 'text', 119 | text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nsimulatorUuid: Required', 120 | }, 121 | ], 122 | isError: true, 123 | }); 124 | }); 125 | 126 | it('should handle exception during execution', async () => { 127 | const mockExecutor = createMockExecutor(new Error('Network error')); 128 | 129 | const result = await set_sim_appearanceLogic( 130 | { 131 | simulatorUuid: 'test-uuid-123', 132 | mode: 'dark', 133 | }, 134 | mockExecutor, 135 | ); 136 | 137 | expect(result).toEqual({ 138 | content: [ 139 | { 140 | type: 'text', 141 | text: 'Failed to set simulator appearance: Network error', 142 | }, 143 | ], 144 | }); 145 | }); 146 | 147 | it('should call correct command', async () => { 148 | const commandCalls: any[] = []; 149 | const mockExecutor = (...args: any[]) => { 150 | commandCalls.push(args); 151 | return Promise.resolve({ 152 | success: true, 153 | output: '', 154 | error: '', 155 | process: { pid: 12345 }, 156 | }); 157 | }; 158 | 159 | await set_sim_appearanceLogic( 160 | { 161 | simulatorUuid: 'test-uuid-123', 162 | mode: 'dark', 163 | }, 164 | mockExecutor, 165 | ); 166 | 167 | expect(commandCalls).toEqual([ 168 | [ 169 | ['xcrun', 'simctl', 'ui', 'test-uuid-123', 'appearance', 'dark'], 170 | 'Set Simulator Appearance', 171 | true, 172 | undefined, 173 | ], 174 | ]); 175 | }); 176 | }); 177 | }); 178 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/boot_sim.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for boot_sim plugin (session-aware version) 3 | * Follows CLAUDE.md guidance: dependency injection, no vi-mocks, literal validation. 4 | */ 5 | 6 | import { describe, it, expect, beforeEach } from 'vitest'; 7 | import { z } from 'zod'; 8 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 9 | import { sessionStore } from '../../../../utils/session-store.ts'; 10 | import bootSim, { boot_simLogic } from '../boot_sim.ts'; 11 | 12 | describe('boot_sim tool', () => { 13 | beforeEach(() => { 14 | sessionStore.clear(); 15 | }); 16 | 17 | describe('Export Field Validation (Literal)', () => { 18 | it('should have correct name', () => { 19 | expect(bootSim.name).toBe('boot_sim'); 20 | }); 21 | 22 | it('should have concise description', () => { 23 | expect(bootSim.description).toBe('Boots an iOS simulator.'); 24 | }); 25 | 26 | it('should expose empty public schema', () => { 27 | const schema = z.object(bootSim.schema); 28 | expect(schema.safeParse({}).success).toBe(true); 29 | expect(Object.keys(bootSim.schema)).toHaveLength(0); 30 | }); 31 | }); 32 | 33 | describe('Handler Requirements', () => { 34 | it('should require simulatorId when not provided', async () => { 35 | const result = await bootSim.handler({}); 36 | 37 | expect(result.isError).toBe(true); 38 | expect(result.content[0].text).toContain('Missing required session defaults'); 39 | expect(result.content[0].text).toContain('simulatorId is required'); 40 | expect(result.content[0].text).toContain('session-set-defaults'); 41 | }); 42 | }); 43 | 44 | describe('Logic Behavior (Literal Results)', () => { 45 | it('should handle successful boot', async () => { 46 | const mockExecutor = createMockExecutor({ 47 | success: true, 48 | output: 'Simulator booted successfully', 49 | }); 50 | 51 | const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); 52 | 53 | expect(result).toEqual({ 54 | content: [ 55 | { 56 | type: 'text', 57 | text: `✅ Simulator booted successfully. To make it visible, use: open_sim() 58 | 59 | Next steps: 60 | 1. Open the Simulator app (makes it visible): open_sim() 61 | 2. Install an app: install_app_sim({ simulatorId: "test-uuid-123", appPath: "PATH_TO_YOUR_APP" }) 62 | 3. Launch an app: launch_app_sim({ simulatorId: "test-uuid-123", bundleId: "YOUR_APP_BUNDLE_ID" })`, 63 | }, 64 | ], 65 | }); 66 | }); 67 | 68 | it('should handle command failure', async () => { 69 | const mockExecutor = createMockExecutor({ 70 | success: false, 71 | error: 'Simulator not found', 72 | }); 73 | 74 | const result = await boot_simLogic({ simulatorId: 'invalid-uuid' }, mockExecutor); 75 | 76 | expect(result).toEqual({ 77 | content: [ 78 | { 79 | type: 'text', 80 | text: 'Boot simulator operation failed: Simulator not found', 81 | }, 82 | ], 83 | }); 84 | }); 85 | 86 | it('should handle exception with Error object', async () => { 87 | const mockExecutor = async () => { 88 | throw new Error('Connection failed'); 89 | }; 90 | 91 | const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); 92 | 93 | expect(result).toEqual({ 94 | content: [ 95 | { 96 | type: 'text', 97 | text: 'Boot simulator operation failed: Connection failed', 98 | }, 99 | ], 100 | }); 101 | }); 102 | 103 | it('should handle exception with string error', async () => { 104 | const mockExecutor = async () => { 105 | throw 'String error'; 106 | }; 107 | 108 | const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); 109 | 110 | expect(result).toEqual({ 111 | content: [ 112 | { 113 | type: 'text', 114 | text: 'Boot simulator operation failed: String error', 115 | }, 116 | ], 117 | }); 118 | }); 119 | 120 | it('should verify command generation with mock executor', async () => { 121 | const calls: Array<{ 122 | command: string[]; 123 | description: string; 124 | allowStderr: boolean; 125 | timeout?: number; 126 | }> = []; 127 | const mockExecutor = async ( 128 | command: string[], 129 | description: string, 130 | allowStderr: boolean, 131 | timeout?: number, 132 | ) => { 133 | calls.push({ command, description, allowStderr, timeout }); 134 | return { 135 | success: true, 136 | output: 'Simulator booted successfully', 137 | error: undefined, 138 | process: { pid: 12345 }, 139 | }; 140 | }; 141 | 142 | await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); 143 | 144 | expect(calls).toHaveLength(1); 145 | expect(calls[0]).toEqual({ 146 | command: ['xcrun', 'simctl', 'boot', 'test-uuid-123'], 147 | description: 'Boot Simulator', 148 | allowStderr: true, 149 | timeout: undefined, 150 | }); 151 | }); 152 | }); 153 | }); 154 | ``` -------------------------------------------------------------------------------- /src/utils/simulator-utils.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Simulator utility functions for name to UUID resolution 3 | */ 4 | 5 | import type { CommandExecutor } from './execution/index.ts'; 6 | import { ToolResponse } from '../types/common.ts'; 7 | import { log } from './logging/index.ts'; 8 | import { createErrorResponse } from './responses/index.ts'; 9 | 10 | /** 11 | * UUID regex pattern to check if a string looks like a UUID 12 | */ 13 | const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; 14 | 15 | /** 16 | * Determines the simulator UUID from either a UUID or name. 17 | * 18 | * Behavior: 19 | * - If simulatorUuid provided: return it directly 20 | * - Else if simulatorName looks like a UUID (regex): treat it as UUID and return it 21 | * - Else: resolve name → UUID via simctl and return the match (isAvailable === true) 22 | * 23 | * @param params Object containing optional simulatorUuid or simulatorName 24 | * @param executor Command executor for running simctl commands 25 | * @returns Object with uuid, optional warning, or error 26 | */ 27 | export async function determineSimulatorUuid( 28 | params: { simulatorUuid?: string; simulatorName?: string }, 29 | executor: CommandExecutor, 30 | ): Promise<{ uuid?: string; warning?: string; error?: ToolResponse }> { 31 | // If UUID is provided directly, use it 32 | if (params.simulatorUuid) { 33 | log('info', `Using provided simulator UUID: ${params.simulatorUuid}`); 34 | return { uuid: params.simulatorUuid }; 35 | } 36 | 37 | // If name is provided, check if it's actually a UUID 38 | if (params.simulatorName) { 39 | // Check if the "name" is actually a UUID string 40 | if (UUID_REGEX.test(params.simulatorName)) { 41 | log( 42 | 'info', 43 | `Simulator name '${params.simulatorName}' appears to be a UUID, using it directly`, 44 | ); 45 | return { 46 | uuid: params.simulatorName, 47 | warning: `The simulatorName '${params.simulatorName}' appears to be a UUID. Consider using simulatorUuid parameter instead.`, 48 | }; 49 | } 50 | 51 | // Resolve name to UUID via simctl 52 | log('info', `Looking up simulator UUID for name: ${params.simulatorName}`); 53 | 54 | const listResult = await executor( 55 | ['xcrun', 'simctl', 'list', 'devices', 'available', '-j'], 56 | 'List available simulators', 57 | ); 58 | 59 | if (!listResult.success) { 60 | return { 61 | error: createErrorResponse( 62 | 'Failed to list simulators', 63 | listResult.error ?? 'Unknown error', 64 | ), 65 | }; 66 | } 67 | 68 | try { 69 | interface SimulatorDevice { 70 | udid: string; 71 | name: string; 72 | isAvailable: boolean; 73 | } 74 | 75 | interface DevicesData { 76 | devices: Record<string, SimulatorDevice[]>; 77 | } 78 | 79 | const devicesData = JSON.parse(listResult.output ?? '{}') as DevicesData; 80 | 81 | // Search through all runtime sections for the named device 82 | for (const runtime of Object.keys(devicesData.devices)) { 83 | const devices = devicesData.devices[runtime]; 84 | if (!Array.isArray(devices)) continue; 85 | 86 | // Look for exact name match with isAvailable === true 87 | const device = devices.find( 88 | (d) => d.name === params.simulatorName && d.isAvailable === true, 89 | ); 90 | 91 | if (device) { 92 | log('info', `Found simulator '${params.simulatorName}' with UUID: ${device.udid}`); 93 | return { uuid: device.udid }; 94 | } 95 | } 96 | 97 | // If no available device found, check if device exists but is unavailable 98 | for (const runtime of Object.keys(devicesData.devices)) { 99 | const devices = devicesData.devices[runtime]; 100 | if (!Array.isArray(devices)) continue; 101 | 102 | const unavailableDevice = devices.find( 103 | (d) => d.name === params.simulatorName && d.isAvailable === false, 104 | ); 105 | 106 | if (unavailableDevice) { 107 | return { 108 | error: createErrorResponse( 109 | `Simulator '${params.simulatorName}' exists but is not available`, 110 | 'The simulator may need to be downloaded or is incompatible with the current Xcode version', 111 | ), 112 | }; 113 | } 114 | } 115 | 116 | // Device not found at all 117 | return { 118 | error: createErrorResponse( 119 | `Simulator '${params.simulatorName}' not found`, 120 | 'Please check the simulator name or use "xcrun simctl list devices" to see available simulators', 121 | ), 122 | }; 123 | } catch (parseError) { 124 | return { 125 | error: createErrorResponse( 126 | 'Failed to parse simulator list', 127 | parseError instanceof Error ? parseError.message : String(parseError), 128 | ), 129 | }; 130 | } 131 | } 132 | 133 | // Neither UUID nor name provided 134 | return { 135 | error: createErrorResponse( 136 | 'No simulator identifier provided', 137 | 'Either simulatorUuid or simulatorName is required', 138 | ), 139 | }; 140 | } 141 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator-management/erase_sims.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { ToolResponse, type ToolResponseContent } from '../../../types/common.ts'; 3 | import { log } from '../../../utils/logging/index.ts'; 4 | import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 5 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 6 | 7 | const eraseSimsBaseSchema = z.object({ 8 | simulatorUdid: z.string().uuid().optional().describe('UDID of the simulator to erase.'), 9 | all: z.boolean().optional().describe('When true, erases all simulators.'), 10 | shutdownFirst: z 11 | .boolean() 12 | .optional() 13 | .describe('If true, shuts down the target (UDID or all) before erasing.'), 14 | }); 15 | 16 | const eraseSimsSchema = eraseSimsBaseSchema.refine( 17 | (v) => { 18 | const selectors = (v.simulatorUdid ? 1 : 0) + (v.all === true ? 1 : 0); 19 | return selectors === 1; 20 | }, 21 | { message: 'Provide exactly one of: simulatorUdid or all=true.' }, 22 | ); 23 | 24 | type EraseSimsParams = z.infer<typeof eraseSimsSchema>; 25 | 26 | export async function erase_simsLogic( 27 | params: EraseSimsParams, 28 | executor: CommandExecutor, 29 | ): Promise<ToolResponse> { 30 | try { 31 | if (params.simulatorUdid) { 32 | const udid = params.simulatorUdid; 33 | log( 34 | 'info', 35 | `Erasing simulator ${udid}${params.shutdownFirst ? ' (shutdownFirst=true)' : ''}`, 36 | ); 37 | 38 | if (params.shutdownFirst) { 39 | try { 40 | await executor( 41 | ['xcrun', 'simctl', 'shutdown', udid], 42 | 'Shutdown Simulator', 43 | true, 44 | undefined, 45 | ); 46 | } catch { 47 | // ignore shutdown errors; proceed to erase attempt 48 | } 49 | } 50 | 51 | const result = await executor( 52 | ['xcrun', 'simctl', 'erase', udid], 53 | 'Erase Simulator', 54 | true, 55 | undefined, 56 | ); 57 | if (result.success) { 58 | return { content: [{ type: 'text', text: `Successfully erased simulator ${udid}` }] }; 59 | } 60 | 61 | // Add tool hint if simulator is booted and shutdownFirst was not requested 62 | const errText = result.error ?? 'Unknown error'; 63 | if (/Unable to erase contents and settings.*Booted/i.test(errText) && !params.shutdownFirst) { 64 | return { 65 | content: [ 66 | { type: 'text', text: `Failed to erase simulator: ${errText}` }, 67 | { 68 | type: 'text', 69 | text: `Tool hint: The simulator appears to be Booted. Re-run erase_sims with { simulatorUdid: '${udid}', shutdownFirst: true } to shut it down before erasing.`, 70 | }, 71 | ], 72 | }; 73 | } 74 | 75 | return { 76 | content: [{ type: 'text', text: `Failed to erase simulator: ${errText}` }], 77 | }; 78 | } 79 | 80 | if (params.all === true) { 81 | log('info', `Erasing ALL simulators${params.shutdownFirst ? ' (shutdownFirst=true)' : ''}`); 82 | if (params.shutdownFirst) { 83 | try { 84 | await executor( 85 | ['xcrun', 'simctl', 'shutdown', 'all'], 86 | 'Shutdown All Simulators', 87 | true, 88 | undefined, 89 | ); 90 | } catch { 91 | // ignore and continue to erase 92 | } 93 | } 94 | 95 | const result = await executor( 96 | ['xcrun', 'simctl', 'erase', 'all'], 97 | 'Erase All Simulators', 98 | true, 99 | undefined, 100 | ); 101 | if (!result.success) { 102 | const errText = result.error ?? 'Unknown error'; 103 | const content: ToolResponseContent[] = [ 104 | { type: 'text', text: `Failed to erase all simulators: ${errText}` }, 105 | ]; 106 | if ( 107 | /Unable to erase contents and settings.*Booted/i.test(errText) && 108 | !params.shutdownFirst 109 | ) { 110 | content.push({ 111 | type: 'text', 112 | text: 'Tool hint: One or more simulators appear to be Booted. Re-run erase_sims with { all: true, shutdownFirst: true } to shut them down before erasing.', 113 | }); 114 | } 115 | return { content }; 116 | } 117 | return { content: [{ type: 'text', text: 'Successfully erased all simulators' }] }; 118 | } 119 | 120 | return { 121 | content: [{ type: 'text', text: 'Invalid parameters: provide simulatorUdid or all=true.' }], 122 | }; 123 | } catch (error: unknown) { 124 | const message = error instanceof Error ? error.message : String(error); 125 | log('error', `Error erasing simulators: ${message}`); 126 | return { content: [{ type: 'text', text: `Failed to erase simulators: ${message}` }] }; 127 | } 128 | } 129 | 130 | export default { 131 | name: 'erase_sims', 132 | description: 133 | 'Erases simulator content and settings. Provide exactly one of: simulatorUdid or all=true. Optional: shutdownFirst to shut down before erasing.', 134 | schema: eraseSimsBaseSchema.shape, 135 | handler: createTypedTool(eraseSimsSchema, erase_simsLogic, getDefaultCommandExecutor), 136 | }; 137 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/project-discovery/show_build_settings.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Project Discovery Plugin: Show Build Settings (Unified) 3 | * 4 | * Shows build settings from either a project or workspace using xcodebuild. 5 | * Accepts mutually exclusive `projectPath` or `workspacePath`. 6 | */ 7 | 8 | import { z } from 'zod'; 9 | import { log } from '../../../utils/logging/index.ts'; 10 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 11 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 12 | import { createTextResponse } from '../../../utils/responses/index.ts'; 13 | import { ToolResponse } from '../../../types/common.ts'; 14 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; 15 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; 16 | 17 | // Unified schema: XOR between projectPath and workspacePath 18 | const baseSchemaObject = z.object({ 19 | projectPath: z.string().optional().describe('Path to the .xcodeproj file'), 20 | workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), 21 | scheme: z.string().describe('Scheme name to show build settings for (Required)'), 22 | }); 23 | 24 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); 25 | 26 | const showBuildSettingsSchema = baseSchema 27 | .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { 28 | message: 'Either projectPath or workspacePath is required.', 29 | }) 30 | .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { 31 | message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', 32 | }); 33 | 34 | export type ShowBuildSettingsParams = z.infer<typeof showBuildSettingsSchema>; 35 | 36 | /** 37 | * Business logic for showing build settings from a project or workspace. 38 | * Exported for direct testing and reuse. 39 | */ 40 | export async function showBuildSettingsLogic( 41 | params: ShowBuildSettingsParams, 42 | executor: CommandExecutor, 43 | ): Promise<ToolResponse> { 44 | log('info', `Showing build settings for scheme ${params.scheme}`); 45 | 46 | try { 47 | // Create the command array for xcodebuild 48 | const command = ['xcodebuild', '-showBuildSettings']; // -showBuildSettings as an option, not an action 49 | 50 | const hasProjectPath = typeof params.projectPath === 'string'; 51 | const path = hasProjectPath ? params.projectPath : params.workspacePath; 52 | 53 | if (hasProjectPath) { 54 | command.push('-project', params.projectPath!); 55 | } else { 56 | command.push('-workspace', params.workspacePath!); 57 | } 58 | 59 | // Add the scheme 60 | command.push('-scheme', params.scheme); 61 | 62 | // Execute the command directly 63 | const result = await executor(command, 'Show Build Settings', true); 64 | 65 | if (!result.success) { 66 | return createTextResponse(`Failed to show build settings: ${result.error}`, true); 67 | } 68 | 69 | // Create response based on which type was used (similar to workspace version with next steps) 70 | const content: Array<{ type: 'text'; text: string }> = [ 71 | { 72 | type: 'text', 73 | text: hasProjectPath 74 | ? `✅ Build settings for scheme ${params.scheme}:` 75 | : '✅ Build settings retrieved successfully', 76 | }, 77 | { 78 | type: 'text', 79 | text: result.output || 'Build settings retrieved successfully.', 80 | }, 81 | ]; 82 | 83 | // Add next steps for workspace (similar to original workspace implementation) 84 | if (!hasProjectPath && path) { 85 | content.push({ 86 | type: 'text', 87 | text: `Next Steps: 88 | - Build the workspace: build_macos({ workspacePath: "${path}", scheme: "${params.scheme}" }) 89 | - For iOS: build_sim({ workspacePath: "${path}", scheme: "${params.scheme}", simulatorName: "iPhone 16" }) 90 | - List schemes: list_schemes({ workspacePath: "${path}" })`, 91 | }); 92 | } 93 | 94 | return { 95 | content, 96 | isError: false, 97 | }; 98 | } catch (error) { 99 | const errorMessage = error instanceof Error ? error.message : String(error); 100 | log('error', `Error showing build settings: ${errorMessage}`); 101 | return createTextResponse(`Error showing build settings: ${errorMessage}`, true); 102 | } 103 | } 104 | 105 | const publicSchemaObject = baseSchemaObject.omit({ 106 | projectPath: true, 107 | workspacePath: true, 108 | scheme: true, 109 | } as const); 110 | 111 | export default { 112 | name: 'show_build_settings', 113 | description: 'Shows xcodebuild build settings.', 114 | schema: publicSchemaObject.shape, 115 | handler: createSessionAwareTool<ShowBuildSettingsParams>({ 116 | internalSchema: showBuildSettingsSchema as unknown as z.ZodType<ShowBuildSettingsParams>, 117 | logicFunction: showBuildSettingsLogic, 118 | getExecutor: getDefaultCommandExecutor, 119 | requirements: [ 120 | { allOf: ['scheme'], message: 'scheme is required' }, 121 | { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, 122 | ], 123 | exclusivePairs: [['projectPath', 'workspacePath']], 124 | }), 125 | }; 126 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/project-discovery/list_schemes.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Project Discovery Plugin: List Schemes (Unified) 3 | * 4 | * Lists available schemes for either a project or workspace using xcodebuild. 5 | * Accepts mutually exclusive `projectPath` or `workspacePath`. 6 | */ 7 | 8 | import { z } from 'zod'; 9 | import { log } from '../../../utils/logging/index.ts'; 10 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 11 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 12 | import { createTextResponse } from '../../../utils/responses/index.ts'; 13 | import { ToolResponse } from '../../../types/common.ts'; 14 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; 15 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; 16 | 17 | // Unified schema: XOR between projectPath and workspacePath 18 | const baseSchemaObject = z.object({ 19 | projectPath: z.string().optional().describe('Path to the .xcodeproj file'), 20 | workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), 21 | }); 22 | 23 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); 24 | 25 | const listSchemesSchema = baseSchema 26 | .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { 27 | message: 'Either projectPath or workspacePath is required.', 28 | }) 29 | .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { 30 | message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', 31 | }); 32 | 33 | export type ListSchemesParams = z.infer<typeof listSchemesSchema>; 34 | 35 | /** 36 | * Business logic for listing schemes in a project or workspace. 37 | * Exported for direct testing and reuse. 38 | */ 39 | export async function listSchemesLogic( 40 | params: ListSchemesParams, 41 | executor: CommandExecutor, 42 | ): Promise<ToolResponse> { 43 | log('info', 'Listing schemes'); 44 | 45 | try { 46 | // For listing schemes, we can't use executeXcodeBuild directly since it's not a standard action 47 | // We need to create a custom command with -list flag 48 | const command = ['xcodebuild', '-list']; 49 | 50 | const hasProjectPath = typeof params.projectPath === 'string'; 51 | const projectOrWorkspace = hasProjectPath ? 'project' : 'workspace'; 52 | const path = hasProjectPath ? params.projectPath : params.workspacePath; 53 | 54 | if (hasProjectPath) { 55 | command.push('-project', params.projectPath!); 56 | } else { 57 | command.push('-workspace', params.workspacePath!); 58 | } 59 | 60 | const result = await executor(command, 'List Schemes', true); 61 | 62 | if (!result.success) { 63 | return createTextResponse(`Failed to list schemes: ${result.error}`, true); 64 | } 65 | 66 | // Extract schemes from the output 67 | const schemesMatch = result.output.match(/Schemes:([\s\S]*?)(?=\n\n|$)/); 68 | 69 | if (!schemesMatch) { 70 | return createTextResponse('No schemes found in the output', true); 71 | } 72 | 73 | const schemeLines = schemesMatch[1].trim().split('\n'); 74 | const schemes = schemeLines.map((line) => line.trim()).filter((line) => line); 75 | 76 | // Prepare next steps with the first scheme if available 77 | let nextStepsText = ''; 78 | if (schemes.length > 0) { 79 | const firstScheme = schemes[0]; 80 | 81 | // Note: After Phase 2, these will be unified tool names too 82 | nextStepsText = `Next Steps: 83 | 1. Build the app: build_macos({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}" }) 84 | or for iOS: build_sim({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}", simulatorName: "iPhone 16" }) 85 | 2. Show build settings: show_build_settings({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}" })`; 86 | } 87 | 88 | return { 89 | content: [ 90 | { 91 | type: 'text', 92 | text: `✅ Available schemes:`, 93 | }, 94 | { 95 | type: 'text', 96 | text: schemes.join('\n'), 97 | }, 98 | { 99 | type: 'text', 100 | text: nextStepsText, 101 | }, 102 | ], 103 | isError: false, 104 | }; 105 | } catch (error) { 106 | const errorMessage = error instanceof Error ? error.message : String(error); 107 | log('error', `Error listing schemes: ${errorMessage}`); 108 | return createTextResponse(`Error listing schemes: ${errorMessage}`, true); 109 | } 110 | } 111 | 112 | const publicSchemaObject = baseSchemaObject.omit({ 113 | projectPath: true, 114 | workspacePath: true, 115 | } as const); 116 | 117 | export default { 118 | name: 'list_schemes', 119 | description: 'Lists schemes for a project or workspace.', 120 | schema: publicSchemaObject.shape, 121 | handler: createSessionAwareTool<ListSchemesParams>({ 122 | internalSchema: listSchemesSchema as unknown as z.ZodType<ListSchemesParams>, 123 | logicFunction: listSchemesLogic, 124 | getExecutor: getDefaultCommandExecutor, 125 | requirements: [ 126 | { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, 127 | ], 128 | exclusivePairs: [['projectPath', 'workspacePath']], 129 | }), 130 | }; 131 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/open_sim.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for open_sim plugin 3 | * Following CLAUDE.md testing standards with literal validation 4 | * Using dependency injection for deterministic testing 5 | */ 6 | 7 | import { describe, it, expect } from 'vitest'; 8 | import { z } from 'zod'; 9 | import { createMockExecutor, type CommandExecutor } from '../../../../test-utils/mock-executors.ts'; 10 | import openSim, { open_simLogic } from '../open_sim.ts'; 11 | 12 | describe('open_sim tool', () => { 13 | describe('Export Field Validation (Literal)', () => { 14 | it('should have correct name field', () => { 15 | expect(openSim.name).toBe('open_sim'); 16 | }); 17 | 18 | it('should have correct description field', () => { 19 | expect(openSim.description).toBe('Opens the iOS Simulator app.'); 20 | }); 21 | 22 | it('should have handler function', () => { 23 | expect(typeof openSim.handler).toBe('function'); 24 | }); 25 | 26 | it('should have correct schema validation', () => { 27 | const schema = z.object(openSim.schema); 28 | 29 | // Schema is empty, so any object should pass 30 | expect(schema.safeParse({}).success).toBe(true); 31 | 32 | expect( 33 | schema.safeParse({ 34 | anyProperty: 'value', 35 | }).success, 36 | ).toBe(true); 37 | 38 | // Empty schema should accept anything 39 | expect( 40 | schema.safeParse({ 41 | enabled: true, 42 | }).success, 43 | ).toBe(true); 44 | }); 45 | }); 46 | 47 | describe('Handler Behavior (Complete Literal Returns)', () => { 48 | it('should return exact successful open simulator response', async () => { 49 | const mockExecutor = createMockExecutor({ 50 | success: true, 51 | output: '', 52 | }); 53 | 54 | const result = await open_simLogic({}, mockExecutor); 55 | 56 | expect(result).toEqual({ 57 | content: [ 58 | { 59 | type: 'text', 60 | text: 'Simulator app opened successfully', 61 | }, 62 | { 63 | type: 'text', 64 | text: `Next Steps: 65 | 1. Boot a simulator if needed: boot_sim({ simulatorUuid: 'UUID_FROM_LIST_SIMULATORS' }) 66 | 2. Launch your app and interact with it 67 | 3. Log capture options: 68 | - Option 1: Capture structured logs only (app continues running): 69 | start_sim_log_cap({ simulatorUuid: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }) 70 | - Option 2: Capture both console and structured logs (app will restart): 71 | start_sim_log_cap({ simulatorUuid: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true }) 72 | - Option 3: Launch app with logs in one step: 73 | launch_app_logs_sim({ simulatorUuid: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' })`, 74 | }, 75 | ], 76 | }); 77 | }); 78 | 79 | it('should return exact command failure response', async () => { 80 | const mockExecutor = createMockExecutor({ 81 | success: false, 82 | error: 'Command failed', 83 | }); 84 | 85 | const result = await open_simLogic({}, mockExecutor); 86 | 87 | expect(result).toEqual({ 88 | content: [ 89 | { 90 | type: 'text', 91 | text: 'Open simulator operation failed: Command failed', 92 | }, 93 | ], 94 | }); 95 | }); 96 | 97 | it('should return exact exception handling response', async () => { 98 | const mockExecutor: CommandExecutor = async () => { 99 | throw new Error('Test error'); 100 | }; 101 | 102 | const result = await open_simLogic({}, mockExecutor); 103 | 104 | expect(result).toEqual({ 105 | content: [ 106 | { 107 | type: 'text', 108 | text: 'Open simulator operation failed: Test error', 109 | }, 110 | ], 111 | }); 112 | }); 113 | 114 | it('should return exact string error handling response', async () => { 115 | const mockExecutor: CommandExecutor = async () => { 116 | throw 'String error'; 117 | }; 118 | 119 | const result = await open_simLogic({}, mockExecutor); 120 | 121 | expect(result).toEqual({ 122 | content: [ 123 | { 124 | type: 'text', 125 | text: 'Open simulator operation failed: String error', 126 | }, 127 | ], 128 | }); 129 | }); 130 | 131 | it('should verify command generation with mock executor', async () => { 132 | const calls: Array<{ 133 | command: string[]; 134 | description: string; 135 | hideOutput: boolean; 136 | workingDirectory: string | undefined; 137 | }> = []; 138 | 139 | const mockExecutor: CommandExecutor = async ( 140 | command, 141 | description, 142 | hideOutput, 143 | workingDirectory, 144 | ) => { 145 | calls.push({ command, description, hideOutput, workingDirectory }); 146 | return { 147 | success: true, 148 | output: '', 149 | error: undefined, 150 | process: { pid: 12345 }, 151 | }; 152 | }; 153 | 154 | await open_simLogic({}, mockExecutor); 155 | 156 | expect(calls).toHaveLength(1); 157 | expect(calls[0]).toEqual({ 158 | command: ['open', '-a', 'Simulator'], 159 | description: 'Open Simulator', 160 | hideOutput: true, 161 | workingDirectory: undefined, 162 | }); 163 | }); 164 | }); 165 | }); 166 | ``` -------------------------------------------------------------------------------- /docs/session-aware-migration-todo.md: -------------------------------------------------------------------------------- ```markdown 1 | # Session-Aware Migration TODO 2 | 3 | _Audit date: October 6, 2025_ 4 | 5 | Reference: `docs/session_management_plan.md` 6 | 7 | ## Utilities 8 | - [x] `src/mcp/tools/utilities/clean.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. 9 | 10 | ## Project Discovery 11 | - [x] `src/mcp/tools/project-discovery/list_schemes.ts` — session defaults: `projectPath`, `workspacePath`. 12 | - [x] `src/mcp/tools/project-discovery/show_build_settings.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`. 13 | 14 | ## Device Workflows 15 | - [x] `src/mcp/tools/device/build_device.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. 16 | - [x] `src/mcp/tools/device/test_device.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `deviceId`, `configuration`. 17 | - [x] `src/mcp/tools/device/get_device_app_path.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. 18 | - [x] `src/mcp/tools/device/install_app_device.ts` — session defaults: `deviceId`. 19 | - [x] `src/mcp/tools/device/launch_app_device.ts` — session defaults: `deviceId`. 20 | - [x] `src/mcp/tools/device/stop_app_device.ts` — session defaults: `deviceId`. 21 | 22 | ## Device Logging 23 | - [x] `src/mcp/tools/logging/start_device_log_cap.ts` — session defaults: `deviceId`. 24 | 25 | ## macOS Workflows 26 | - [x] `src/mcp/tools/macos/build_macos.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`, `arch`. 27 | - [x] `src/mcp/tools/macos/build_run_macos.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`, `arch`. 28 | - [x] `src/mcp/tools/macos/test_macos.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. 29 | - [x] `src/mcp/tools/macos/get_mac_app_path.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`, `arch`. 30 | 31 | ## Simulator Build/Test/Path 32 | - [x] `src/mcp/tools/simulator/test_sim.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `simulatorId`, `simulatorName`, `configuration`, `useLatestOS`. 33 | - [x] `src/mcp/tools/simulator/get_sim_app_path.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `simulatorId`, `simulatorName`, `configuration`, `useLatestOS`, `arch`. 34 | 35 | ## Simulator Runtime Actions 36 | - [x] `src/mcp/tools/simulator/boot_sim.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 37 | - [x] `src/mcp/tools/simulator/install_app_sim.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 38 | - [x] `src/mcp/tools/simulator/launch_app_sim.ts` — session defaults: `simulatorId`, `simulatorName` (hydrate `simulatorUuid`). 39 | - [x] `src/mcp/tools/simulator/launch_app_logs_sim.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 40 | - [x] `src/mcp/tools/simulator/stop_app_sim.ts` — session defaults: `simulatorId`, `simulatorName` (hydrate `simulatorUuid`). 41 | - [x] `src/mcp/tools/simulator/record_sim_video.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 42 | 43 | ## Simulator Management 44 | - [ ] `src/mcp/tools/simulator-management/erase_sims.ts` — session defaults: `simulatorId` (covers `simulatorUdid`). 45 | - [ ] `src/mcp/tools/simulator-management/set_sim_location.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 46 | - [ ] `src/mcp/tools/simulator-management/reset_sim_location.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 47 | - [ ] `src/mcp/tools/simulator-management/set_sim_appearance.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 48 | - [ ] `src/mcp/tools/simulator-management/sim_statusbar.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 49 | 50 | ## Simulator Logging 51 | - [ ] `src/mcp/tools/logging/start_sim_log_cap.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 52 | 53 | ## AXe UI Testing Tools 54 | - [ ] `src/mcp/tools/ui-testing/button.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 55 | - [ ] `src/mcp/tools/ui-testing/describe_ui.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 56 | - [ ] `src/mcp/tools/ui-testing/gesture.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 57 | - [ ] `src/mcp/tools/ui-testing/key_press.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 58 | - [ ] `src/mcp/tools/ui-testing/key_sequence.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 59 | - [ ] `src/mcp/tools/ui-testing/long_press.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 60 | - [ ] `src/mcp/tools/ui-testing/screenshot.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 61 | - [ ] `src/mcp/tools/ui-testing/swipe.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 62 | - [ ] `src/mcp/tools/ui-testing/tap.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 63 | - [ ] `src/mcp/tools/ui-testing/touch.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 64 | - [ ] `src/mcp/tools/ui-testing/type_text.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). 65 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from 'vitest'; 2 | import { z } from 'zod'; 3 | import eraseSims, { erase_simsLogic } from '../erase_sims.ts'; 4 | import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; 5 | 6 | describe('erase_sims tool (UDID or ALL only)', () => { 7 | describe('Export Field Validation (Literal)', () => { 8 | it('should have correct name', () => { 9 | expect(eraseSims.name).toBe('erase_sims'); 10 | }); 11 | 12 | it('should have correct description', () => { 13 | expect(eraseSims.description).toContain('Provide exactly one of: simulatorUdid or all=true'); 14 | expect(eraseSims.description).toContain('shutdownFirst'); 15 | }); 16 | 17 | it('should have handler function', () => { 18 | expect(typeof eraseSims.handler).toBe('function'); 19 | }); 20 | 21 | it('should validate schema fields (shape only)', () => { 22 | const schema = z.object(eraseSims.schema); 23 | // Valid 24 | expect( 25 | schema.safeParse({ simulatorUdid: '123e4567-e89b-12d3-a456-426614174000' }).success, 26 | ).toBe(true); 27 | expect(schema.safeParse({ all: true }).success).toBe(true); 28 | // Shape-level schema does not enforce selection rules; handler validation covers that. 29 | }); 30 | }); 31 | 32 | describe('Single mode', () => { 33 | it('erases a simulator successfully', async () => { 34 | const mock = createMockExecutor({ success: true, output: 'OK' }); 35 | const res = await erase_simsLogic({ simulatorUdid: 'UD1' }, mock); 36 | expect(res).toEqual({ 37 | content: [{ type: 'text', text: 'Successfully erased simulator UD1' }], 38 | }); 39 | }); 40 | 41 | it('returns failure when erase fails', async () => { 42 | const mock = createMockExecutor({ success: false, error: 'Booted device' }); 43 | const res = await erase_simsLogic({ simulatorUdid: 'UD1' }, mock); 44 | expect(res).toEqual({ 45 | content: [{ type: 'text', text: 'Failed to erase simulator: Booted device' }], 46 | }); 47 | }); 48 | 49 | it('adds tool hint when booted error occurs without shutdownFirst', async () => { 50 | const bootedError = 51 | 'An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):\nUnable to erase contents and settings in current state: Booted\n'; 52 | const mock = createMockExecutor({ success: false, error: bootedError }); 53 | const res = await erase_simsLogic({ simulatorUdid: 'UD1' }, mock); 54 | expect((res.content?.[1] as any).text).toContain('Tool hint'); 55 | expect((res.content?.[1] as any).text).toContain('shutdownFirst: true'); 56 | }); 57 | 58 | it('performs shutdown first when shutdownFirst=true', async () => { 59 | const calls: any[] = []; 60 | const exec = async (cmd: string[]) => { 61 | calls.push(cmd); 62 | return { success: true, output: 'OK', error: '', process: { pid: 1 } as any }; 63 | }; 64 | const res = await erase_simsLogic({ simulatorUdid: 'UD1', shutdownFirst: true }, exec as any); 65 | expect(calls).toEqual([ 66 | ['xcrun', 'simctl', 'shutdown', 'UD1'], 67 | ['xcrun', 'simctl', 'erase', 'UD1'], 68 | ]); 69 | expect(res).toEqual({ 70 | content: [{ type: 'text', text: 'Successfully erased simulator UD1' }], 71 | }); 72 | }); 73 | }); 74 | 75 | describe('All mode', () => { 76 | it('erases all simulators successfully', async () => { 77 | const exec = createMockExecutor({ success: true, output: 'OK' }); 78 | const res = await erase_simsLogic({ all: true }, exec); 79 | expect(res).toEqual({ 80 | content: [{ type: 'text', text: 'Successfully erased all simulators' }], 81 | }); 82 | }); 83 | 84 | it('returns failure when erase all fails', async () => { 85 | const exec = createMockExecutor({ success: false, error: 'Denied' }); 86 | const res = await erase_simsLogic({ all: true }, exec); 87 | expect(res).toEqual({ 88 | content: [{ type: 'text', text: 'Failed to erase all simulators: Denied' }], 89 | }); 90 | }); 91 | 92 | it('performs shutdown all when shutdownFirst=true', async () => { 93 | const calls: any[] = []; 94 | const exec = async (cmd: string[]) => { 95 | calls.push(cmd); 96 | return { success: true, output: 'OK', error: '', process: { pid: 1 } as any }; 97 | }; 98 | const res = await erase_simsLogic({ all: true, shutdownFirst: true }, exec as any); 99 | expect(calls).toEqual([ 100 | ['xcrun', 'simctl', 'shutdown', 'all'], 101 | ['xcrun', 'simctl', 'erase', 'all'], 102 | ]); 103 | expect(res).toEqual({ 104 | content: [{ type: 'text', text: 'Successfully erased all simulators' }], 105 | }); 106 | }); 107 | 108 | it('adds tool hint on booted error without shutdownFirst (all mode)', async () => { 109 | const bootedError = 'Unable to erase contents and settings in current state: Booted'; 110 | const exec = createMockExecutor({ success: false, error: bootedError }); 111 | const res = await erase_simsLogic({ all: true }, exec); 112 | expect((res.content?.[1] as any).text).toContain('Tool hint'); 113 | expect((res.content?.[1] as any).text).toContain('shutdownFirst: true'); 114 | }); 115 | }); 116 | }); 117 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/type_text.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * UI Testing Plugin: Type Text 3 | * 4 | * Types text into the iOS Simulator using keyboard input. 5 | * Supports standard US keyboard characters. 6 | */ 7 | 8 | import { z } from 'zod'; 9 | import { ToolResponse } from '../../../types/common.ts'; 10 | import { log } from '../../../utils/logging/index.ts'; 11 | import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; 12 | import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; 13 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 14 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 15 | import { 16 | createAxeNotAvailableResponse, 17 | getAxePath, 18 | getBundledAxeEnvironment, 19 | } from '../../../utils/axe-helpers.ts'; 20 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 21 | 22 | const LOG_PREFIX = '[AXe]'; 23 | 24 | // Define schema as ZodObject 25 | const typeTextSchema = z.object({ 26 | simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), 27 | text: z.string().min(1, 'Text cannot be empty'), 28 | }); 29 | 30 | // Use z.infer for type safety 31 | type TypeTextParams = z.infer<typeof typeTextSchema>; 32 | 33 | interface AxeHelpers { 34 | getAxePath: () => string | null; 35 | getBundledAxeEnvironment: () => Record<string, string>; 36 | } 37 | 38 | export async function type_textLogic( 39 | params: TypeTextParams, 40 | executor: CommandExecutor, 41 | axeHelpers?: AxeHelpers, 42 | ): Promise<ToolResponse> { 43 | const toolName = 'type_text'; 44 | 45 | // Params are already validated by the factory, use directly 46 | const { simulatorUuid, text } = params; 47 | const commandArgs = ['type', text]; 48 | 49 | log( 50 | 'info', 51 | `${LOG_PREFIX}/${toolName}: Starting type "${text.substring(0, 20)}..." on ${simulatorUuid}`, 52 | ); 53 | 54 | try { 55 | await executeAxeCommand(commandArgs, simulatorUuid, 'type', executor, axeHelpers); 56 | log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); 57 | return createTextResponse('Text typing simulated successfully.'); 58 | } catch (error) { 59 | log( 60 | 'error', 61 | `${LOG_PREFIX}/${toolName}: Failed - ${error instanceof Error ? error.message : String(error)}`, 62 | ); 63 | if (error instanceof DependencyError) { 64 | return createAxeNotAvailableResponse(); 65 | } else if (error instanceof AxeError) { 66 | return createErrorResponse( 67 | `Failed to simulate text typing: ${error.message}`, 68 | error.axeOutput, 69 | ); 70 | } else if (error instanceof SystemError) { 71 | return createErrorResponse( 72 | `System error executing axe: ${error.message}`, 73 | error.originalError?.stack, 74 | ); 75 | } 76 | return createErrorResponse( 77 | `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, 78 | ); 79 | } 80 | } 81 | 82 | export default { 83 | name: 'type_text', 84 | description: 85 | 'Type text (supports US keyboard characters). Use describe_ui to find text field, tap to focus, then type.', 86 | schema: typeTextSchema.shape, // MCP SDK compatibility 87 | handler: createTypedTool(typeTextSchema, type_textLogic, getDefaultCommandExecutor), // Safe factory 88 | }; 89 | 90 | // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) 91 | async function executeAxeCommand( 92 | commandArgs: string[], 93 | simulatorUuid: string, 94 | commandName: string, 95 | executor: CommandExecutor = getDefaultCommandExecutor(), 96 | axeHelpers?: AxeHelpers, 97 | ): Promise<void> { 98 | // Use provided helpers or defaults 99 | const helpers = axeHelpers ?? { getAxePath, getBundledAxeEnvironment }; 100 | 101 | // Get the appropriate axe binary path 102 | const axeBinary = helpers.getAxePath(); 103 | if (!axeBinary) { 104 | throw new DependencyError('AXe binary not found'); 105 | } 106 | 107 | // Add --udid parameter to all commands 108 | const fullArgs = [...commandArgs, '--udid', simulatorUuid]; 109 | 110 | // Construct the full command array with the axe binary as the first element 111 | const fullCommand = [axeBinary, ...fullArgs]; 112 | 113 | try { 114 | // Determine environment variables for bundled AXe 115 | const axeEnv = axeBinary !== 'axe' ? helpers.getBundledAxeEnvironment() : undefined; 116 | 117 | const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); 118 | 119 | if (!result.success) { 120 | throw new AxeError( 121 | `axe command '${commandName}' failed.`, 122 | commandName, 123 | result.error ?? result.output, 124 | simulatorUuid, 125 | ); 126 | } 127 | 128 | // Check for stderr output in successful commands 129 | if (result.error) { 130 | log( 131 | 'warn', 132 | `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, 133 | ); 134 | } 135 | 136 | // Function now returns void - the calling code creates its own response 137 | } catch (error) { 138 | if (error instanceof Error) { 139 | if (error instanceof AxeError) { 140 | throw error; 141 | } 142 | 143 | // Otherwise wrap it in a SystemError 144 | throw new SystemError(`Failed to execute axe command: ${error.message}`, error); 145 | } 146 | 147 | // For any other type of error 148 | throw new SystemError(`Failed to execute axe command: ${String(error)}`); 149 | } 150 | } 151 | ``` -------------------------------------------------------------------------------- /src/utils/__tests__/typed-tool-factory.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tests for the createTypedTool factory 3 | */ 4 | 5 | import { describe, it, expect } from 'vitest'; 6 | import { z } from 'zod'; 7 | import { createTypedTool } from '../typed-tool-factory.ts'; 8 | import { createMockExecutor } from '../../test-utils/mock-executors.ts'; 9 | import { ToolResponse } from '../../types/common.ts'; 10 | 11 | // Test schema and types 12 | const testSchema = z.object({ 13 | requiredParam: z.string().describe('A required string parameter'), 14 | optionalParam: z.number().optional().describe('An optional number parameter'), 15 | }); 16 | 17 | type TestParams = z.infer<typeof testSchema>; 18 | 19 | // Mock logic function for testing 20 | async function testLogic(params: TestParams): Promise<ToolResponse> { 21 | return { 22 | content: [{ type: 'text', text: `Logic executed with: ${params.requiredParam}` }], 23 | isError: false, 24 | }; 25 | } 26 | 27 | describe('createTypedTool', () => { 28 | describe('Type Safety and Validation', () => { 29 | it('should accept valid parameters and call logic function', async () => { 30 | const mockExecutor = createMockExecutor({ success: true, output: 'test' }); 31 | const handler = createTypedTool(testSchema, testLogic, () => mockExecutor); 32 | 33 | const result = await handler({ 34 | requiredParam: 'valid-value', 35 | optionalParam: 42, 36 | }); 37 | 38 | expect(result.isError).toBe(false); 39 | expect(result.content[0].text).toContain('Logic executed with: valid-value'); 40 | }); 41 | 42 | it('should reject parameters with missing required fields', async () => { 43 | const mockExecutor = createMockExecutor({ success: true, output: 'test' }); 44 | const handler = createTypedTool(testSchema, testLogic, () => mockExecutor); 45 | 46 | const result = await handler({ 47 | // Missing requiredParam 48 | optionalParam: 42, 49 | }); 50 | 51 | expect(result.isError).toBe(true); 52 | expect(result.content[0].text).toContain('Parameter validation failed'); 53 | expect(result.content[0].text).toContain('requiredParam'); 54 | }); 55 | 56 | it('should reject parameters with wrong types', async () => { 57 | const mockExecutor = createMockExecutor({ success: true, output: 'test' }); 58 | const handler = createTypedTool(testSchema, testLogic, () => mockExecutor); 59 | 60 | const result = await handler({ 61 | requiredParam: 123, // Should be string, not number 62 | optionalParam: 42, 63 | }); 64 | 65 | expect(result.isError).toBe(true); 66 | expect(result.content[0].text).toContain('Parameter validation failed'); 67 | expect(result.content[0].text).toContain('requiredParam'); 68 | }); 69 | 70 | it('should accept parameters with only required fields', async () => { 71 | const mockExecutor = createMockExecutor({ success: true, output: 'test' }); 72 | const handler = createTypedTool(testSchema, testLogic, () => mockExecutor); 73 | 74 | const result = await handler({ 75 | requiredParam: 'valid-value', 76 | // optionalParam omitted 77 | }); 78 | 79 | expect(result.isError).toBe(false); 80 | expect(result.content[0].text).toContain('Logic executed with: valid-value'); 81 | }); 82 | 83 | it('should provide detailed validation error messages', async () => { 84 | const mockExecutor = createMockExecutor({ success: true, output: 'test' }); 85 | const handler = createTypedTool(testSchema, testLogic, () => mockExecutor); 86 | 87 | const result = await handler({ 88 | requiredParam: 123, // Wrong type 89 | optionalParam: 'should-be-number', // Wrong type 90 | }); 91 | 92 | expect(result.isError).toBe(true); 93 | const errorText = result.content[0].text; 94 | expect(errorText).toContain('Parameter validation failed'); 95 | expect(errorText).toContain('requiredParam'); 96 | expect(errorText).toContain('optionalParam'); 97 | }); 98 | }); 99 | 100 | describe('Error Handling', () => { 101 | it('should re-throw non-Zod errors from logic function', async () => { 102 | const mockExecutor = createMockExecutor({ success: true, output: 'test' }); 103 | 104 | // Logic function that throws a non-Zod error 105 | async function errorLogic(): Promise<ToolResponse> { 106 | throw new Error('Unexpected error'); 107 | } 108 | 109 | const handler = createTypedTool(testSchema, errorLogic, () => mockExecutor); 110 | 111 | await expect(handler({ requiredParam: 'valid' })).rejects.toThrow('Unexpected error'); 112 | }); 113 | }); 114 | 115 | describe('Executor Integration', () => { 116 | it('should pass the provided executor to logic function', async () => { 117 | const mockExecutor = createMockExecutor({ success: true, output: 'test' }); 118 | 119 | async function executorTestLogic(params: TestParams, executor: any): Promise<ToolResponse> { 120 | // Verify executor is passed correctly 121 | expect(executor).toBe(mockExecutor); 122 | return { 123 | content: [{ type: 'text', text: 'Executor passed correctly' }], 124 | isError: false, 125 | }; 126 | } 127 | 128 | const handler = createTypedTool(testSchema, executorTestLogic, () => mockExecutor); 129 | 130 | const result = await handler({ requiredParam: 'valid' }); 131 | 132 | expect(result.isError).toBe(false); 133 | expect(result.content[0].text).toBe('Executor passed correctly'); 134 | }); 135 | }); 136 | }); 137 | ``` -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Logger Utility - Simple logging implementation for the application 3 | * 4 | * This utility module provides a lightweight logging system that directs log 5 | * messages to stderr rather than stdout, ensuring they don't interfere with 6 | * the MCP protocol communication which uses stdout. 7 | * 8 | * Responsibilities: 9 | * - Formatting log messages with timestamps and level indicators 10 | * - Directing all logs to stderr to avoid MCP protocol interference 11 | * - Supporting different log levels (info, warning, error, debug) 12 | * - Providing a simple, consistent logging interface throughout the application 13 | * - Sending error-level logs to Sentry for monitoring and alerting 14 | * 15 | * While intentionally minimal, this logger provides the essential functionality 16 | * needed for operational monitoring and debugging throughout the application. 17 | * It's used by virtually all other modules for status reporting and error logging. 18 | */ 19 | 20 | import { createRequire } from 'node:module'; 21 | // Note: Removed "import * as Sentry from '@sentry/node'" to prevent native module loading at import time 22 | 23 | const SENTRY_ENABLED = 24 | process.env.SENTRY_DISABLED !== 'true' && process.env.XCODEBUILDMCP_SENTRY_DISABLED !== 'true'; 25 | 26 | // Log levels in order of severity (lower number = more severe) 27 | const LOG_LEVELS = { 28 | emergency: 0, 29 | alert: 1, 30 | critical: 2, 31 | error: 3, 32 | warning: 4, 33 | notice: 5, 34 | info: 6, 35 | debug: 7, 36 | } as const; 37 | 38 | export type LogLevel = keyof typeof LOG_LEVELS; 39 | 40 | /** 41 | * Optional context for logging to control Sentry capture 42 | */ 43 | export interface LogContext { 44 | sentry?: boolean; 45 | } 46 | 47 | // Client-requested log level (null means no filtering) 48 | let clientLogLevel: LogLevel | null = null; 49 | 50 | function isTestEnv(): boolean { 51 | return ( 52 | process.env.VITEST === 'true' || 53 | process.env.NODE_ENV === 'test' || 54 | process.env.XCODEBUILDMCP_SILENCE_LOGS === 'true' 55 | ); 56 | } 57 | 58 | type SentryModule = typeof import('@sentry/node'); 59 | 60 | const require = createRequire(import.meta.url); 61 | let cachedSentry: SentryModule | null = null; 62 | 63 | function loadSentrySync(): SentryModule | null { 64 | if (!SENTRY_ENABLED || isTestEnv()) return null; 65 | if (cachedSentry) return cachedSentry; 66 | try { 67 | cachedSentry = require('@sentry/node') as SentryModule; 68 | return cachedSentry; 69 | } catch { 70 | // If @sentry/node is not installed in some environments, fail silently. 71 | return null; 72 | } 73 | } 74 | 75 | function withSentry(cb: (s: SentryModule) => void): void { 76 | const s = loadSentrySync(); 77 | if (!s) return; 78 | try { 79 | cb(s); 80 | } catch { 81 | // no-op: avoid throwing inside logger 82 | } 83 | } 84 | 85 | if (!SENTRY_ENABLED) { 86 | if (process.env.SENTRY_DISABLED === 'true') { 87 | log('info', 'Sentry disabled due to SENTRY_DISABLED environment variable'); 88 | } else if (process.env.XCODEBUILDMCP_SENTRY_DISABLED === 'true') { 89 | log('info', 'Sentry disabled due to XCODEBUILDMCP_SENTRY_DISABLED environment variable'); 90 | } 91 | } 92 | 93 | /** 94 | * Set the minimum log level for client-requested filtering 95 | * @param level The minimum log level to output 96 | */ 97 | export function setLogLevel(level: LogLevel): void { 98 | clientLogLevel = level; 99 | log('debug', `Log level set to: ${level}`); 100 | } 101 | 102 | /** 103 | * Get the current client-requested log level 104 | * @returns The current log level or null if no filtering is active 105 | */ 106 | export function getLogLevel(): LogLevel | null { 107 | return clientLogLevel; 108 | } 109 | 110 | /** 111 | * Check if a log level should be output based on client settings 112 | * @param level The log level to check 113 | * @returns true if the message should be logged 114 | */ 115 | function shouldLog(level: string): boolean { 116 | // Suppress logging during tests to keep test output clean 117 | if (isTestEnv()) { 118 | return false; 119 | } 120 | 121 | // If no client level set, log everything 122 | if (clientLogLevel === null) { 123 | return true; 124 | } 125 | 126 | // Check if the level is valid 127 | const levelKey = level.toLowerCase() as LogLevel; 128 | if (!(levelKey in LOG_LEVELS)) { 129 | return true; // Log unknown levels 130 | } 131 | 132 | // Only log if the message level is at or above the client's requested level 133 | return LOG_LEVELS[levelKey] <= LOG_LEVELS[clientLogLevel]; 134 | } 135 | 136 | /** 137 | * Log a message with the specified level 138 | * @param level The log level (emergency, alert, critical, error, warning, notice, info, debug) 139 | * @param message The message to log 140 | * @param context Optional context to control Sentry capture and other behavior 141 | */ 142 | export function log(level: string, message: string, context?: LogContext): void { 143 | // Check if we should log this level 144 | if (!shouldLog(level)) { 145 | return; 146 | } 147 | 148 | const timestamp = new Date().toISOString(); 149 | const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; 150 | 151 | // Default: error level goes to Sentry 152 | // But respect explicit override from context 153 | const captureToSentry = SENTRY_ENABLED && (context?.sentry ?? level === 'error'); 154 | 155 | if (captureToSentry) { 156 | withSentry((s) => s.captureMessage(logMessage)); 157 | } 158 | 159 | // It's important to use console.error here to ensure logs don't interfere with MCP protocol communication 160 | // see https://modelcontextprotocol.io/docs/tools/debugging#server-side-logging 161 | console.error(logMessage); 162 | } 163 | ``` -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- ```javascript 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | import prettierPlugin from 'eslint-plugin-prettier'; 4 | 5 | export default [ 6 | eslint.configs.recommended, 7 | ...tseslint.configs.recommended, 8 | { 9 | ignores: ['node_modules/**', 'build/**', 'dist/**', 'coverage/**', 'src/core/generated-plugins.ts', 'src/core/generated-resources.ts'], 10 | }, 11 | { 12 | // TypeScript files in src/ directory (covered by tsconfig.json) 13 | files: ['src/**/*.ts'], 14 | languageOptions: { 15 | ecmaVersion: 2020, 16 | sourceType: 'module', 17 | parser: tseslint.parser, 18 | parserOptions: { 19 | project: ['./tsconfig.json'], 20 | }, 21 | }, 22 | plugins: { 23 | '@typescript-eslint': tseslint.plugin, 24 | 'prettier': prettierPlugin, 25 | }, 26 | rules: { 27 | 'prettier/prettier': 'error', 28 | '@typescript-eslint/explicit-function-return-type': 'warn', 29 | '@typescript-eslint/no-explicit-any': 'error', 30 | '@typescript-eslint/no-unused-vars': ['error', { 31 | argsIgnorePattern: 'never', 32 | varsIgnorePattern: 'never' 33 | }], 34 | 'no-console': ['warn', { allow: ['error'] }], 35 | 36 | // Prevent dangerous type casting anti-patterns (errors) 37 | '@typescript-eslint/consistent-type-assertions': ['error', { 38 | assertionStyle: 'as', 39 | objectLiteralTypeAssertions: 'never' 40 | }], 41 | '@typescript-eslint/no-unsafe-argument': 'error', 42 | '@typescript-eslint/no-unsafe-assignment': 'error', 43 | '@typescript-eslint/no-unsafe-call': 'error', 44 | '@typescript-eslint/no-unsafe-member-access': 'error', 45 | '@typescript-eslint/no-unsafe-return': 'error', 46 | 47 | // Prevent specific anti-patterns we found 48 | '@typescript-eslint/ban-ts-comment': ['error', { 49 | 'ts-expect-error': 'allow-with-description', 50 | 'ts-ignore': true, 51 | 'ts-nocheck': true, 52 | 'ts-check': false, 53 | }], 54 | 55 | // Encourage best practices (warnings - can be gradually fixed) 56 | '@typescript-eslint/prefer-as-const': 'warn', 57 | '@typescript-eslint/prefer-nullish-coalescing': 'warn', 58 | '@typescript-eslint/prefer-optional-chain': 'warn', 59 | 60 | // Prevent barrel imports to maintain architectural improvements 61 | 'no-restricted-imports': ['error', { 62 | patterns: [ 63 | { 64 | group: ['**/utils/index.js', '../utils/index.js', '../../utils/index.js', '../../../utils/index.js', '**/utils/index.ts', '../utils/index.ts', '../../utils/index.ts', '../../../utils/index.ts'], 65 | message: 'Barrel imports from utils/index are prohibited. Use focused facade imports instead (e.g., utils/logging/index.ts, utils/execution/index.ts).' 66 | }, 67 | { 68 | group: ['./**/*.js', '../**/*.js'], 69 | message: 'Import TypeScript files with .ts extension, not .js. This ensures compatibility with native TypeScript runtimes like Bun and Deno. Change .js to .ts in your import path.' 70 | } 71 | ] 72 | }], 73 | }, 74 | }, 75 | { 76 | // JavaScript and TypeScript files outside the main project (scripts/, etc.) 77 | files: ['**/*.{js,ts}'], 78 | ignores: ['src/**/*', '**/*.test.ts'], 79 | languageOptions: { 80 | ecmaVersion: 2020, 81 | sourceType: 'module', 82 | parser: tseslint.parser, 83 | // No project reference for scripts - use standalone parsing 84 | }, 85 | plugins: { 86 | '@typescript-eslint': tseslint.plugin, 87 | 'prettier': prettierPlugin, 88 | }, 89 | rules: { 90 | 'prettier/prettier': 'error', 91 | // Relaxed TypeScript rules for scripts since they're not in the main project 92 | '@typescript-eslint/explicit-function-return-type': 'off', 93 | '@typescript-eslint/no-explicit-any': 'warn', 94 | '@typescript-eslint/no-unused-vars': ['warn', { 95 | argsIgnorePattern: 'never', 96 | varsIgnorePattern: 'never' 97 | }], 98 | 'no-console': 'off', // Scripts are allowed to use console 99 | 100 | // Disable project-dependent rules for scripts 101 | '@typescript-eslint/no-unsafe-argument': 'off', 102 | '@typescript-eslint/no-unsafe-assignment': 'off', 103 | '@typescript-eslint/no-unsafe-call': 'off', 104 | '@typescript-eslint/no-unsafe-member-access': 'off', 105 | '@typescript-eslint/no-unsafe-return': 'off', 106 | '@typescript-eslint/prefer-nullish-coalescing': 'off', 107 | '@typescript-eslint/prefer-optional-chain': 'off', 108 | }, 109 | }, 110 | { 111 | files: ['**/*.test.ts'], 112 | languageOptions: { 113 | parser: tseslint.parser, 114 | parserOptions: { 115 | project: './tsconfig.test.json', 116 | }, 117 | }, 118 | rules: { 119 | '@typescript-eslint/no-explicit-any': 'off', 120 | '@typescript-eslint/no-unused-vars': 'off', 121 | '@typescript-eslint/explicit-function-return-type': 'off', 122 | 'prefer-const': 'off', 123 | 124 | // Relax unsafe rules for tests - tests often need more flexibility 125 | '@typescript-eslint/no-unsafe-argument': 'off', 126 | '@typescript-eslint/no-unsafe-assignment': 'off', 127 | '@typescript-eslint/no-unsafe-call': 'off', 128 | '@typescript-eslint/no-unsafe-member-access': 'off', 129 | '@typescript-eslint/no-unsafe-return': 'off', 130 | }, 131 | }, 132 | ]; 133 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/stop_app_sim.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { ToolResponse } from '../../../types/common.ts'; 3 | import { log } from '../../../utils/logging/index.ts'; 4 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 5 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 6 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; 7 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; 8 | 9 | const baseSchemaObject = z.object({ 10 | simulatorId: z 11 | .string() 12 | .optional() 13 | .describe( 14 | 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', 15 | ), 16 | simulatorName: z 17 | .string() 18 | .optional() 19 | .describe( 20 | "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", 21 | ), 22 | bundleId: z.string().describe("Bundle identifier of the app to stop (e.g., 'com.example.MyApp')"), 23 | }); 24 | 25 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); 26 | 27 | const stopAppSimSchema = baseSchema 28 | .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { 29 | message: 'Either simulatorId or simulatorName is required.', 30 | }) 31 | .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { 32 | message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', 33 | }); 34 | 35 | export type StopAppSimParams = z.infer<typeof stopAppSimSchema>; 36 | 37 | export async function stop_app_simLogic( 38 | params: StopAppSimParams, 39 | executor: CommandExecutor, 40 | ): Promise<ToolResponse> { 41 | let simulatorId = params.simulatorId; 42 | let simulatorDisplayName = simulatorId ?? ''; 43 | 44 | if (params.simulatorName && !simulatorId) { 45 | log('info', `Looking up simulator by name: ${params.simulatorName}`); 46 | 47 | const simulatorListResult = await executor( 48 | ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], 49 | 'List Simulators', 50 | true, 51 | ); 52 | if (!simulatorListResult.success) { 53 | return { 54 | content: [ 55 | { 56 | type: 'text', 57 | text: `Failed to list simulators: ${simulatorListResult.error}`, 58 | }, 59 | ], 60 | isError: true, 61 | }; 62 | } 63 | 64 | const simulatorsData = JSON.parse(simulatorListResult.output) as { 65 | devices: Record<string, Array<{ udid: string; name: string }>>; 66 | }; 67 | 68 | let foundSimulator: { udid: string; name: string } | null = null; 69 | for (const runtime in simulatorsData.devices) { 70 | const devices = simulatorsData.devices[runtime]; 71 | const simulator = devices.find((device) => device.name === params.simulatorName); 72 | if (simulator) { 73 | foundSimulator = simulator; 74 | break; 75 | } 76 | } 77 | 78 | if (!foundSimulator) { 79 | return { 80 | content: [ 81 | { 82 | type: 'text', 83 | text: `Simulator named "${params.simulatorName}" not found. Use list_sims to see available simulators.`, 84 | }, 85 | ], 86 | isError: true, 87 | }; 88 | } 89 | 90 | simulatorId = foundSimulator.udid; 91 | simulatorDisplayName = `"${params.simulatorName}" (${foundSimulator.udid})`; 92 | } 93 | 94 | if (!simulatorId) { 95 | return { 96 | content: [ 97 | { 98 | type: 'text', 99 | text: 'No simulator identifier provided', 100 | }, 101 | ], 102 | isError: true, 103 | }; 104 | } 105 | 106 | log('info', `Stopping app ${params.bundleId} in simulator ${simulatorId}`); 107 | 108 | try { 109 | const command = ['xcrun', 'simctl', 'terminate', simulatorId, params.bundleId]; 110 | const result = await executor(command, 'Stop App in Simulator', true, undefined); 111 | 112 | if (!result.success) { 113 | return { 114 | content: [ 115 | { 116 | type: 'text', 117 | text: `Stop app in simulator operation failed: ${result.error}`, 118 | }, 119 | ], 120 | isError: true, 121 | }; 122 | } 123 | 124 | return { 125 | content: [ 126 | { 127 | type: 'text', 128 | text: `✅ App ${params.bundleId} stopped successfully in simulator ${ 129 | simulatorDisplayName || simulatorId 130 | }`, 131 | }, 132 | ], 133 | }; 134 | } catch (error) { 135 | const errorMessage = error instanceof Error ? error.message : String(error); 136 | log('error', `Error stopping app in simulator: ${errorMessage}`); 137 | return { 138 | content: [ 139 | { 140 | type: 'text', 141 | text: `Stop app in simulator operation failed: ${errorMessage}`, 142 | }, 143 | ], 144 | isError: true, 145 | }; 146 | } 147 | } 148 | 149 | const publicSchemaObject = baseSchemaObject.omit({ 150 | simulatorId: true, 151 | simulatorName: true, 152 | } as const); 153 | 154 | export default { 155 | name: 'stop_app_sim', 156 | description: 'Stops an app running in an iOS simulator.', 157 | schema: publicSchemaObject.shape, 158 | handler: createSessionAwareTool<StopAppSimParams>({ 159 | internalSchema: stopAppSimSchema as unknown as z.ZodType<StopAppSimParams>, 160 | logicFunction: stop_app_simLogic, 161 | getExecutor: getDefaultCommandExecutor, 162 | requirements: [ 163 | { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, 164 | ], 165 | exclusivePairs: [['simulatorId', 'simulatorName']], 166 | }), 167 | }; 168 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/button.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import type { ToolResponse } from '../../../types/common.ts'; 3 | import { log } from '../../../utils/logging/index.ts'; 4 | import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; 5 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 6 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 7 | import { 8 | createAxeNotAvailableResponse, 9 | getAxePath, 10 | getBundledAxeEnvironment, 11 | } from '../../../utils/axe-helpers.ts'; 12 | import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; 13 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 14 | 15 | // Define schema as ZodObject 16 | const buttonSchema = z.object({ 17 | simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), 18 | buttonType: z.enum(['apple-pay', 'home', 'lock', 'side-button', 'siri']), 19 | duration: z.number().min(0, 'Duration must be non-negative').optional(), 20 | }); 21 | 22 | // Use z.infer for type safety 23 | type ButtonParams = z.infer<typeof buttonSchema>; 24 | 25 | export interface AxeHelpers { 26 | getAxePath: () => string | null; 27 | getBundledAxeEnvironment: () => Record<string, string>; 28 | createAxeNotAvailableResponse: () => ToolResponse; 29 | } 30 | 31 | const LOG_PREFIX = '[AXe]'; 32 | 33 | export async function buttonLogic( 34 | params: ButtonParams, 35 | executor: CommandExecutor, 36 | axeHelpers: AxeHelpers = { 37 | getAxePath, 38 | getBundledAxeEnvironment, 39 | createAxeNotAvailableResponse, 40 | }, 41 | ): Promise<ToolResponse> { 42 | const toolName = 'button'; 43 | const { simulatorUuid, buttonType, duration } = params; 44 | const commandArgs = ['button', buttonType]; 45 | if (duration !== undefined) { 46 | commandArgs.push('--duration', String(duration)); 47 | } 48 | 49 | log('info', `${LOG_PREFIX}/${toolName}: Starting ${buttonType} button press on ${simulatorUuid}`); 50 | 51 | try { 52 | await executeAxeCommand(commandArgs, simulatorUuid, 'button', executor, axeHelpers); 53 | log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); 54 | return createTextResponse(`Hardware button '${buttonType}' pressed successfully.`); 55 | } catch (error) { 56 | log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); 57 | if (error instanceof DependencyError) { 58 | return axeHelpers.createAxeNotAvailableResponse(); 59 | } else if (error instanceof AxeError) { 60 | return createErrorResponse( 61 | `Failed to press button '${buttonType}': ${error.message}`, 62 | error.axeOutput, 63 | ); 64 | } else if (error instanceof SystemError) { 65 | return createErrorResponse( 66 | `System error executing axe: ${error.message}`, 67 | error.originalError?.stack, 68 | ); 69 | } 70 | return createErrorResponse( 71 | `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, 72 | ); 73 | } 74 | } 75 | 76 | export default { 77 | name: 'button', 78 | description: 79 | 'Press hardware button on iOS simulator. Supported buttons: apple-pay, home, lock, side-button, siri', 80 | schema: buttonSchema.shape, // MCP SDK compatibility 81 | handler: createTypedTool( 82 | buttonSchema, 83 | (params: ButtonParams, executor: CommandExecutor) => { 84 | return buttonLogic(params, executor, { 85 | getAxePath, 86 | getBundledAxeEnvironment, 87 | createAxeNotAvailableResponse, 88 | }); 89 | }, 90 | getDefaultCommandExecutor, 91 | ), 92 | }; 93 | 94 | // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) 95 | async function executeAxeCommand( 96 | commandArgs: string[], 97 | simulatorUuid: string, 98 | commandName: string, 99 | executor: CommandExecutor = getDefaultCommandExecutor(), 100 | axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, 101 | ): Promise<void> { 102 | // Get the appropriate axe binary path 103 | const axeBinary = axeHelpers.getAxePath(); 104 | if (!axeBinary) { 105 | throw new DependencyError('AXe binary not found'); 106 | } 107 | 108 | // Add --udid parameter to all commands 109 | const fullArgs = [...commandArgs, '--udid', simulatorUuid]; 110 | 111 | // Construct the full command array with the axe binary as the first element 112 | const fullCommand = [axeBinary, ...fullArgs]; 113 | 114 | try { 115 | // Determine environment variables for bundled AXe 116 | const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; 117 | 118 | const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); 119 | 120 | if (!result.success) { 121 | throw new AxeError( 122 | `axe command '${commandName}' failed.`, 123 | commandName, 124 | result.error ?? result.output, 125 | simulatorUuid, 126 | ); 127 | } 128 | 129 | // Check for stderr output in successful commands 130 | if (result.error) { 131 | log( 132 | 'warn', 133 | `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, 134 | ); 135 | } 136 | 137 | // Function now returns void - the calling code creates its own response 138 | } catch (error) { 139 | if (error instanceof Error) { 140 | if (error instanceof AxeError) { 141 | throw error; 142 | } 143 | 144 | // Otherwise wrap it in a SystemError 145 | throw new SystemError(`Failed to execute axe command: ${error.message}`, error); 146 | } 147 | 148 | // For any other type of error 149 | throw new SystemError(`Failed to execute axe command: ${String(error)}`); 150 | } 151 | } 152 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/key_press.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { ToolResponse } from '../../../types/common.ts'; 3 | import { log } from '../../../utils/logging/index.ts'; 4 | import { 5 | createTextResponse, 6 | createErrorResponse, 7 | DependencyError, 8 | AxeError, 9 | SystemError, 10 | } from '../../../utils/responses/index.ts'; 11 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 12 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 13 | import { 14 | createAxeNotAvailableResponse, 15 | getAxePath, 16 | getBundledAxeEnvironment, 17 | } from '../../../utils/axe/index.ts'; 18 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 19 | 20 | // Define schema as ZodObject 21 | const keyPressSchema = z.object({ 22 | simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), 23 | keyCode: z.number().int('HID keycode to press (0-255)').min(0).max(255), 24 | duration: z.number().min(0, 'Duration must be non-negative').optional(), 25 | }); 26 | 27 | // Use z.infer for type safety 28 | type KeyPressParams = z.infer<typeof keyPressSchema>; 29 | 30 | export interface AxeHelpers { 31 | getAxePath: () => string | null; 32 | getBundledAxeEnvironment: () => Record<string, string>; 33 | createAxeNotAvailableResponse: () => ToolResponse; 34 | } 35 | 36 | const LOG_PREFIX = '[AXe]'; 37 | 38 | export async function key_pressLogic( 39 | params: KeyPressParams, 40 | executor: CommandExecutor, 41 | axeHelpers: AxeHelpers = { 42 | getAxePath, 43 | getBundledAxeEnvironment, 44 | createAxeNotAvailableResponse, 45 | }, 46 | ): Promise<ToolResponse> { 47 | const toolName = 'key_press'; 48 | const { simulatorUuid, keyCode, duration } = params; 49 | const commandArgs = ['key', String(keyCode)]; 50 | if (duration !== undefined) { 51 | commandArgs.push('--duration', String(duration)); 52 | } 53 | 54 | log('info', `${LOG_PREFIX}/${toolName}: Starting key press ${keyCode} on ${simulatorUuid}`); 55 | 56 | try { 57 | await executeAxeCommand(commandArgs, simulatorUuid, 'key', executor, axeHelpers); 58 | log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); 59 | return createTextResponse(`Key press (code: ${keyCode}) simulated successfully.`); 60 | } catch (error) { 61 | log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); 62 | if (error instanceof DependencyError) { 63 | return axeHelpers.createAxeNotAvailableResponse(); 64 | } else if (error instanceof AxeError) { 65 | return createErrorResponse( 66 | `Failed to simulate key press (code: ${keyCode}): ${error.message}`, 67 | error.axeOutput, 68 | ); 69 | } else if (error instanceof SystemError) { 70 | return createErrorResponse( 71 | `System error executing axe: ${error.message}`, 72 | error.originalError?.stack, 73 | ); 74 | } 75 | return createErrorResponse( 76 | `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, 77 | ); 78 | } 79 | } 80 | 81 | export default { 82 | name: 'key_press', 83 | description: 84 | 'Press a single key by keycode on the simulator. Common keycodes: 40=Return, 42=Backspace, 43=Tab, 44=Space, 58-67=F1-F10.', 85 | schema: keyPressSchema.shape, // MCP SDK compatibility 86 | handler: createTypedTool( 87 | keyPressSchema, 88 | (params: KeyPressParams, executor: CommandExecutor) => { 89 | return key_pressLogic(params, executor, { 90 | getAxePath, 91 | getBundledAxeEnvironment, 92 | createAxeNotAvailableResponse, 93 | }); 94 | }, 95 | getDefaultCommandExecutor, 96 | ), 97 | }; 98 | 99 | // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) 100 | async function executeAxeCommand( 101 | commandArgs: string[], 102 | simulatorUuid: string, 103 | commandName: string, 104 | executor: CommandExecutor = getDefaultCommandExecutor(), 105 | axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, 106 | ): Promise<void> { 107 | // Get the appropriate axe binary path 108 | const axeBinary = axeHelpers.getAxePath(); 109 | if (!axeBinary) { 110 | throw new DependencyError('AXe binary not found'); 111 | } 112 | 113 | // Add --udid parameter to all commands 114 | const fullArgs = [...commandArgs, '--udid', simulatorUuid]; 115 | 116 | // Construct the full command array with the axe binary as the first element 117 | const fullCommand = [axeBinary, ...fullArgs]; 118 | 119 | try { 120 | // Determine environment variables for bundled AXe 121 | const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; 122 | 123 | const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); 124 | 125 | if (!result.success) { 126 | throw new AxeError( 127 | `axe command '${commandName}' failed.`, 128 | commandName, 129 | result.error ?? result.output, 130 | simulatorUuid, 131 | ); 132 | } 133 | 134 | // Check for stderr output in successful commands 135 | if (result.error) { 136 | log( 137 | 'warn', 138 | `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, 139 | ); 140 | } 141 | 142 | // Function now returns void - the calling code creates its own response 143 | } catch (error) { 144 | if (error instanceof Error) { 145 | if (error instanceof AxeError) { 146 | throw error; 147 | } 148 | 149 | // Otherwise wrap it in a SystemError 150 | throw new SystemError(`Failed to execute axe command: ${error.message}`, error); 151 | } 152 | 153 | // For any other type of error 154 | throw new SystemError(`Failed to execute axe command: ${String(error)}`); 155 | } 156 | } 157 | ``` -------------------------------------------------------------------------------- /src/utils/template-manager.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { join } from 'path'; 2 | import { tmpdir } from 'os'; 3 | import { randomUUID } from 'crypto'; 4 | import { log } from './logger.ts'; 5 | import { iOSTemplateVersion, macOSTemplateVersion } from '../version.ts'; 6 | import { CommandExecutor } from './command.ts'; 7 | import { FileSystemExecutor } from './FileSystemExecutor.ts'; 8 | 9 | /** 10 | * Template manager for downloading and managing project templates 11 | */ 12 | export class TemplateManager { 13 | private static readonly GITHUB_ORG = 'cameroncooke'; 14 | private static readonly IOS_TEMPLATE_REPO = 'XcodeBuildMCP-iOS-Template'; 15 | private static readonly MACOS_TEMPLATE_REPO = 'XcodeBuildMCP-macOS-Template'; 16 | 17 | /** 18 | * Get the template path for a specific platform 19 | * Checks for local override via environment variable first 20 | */ 21 | static async getTemplatePath( 22 | platform: 'iOS' | 'macOS', 23 | commandExecutor: CommandExecutor, 24 | fileSystemExecutor: FileSystemExecutor, 25 | ): Promise<string> { 26 | // Check for local override 27 | const envVar = 28 | platform === 'iOS' ? 'XCODEBUILDMCP_IOS_TEMPLATE_PATH' : 'XCODEBUILDMCP_MACOS_TEMPLATE_PATH'; 29 | 30 | const localPath = process.env[envVar]; 31 | log('debug', `[TemplateManager] Checking env var '${envVar}'. Value: '${localPath}'`); 32 | 33 | if (localPath) { 34 | const pathExists = fileSystemExecutor.existsSync(localPath); 35 | log('debug', `[TemplateManager] Env var set. Path '${localPath}' exists? ${pathExists}`); 36 | if (pathExists) { 37 | const templateSubdir = join(localPath, 'template'); 38 | const subdirExists = fileSystemExecutor.existsSync(templateSubdir); 39 | log( 40 | 'debug', 41 | `[TemplateManager] Checking for subdir '${templateSubdir}'. Exists? ${subdirExists}`, 42 | ); 43 | if (subdirExists) { 44 | log('info', `Using local ${platform} template from: ${templateSubdir}`); 45 | return templateSubdir; 46 | } else { 47 | log('info', `Template directory not found in ${localPath}, using GitHub release`); 48 | } 49 | } 50 | } 51 | 52 | log('debug', '[TemplateManager] Env var not set or path invalid, proceeding to download.'); 53 | // Download from GitHub release 54 | return await this.downloadTemplate(platform, commandExecutor, fileSystemExecutor); 55 | } 56 | 57 | /** 58 | * Download template from GitHub release 59 | */ 60 | private static async downloadTemplate( 61 | platform: 'iOS' | 'macOS', 62 | commandExecutor: CommandExecutor, 63 | fileSystemExecutor: FileSystemExecutor, 64 | ): Promise<string> { 65 | const repo = platform === 'iOS' ? this.IOS_TEMPLATE_REPO : this.MACOS_TEMPLATE_REPO; 66 | const defaultVersion = platform === 'iOS' ? iOSTemplateVersion : macOSTemplateVersion; 67 | const envVarName = 68 | platform === 'iOS' 69 | ? 'XCODEBUILD_MCP_IOS_TEMPLATE_VERSION' 70 | : 'XCODEBUILD_MCP_MACOS_TEMPLATE_VERSION'; 71 | const version = String( 72 | process.env[envVarName] ?? process.env.XCODEBUILD_MCP_TEMPLATE_VERSION ?? defaultVersion, 73 | ); 74 | 75 | // Create temp directory for download 76 | const tempDir = join(tmpdir(), `xcodebuild-mcp-template-${randomUUID()}`); 77 | await fileSystemExecutor.mkdir(tempDir, { recursive: true }); 78 | 79 | try { 80 | const downloadUrl = `https://github.com/${this.GITHUB_ORG}/${repo}/releases/download/${version}/${repo}-${version.substring(1)}.zip`; 81 | const zipPath = join(tempDir, 'template.zip'); 82 | 83 | log('info', `Downloading ${platform} template ${version} from GitHub...`); 84 | log('info', `Download URL: ${downloadUrl}`); 85 | 86 | // Download the release artifact 87 | const curlResult = await commandExecutor( 88 | ['curl', '-L', '-f', '-o', zipPath, downloadUrl], 89 | 'Download Template', 90 | true, 91 | undefined, 92 | ); 93 | 94 | if (!curlResult.success) { 95 | throw new Error(`Failed to download template: ${curlResult.error}`); 96 | } 97 | 98 | // Extract the zip file 99 | // Temporarily change to temp directory for extraction 100 | const originalCwd = process.cwd(); 101 | try { 102 | process.chdir(tempDir); 103 | const unzipResult = await commandExecutor( 104 | ['unzip', '-q', zipPath], 105 | 'Extract Template', 106 | true, 107 | undefined, 108 | ); 109 | 110 | if (!unzipResult.success) { 111 | throw new Error(`Failed to extract template: ${unzipResult.error}`); 112 | } 113 | } finally { 114 | process.chdir(originalCwd); 115 | } 116 | 117 | // Find the extracted directory and return the template subdirectory 118 | const extractedDir = join(tempDir, `${repo}-${version.substring(1)}`); 119 | if (!fileSystemExecutor.existsSync(extractedDir)) { 120 | throw new Error(`Expected template directory not found: ${extractedDir}`); 121 | } 122 | 123 | log('info', `Successfully downloaded ${platform} template ${version}`); 124 | return extractedDir; 125 | } catch (error) { 126 | // Clean up on error 127 | log('error', `Failed to download ${platform} template ${version}: ${error}`); 128 | await this.cleanup(tempDir, fileSystemExecutor); 129 | throw error; 130 | } 131 | } 132 | 133 | /** 134 | * Clean up downloaded template directory 135 | */ 136 | static async cleanup( 137 | templatePath: string, 138 | fileSystemExecutor: FileSystemExecutor, 139 | ): Promise<void> { 140 | // Only clean up if it's in temp directory 141 | if (templatePath.startsWith(tmpdir())) { 142 | await fileSystemExecutor.rm(templatePath, { recursive: true, force: true }); 143 | } 144 | } 145 | } 146 | ``` -------------------------------------------------------------------------------- /docs/ESLINT_TYPE_SAFETY.md: -------------------------------------------------------------------------------- ```markdown 1 | # ESLint Type Safety Rules 2 | 3 | This document explains the ESLint rules added to prevent TypeScript anti-patterns and improve type safety. 4 | 5 | ## Rules Added 6 | 7 | ### Error-Level Rules (Block CI/Deployment) 8 | 9 | These rules prevent dangerous type casting patterns that can lead to runtime errors: 10 | 11 | #### `@typescript-eslint/consistent-type-assertions` 12 | - **Purpose**: Prevents dangerous object literal type assertions 13 | - **Example**: Prevents `{ foo: 'bar' } as ComplexType` 14 | - **Rationale**: Object literal assertions can hide missing properties 15 | 16 | #### `@typescript-eslint/no-unsafe-*` (5 rules) 17 | - **no-unsafe-argument**: Prevents passing `any` to typed parameters 18 | - **no-unsafe-assignment**: Prevents assigning `any` to typed variables 19 | - **no-unsafe-call**: Prevents calling `any` as a function 20 | - **no-unsafe-member-access**: Prevents accessing properties on `any` 21 | - **no-unsafe-return**: Prevents returning `any` from typed functions 22 | 23 | **Example of prevented anti-pattern:** 24 | ```typescript 25 | // ❌ BAD - This would now be an ESLint error 26 | function handleParams(args: Record<string, unknown>) { 27 | const typedParams = args as MyToolParams; // Unsafe casting 28 | return typedParams.someProperty as string; // Unsafe member access 29 | } 30 | 31 | // ✅ GOOD - Proper validation approach 32 | function handleParams(args: Record<string, unknown>) { 33 | const typedParams = MyToolParamsSchema.parse(args); // Runtime validation 34 | return typedParams.someProperty; // Type-safe access 35 | } 36 | ``` 37 | 38 | #### `@typescript-eslint/ban-ts-comment` 39 | - **Purpose**: Prevents unsafe TypeScript comments 40 | - **Blocks**: `@ts-ignore`, `@ts-nocheck` 41 | - **Allows**: `@ts-expect-error` (with description) 42 | 43 | ### Warning-Level Rules (Encourage Best Practices) 44 | 45 | These rules encourage modern TypeScript patterns but don't block builds: 46 | 47 | #### `@typescript-eslint/prefer-nullish-coalescing` 48 | - **Purpose**: Prefer `??` over `||` for default values 49 | - **Example**: `value ?? 'default'` instead of `value || 'default'` 50 | - **Rationale**: More precise handling of falsy values (0, '', false) 51 | 52 | #### `@typescript-eslint/prefer-optional-chain` 53 | - **Purpose**: Prefer `?.` for safe property access 54 | - **Example**: `obj?.prop` instead of `obj && obj.prop` 55 | - **Rationale**: More concise and readable 56 | 57 | #### `@typescript-eslint/prefer-as-const` 58 | - **Purpose**: Prefer `as const` for literal types 59 | - **Example**: `['a', 'b'] as const` instead of `['a', 'b'] as string[]` 60 | 61 | ## Test File Exceptions 62 | 63 | Test files (`.test.ts`) have relaxed rules for flexibility: 64 | - All `no-unsafe-*` rules are disabled 65 | - `no-explicit-any` is disabled 66 | - Tests often need to test error conditions and edge cases 67 | 68 | ## Impact on Codebase 69 | 70 | ### Current Status (Post-Implementation) 71 | - **387 total issues detected** 72 | - **207 errors**: Require fixing for type safety 73 | - **180 warnings**: Can be gradually improved 74 | 75 | ### Gradual Migration Strategy 76 | 77 | 1. **Phase 1** (Immediate): Error-level rules prevent new anti-patterns 78 | 2. **Phase 2** (Ongoing): Gradually fix warning-level violations 79 | 3. **Phase 3** (Future): Consider promoting warnings to errors 80 | 81 | ### Benefits 82 | 83 | 1. **Prevents Regression**: New code can't introduce the anti-patterns we just fixed 84 | 2. **Runtime Safety**: Catches potential runtime errors at compile time 85 | 3. **Code Quality**: Encourages modern TypeScript best practices 86 | 4. **Developer Experience**: Better IDE support and autocomplete 87 | 88 | ## Related Issues Fixed 89 | 90 | These rules prevent the specific anti-patterns identified in PR review: 91 | 92 | 1. **✅ Type Casting in Parameters**: `args as SomeType` patterns now flagged 93 | 2. **✅ Unsafe Property Access**: `params.field as string` patterns prevented 94 | 3. **✅ Missing Validation**: Encourages schema validation over casting 95 | 4. **✅ Return Type Mismatches**: Function signature inconsistencies caught 96 | 5. **✅ Nullish Coalescing**: Promotes safer default value handling 97 | 98 | ## Agent Orchestration for ESLint Fixes 99 | 100 | ### Parallel Agent Strategy 101 | 102 | When fixing ESLint issues across the codebase: 103 | 104 | 1. **Deploy Multiple Agents**: Run agents in parallel on different files 105 | 2. **Single File Focus**: Each agent works on ONE tool file at a time 106 | 3. **Individual Linting**: Agents run `npm run lint path/to/single/file.ts` only 107 | 4. **Immediate Commits**: Commit each agent's work as soon as they complete 108 | 5. **Never Wait**: Don't wait for all agents to finish before committing 109 | 6. **Avoid Full Linting**: Never run `npm run lint` without a file path (eats context) 110 | 7. **Progress Tracking**: Update todo list and periodically check overall status 111 | 8. **Loop Until Done**: Keep deploying agents until all issues are resolved 112 | 113 | ### Example Commands for Agents 114 | 115 | ```bash 116 | # Single file linting (what agents should run) 117 | npm run lint src/mcp/tools/device-project/test_device_proj.ts 118 | 119 | # NOT this (too much context) 120 | npm run lint 121 | ``` 122 | 123 | ### Commit Strategy 124 | 125 | - **Individual commits**: One commit per agent completion 126 | - **Clear messages**: `fix: resolve ESLint errors in tool_name.ts` 127 | - **Never batch**: Don't wait to commit multiple files together 128 | - **Progress preservation**: Each fix is immediately saved 129 | 130 | ## Future Improvements 131 | 132 | Consider adding these rules in future iterations: 133 | 134 | - `@typescript-eslint/strict-boolean-expressions`: Stricter boolean logic 135 | - `@typescript-eslint/prefer-reduce-type-parameter`: Better generic usage 136 | - `@typescript-eslint/switch-exhaustiveness-check`: Complete switch statements ``` -------------------------------------------------------------------------------- /src/mcp/tools/ui-testing/key_sequence.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * UI Testing Plugin: Key Sequence 3 | * 4 | * Press key sequence using HID keycodes on iOS simulator with configurable delay. 5 | */ 6 | 7 | import { z } from 'zod'; 8 | import { ToolResponse } from '../../../types/common.ts'; 9 | import { log } from '../../../utils/logging/index.ts'; 10 | import { 11 | createTextResponse, 12 | createErrorResponse, 13 | DependencyError, 14 | AxeError, 15 | SystemError, 16 | } from '../../../utils/responses/index.ts'; 17 | import type { CommandExecutor } from '../../../utils/execution/index.ts'; 18 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; 19 | import { 20 | createAxeNotAvailableResponse, 21 | getAxePath, 22 | getBundledAxeEnvironment, 23 | } from '../../../utils/axe/index.ts'; 24 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; 25 | 26 | // Define schema as ZodObject 27 | const keySequenceSchema = z.object({ 28 | simulatorUuid: z.string().uuid('Invalid Simulator UUID format'), 29 | keyCodes: z.array(z.number().int().min(0).max(255)).min(1, 'At least one key code required'), 30 | delay: z.number().min(0, 'Delay must be non-negative').optional(), 31 | }); 32 | 33 | // Use z.infer for type safety 34 | type KeySequenceParams = z.infer<typeof keySequenceSchema>; 35 | 36 | export interface AxeHelpers { 37 | getAxePath: () => string | null; 38 | getBundledAxeEnvironment: () => Record<string, string>; 39 | createAxeNotAvailableResponse: () => ToolResponse; 40 | } 41 | 42 | const LOG_PREFIX = '[AXe]'; 43 | 44 | export async function key_sequenceLogic( 45 | params: KeySequenceParams, 46 | executor: CommandExecutor, 47 | axeHelpers: AxeHelpers = { 48 | getAxePath, 49 | getBundledAxeEnvironment, 50 | createAxeNotAvailableResponse, 51 | }, 52 | ): Promise<ToolResponse> { 53 | const toolName = 'key_sequence'; 54 | const { simulatorUuid, keyCodes, delay } = params; 55 | const commandArgs = ['key-sequence', '--keycodes', keyCodes.join(',')]; 56 | if (delay !== undefined) { 57 | commandArgs.push('--delay', String(delay)); 58 | } 59 | 60 | log( 61 | 'info', 62 | `${LOG_PREFIX}/${toolName}: Starting key sequence [${keyCodes.join(',')}] on ${simulatorUuid}`, 63 | ); 64 | 65 | try { 66 | await executeAxeCommand(commandArgs, simulatorUuid, 'key-sequence', executor, axeHelpers); 67 | log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorUuid}`); 68 | return createTextResponse(`Key sequence [${keyCodes.join(',')}] executed successfully.`); 69 | } catch (error) { 70 | log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); 71 | if (error instanceof DependencyError) { 72 | return axeHelpers.createAxeNotAvailableResponse(); 73 | } else if (error instanceof AxeError) { 74 | return createErrorResponse( 75 | `Failed to execute key sequence: ${error.message}`, 76 | error.axeOutput, 77 | ); 78 | } else if (error instanceof SystemError) { 79 | return createErrorResponse( 80 | `System error executing axe: ${error.message}`, 81 | error.originalError?.stack, 82 | ); 83 | } 84 | return createErrorResponse( 85 | `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, 86 | ); 87 | } 88 | } 89 | 90 | export default { 91 | name: 'key_sequence', 92 | description: 'Press key sequence using HID keycodes on iOS simulator with configurable delay', 93 | schema: keySequenceSchema.shape, // MCP SDK compatibility 94 | handler: createTypedTool( 95 | keySequenceSchema, 96 | (params: KeySequenceParams, executor: CommandExecutor) => { 97 | return key_sequenceLogic(params, executor, { 98 | getAxePath, 99 | getBundledAxeEnvironment, 100 | createAxeNotAvailableResponse, 101 | }); 102 | }, 103 | getDefaultCommandExecutor, 104 | ), 105 | }; 106 | 107 | // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) 108 | async function executeAxeCommand( 109 | commandArgs: string[], 110 | simulatorUuid: string, 111 | commandName: string, 112 | executor: CommandExecutor = getDefaultCommandExecutor(), 113 | axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, 114 | ): Promise<void> { 115 | // Get the appropriate axe binary path 116 | const axeBinary = axeHelpers.getAxePath(); 117 | if (!axeBinary) { 118 | throw new DependencyError('AXe binary not found'); 119 | } 120 | 121 | // Add --udid parameter to all commands 122 | const fullArgs = [...commandArgs, '--udid', simulatorUuid]; 123 | 124 | // Construct the full command array with the axe binary as the first element 125 | const fullCommand = [axeBinary, ...fullArgs]; 126 | 127 | try { 128 | // Determine environment variables for bundled AXe 129 | const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; 130 | 131 | const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); 132 | 133 | if (!result.success) { 134 | throw new AxeError( 135 | `axe command '${commandName}' failed.`, 136 | commandName, 137 | result.error ?? result.output, 138 | simulatorUuid, 139 | ); 140 | } 141 | 142 | // Check for stderr output in successful commands 143 | if (result.error) { 144 | log( 145 | 'warn', 146 | `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, 147 | ); 148 | } 149 | 150 | // Function now returns void - the calling code creates its own response 151 | } catch (error) { 152 | if (error instanceof Error) { 153 | if (error instanceof AxeError) { 154 | throw error; 155 | } 156 | 157 | // Otherwise wrap it in a SystemError 158 | throw new SystemError(`Failed to execute axe command: ${error.message}`, error); 159 | } 160 | 161 | // For any other type of error 162 | throw new SystemError(`Failed to execute axe command: ${String(error)}`); 163 | } 164 | } 165 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/simulator/__tests__/record_sim_video.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, afterEach } from 'vitest'; 2 | 3 | // Import the tool and logic 4 | import tool, { record_sim_videoLogic } from '../record_sim_video.ts'; 5 | import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; 6 | 7 | const DUMMY_EXECUTOR: any = (async () => ({ success: true })) as any; // CommandExecutor stub 8 | const VALID_SIM_ID = '00000000-0000-0000-0000-000000000000'; 9 | 10 | afterEach(() => { 11 | vi.restoreAllMocks(); 12 | }); 13 | 14 | describe('record_sim_video tool - validation', () => { 15 | it('errors when start and stop are both true (mutually exclusive)', async () => { 16 | const res = await tool.handler({ 17 | simulatorId: VALID_SIM_ID, 18 | start: true, 19 | stop: true, 20 | } as any); 21 | 22 | expect(res.isError).toBe(true); 23 | const text = (res.content?.[0] as any)?.text ?? ''; 24 | expect(text.toLowerCase()).toContain('mutually exclusive'); 25 | }); 26 | 27 | it('errors when stop=true but outputFile is missing', async () => { 28 | const res = await tool.handler({ 29 | simulatorId: VALID_SIM_ID, 30 | stop: true, 31 | } as any); 32 | 33 | expect(res.isError).toBe(true); 34 | const text = (res.content?.[0] as any)?.text ?? ''; 35 | expect(text.toLowerCase()).toContain('outputfile is required'); 36 | }); 37 | }); 38 | 39 | describe('record_sim_video logic - start behavior', () => { 40 | it('starts with default fps (30) and warns when outputFile is provided on start (ignored)', async () => { 41 | const video: any = { 42 | startSimulatorVideoCapture: async () => ({ 43 | started: true, 44 | sessionId: 'sess-123', 45 | }), 46 | stopSimulatorVideoCapture: async () => ({ 47 | stopped: false, 48 | }), 49 | }; 50 | 51 | // DI for AXe helpers: available and version OK 52 | const axe = { 53 | areAxeToolsAvailable: () => true, 54 | isAxeAtLeastVersion: async () => true, 55 | createAxeNotAvailableResponse: () => ({ 56 | content: [{ type: 'text', text: 'AXe not available' }], 57 | isError: true, 58 | }), 59 | }; 60 | 61 | const fs = createMockFileSystemExecutor(); 62 | 63 | const res = await record_sim_videoLogic( 64 | { 65 | simulatorId: VALID_SIM_ID, 66 | start: true, 67 | // fps omitted to hit default 30 68 | outputFile: '/tmp/ignored.mp4', // should be ignored with a note 69 | } as any, 70 | DUMMY_EXECUTOR, 71 | axe, 72 | video, 73 | fs, 74 | ); 75 | 76 | expect(res.isError).toBe(false); 77 | const texts = (res.content ?? []).map((c: any) => c.text).join('\n'); 78 | 79 | expect(texts).toContain('🎥'); 80 | expect(texts).toMatch(/30\s*fps/i); 81 | expect(texts.toLowerCase()).toContain('outputfile is ignored'); 82 | expect(texts).toContain('Next Steps'); 83 | expect(texts).toContain('stop: true'); 84 | expect(texts).toContain('outputFile'); 85 | }); 86 | }); 87 | 88 | describe('record_sim_video logic - end-to-end stop with rename', () => { 89 | it('stops, parses stdout path, and renames to outputFile', async () => { 90 | const video: any = { 91 | startSimulatorVideoCapture: async () => ({ 92 | started: true, 93 | sessionId: 'sess-abc', 94 | }), 95 | stopSimulatorVideoCapture: async () => ({ 96 | stopped: true, 97 | parsedPath: '/tmp/recorded.mp4', 98 | stdout: 'Saved to /tmp/recorded.mp4', 99 | }), 100 | }; 101 | 102 | const fs = createMockFileSystemExecutor(); 103 | 104 | const axe = { 105 | areAxeToolsAvailable: () => true, 106 | isAxeAtLeastVersion: async () => true, 107 | createAxeNotAvailableResponse: () => ({ 108 | content: [{ type: 'text', text: 'AXe not available' }], 109 | isError: true, 110 | }), 111 | }; 112 | 113 | // Start (not strictly required for stop path, but included to mimic flow) 114 | const startRes = await record_sim_videoLogic( 115 | { 116 | simulatorId: VALID_SIM_ID, 117 | start: true, 118 | } as any, 119 | DUMMY_EXECUTOR, 120 | axe, 121 | video, 122 | fs, 123 | ); 124 | expect(startRes.isError).toBe(false); 125 | 126 | // Stop and rename 127 | const outputFile = '/var/videos/final.mp4'; 128 | const stopRes = await record_sim_videoLogic( 129 | { 130 | simulatorId: VALID_SIM_ID, 131 | stop: true, 132 | outputFile, 133 | } as any, 134 | DUMMY_EXECUTOR, 135 | axe, 136 | video, 137 | fs, 138 | ); 139 | 140 | expect(stopRes.isError).toBe(false); 141 | const texts = (stopRes.content ?? []).map((c: any) => c.text).join('\n'); 142 | expect(texts).toContain('Original file: /tmp/recorded.mp4'); 143 | expect(texts).toContain(`Saved to: ${outputFile}`); 144 | 145 | // _meta should include final saved path 146 | expect((stopRes as any)._meta?.outputFile).toBe(outputFile); 147 | }); 148 | }); 149 | 150 | describe('record_sim_video logic - version gate', () => { 151 | it('errors when AXe version is below 1.1.0', async () => { 152 | const axe = { 153 | areAxeToolsAvailable: () => true, 154 | isAxeAtLeastVersion: async () => false, 155 | createAxeNotAvailableResponse: () => ({ 156 | content: [{ type: 'text', text: 'AXe not available' }], 157 | isError: true, 158 | }), 159 | }; 160 | 161 | const video: any = { 162 | startSimulatorVideoCapture: async () => ({ 163 | started: true, 164 | sessionId: 'sess-xyz', 165 | }), 166 | stopSimulatorVideoCapture: async () => ({ 167 | stopped: true, 168 | }), 169 | }; 170 | 171 | const fs = createMockFileSystemExecutor(); 172 | 173 | const res = await record_sim_videoLogic( 174 | { 175 | simulatorId: VALID_SIM_ID, 176 | start: true, 177 | } as any, 178 | DUMMY_EXECUTOR, 179 | axe, 180 | video, 181 | fs, 182 | ); 183 | 184 | expect(res.isError).toBe(true); 185 | const text = (res.content?.[0] as any)?.text ?? ''; 186 | expect(text).toContain('AXe v1.1.0'); 187 | }); 188 | }); 189 | ```