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