This is page 4 of 12. Use http://codebase.md/cameroncooke/xcodebuildmcp?page={x} to view the full context.
# Directory Structure
```
├── .axe-version
├── .claude
│ └── agents
│ └── xcodebuild-mcp-qa-tester.md
├── .cursor
│ ├── BUGBOT.md
│ └── environment.json
├── .cursorrules
├── .github
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ ├── config.yml
│ │ └── feature_request.yml
│ └── workflows
│ ├── ci.yml
│ ├── README.md
│ ├── release.yml
│ ├── sentry.yml
│ └── stale.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
├── docs
│ ├── CONFIGURATION.md
│ ├── DAP_BACKEND_IMPLEMENTATION_PLAN.md
│ ├── DEBUGGING_ARCHITECTURE.md
│ ├── DEMOS.md
│ ├── dev
│ │ ├── ARCHITECTURE.md
│ │ ├── CODE_QUALITY.md
│ │ ├── CONTRIBUTING.md
│ │ ├── ESLINT_TYPE_SAFETY.md
│ │ ├── MANUAL_TESTING.md
│ │ ├── NODEJS_2025.md
│ │ ├── PLUGIN_DEVELOPMENT.md
│ │ ├── README.md
│ │ ├── RELEASE_PROCESS.md
│ │ ├── RELOADEROO_FOR_XCODEBUILDMCP.md
│ │ ├── RELOADEROO_XCODEBUILDMCP_PRIMER.md
│ │ ├── RELOADEROO.md
│ │ ├── session_management_plan.md
│ │ ├── session-aware-migration-todo.md
│ │ ├── SMITHERY.md
│ │ ├── TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md
│ │ ├── TESTING.md
│ │ └── ZOD_MIGRATION_GUIDE.md
│ ├── DEVICE_CODE_SIGNING.md
│ ├── GETTING_STARTED.md
│ ├── investigations
│ │ ├── issue-154-screenshot-downscaling.md
│ │ ├── issue-163.md
│ │ ├── issue-debugger-attach-stopped.md
│ │ └── issue-describe-ui-empty-after-debugger-resume.md
│ ├── OVERVIEW.md
│ ├── PRIVACY.md
│ ├── README.md
│ ├── SESSION_DEFAULTS.md
│ ├── TOOLS.md
│ └── TROUBLESHOOTING.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
│ │ ├── .gitignore
│ │ ├── 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
│ │ └── MCPTestTests
│ │ └── MCPTestTests.swift
│ └── 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
│ ├── generate-loaders.ts
│ ├── generate-version.ts
│ ├── release.sh
│ ├── tools-cli.ts
│ ├── update-tools-docs.ts
│ └── verify-smithery-bundle.sh
├── server.json
├── smithery.config.js
├── smithery.yaml
├── src
│ ├── core
│ │ ├── __tests__
│ │ │ └── resources.test.ts
│ │ ├── generated-plugins.ts
│ │ ├── generated-resources.ts
│ │ ├── plugin-registry.ts
│ │ ├── plugin-types.ts
│ │ └── resources.ts
│ ├── doctor-cli.ts
│ ├── index.ts
│ ├── mcp
│ │ ├── resources
│ │ │ ├── __tests__
│ │ │ │ ├── devices.test.ts
│ │ │ │ ├── doctor.test.ts
│ │ │ │ ├── session-status.test.ts
│ │ │ │ └── simulators.test.ts
│ │ │ ├── devices.ts
│ │ │ ├── doctor.ts
│ │ │ ├── session-status.ts
│ │ │ └── simulators.ts
│ │ └── tools
│ │ ├── debugging
│ │ │ ├── debug_attach_sim.ts
│ │ │ ├── debug_breakpoint_add.ts
│ │ │ ├── debug_breakpoint_remove.ts
│ │ │ ├── debug_continue.ts
│ │ │ ├── debug_detach.ts
│ │ │ ├── debug_lldb_command.ts
│ │ │ ├── debug_stack.ts
│ │ │ ├── debug_variables.ts
│ │ │ └── index.ts
│ │ ├── 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
│ │ ├── 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
│ │ ├── bootstrap.ts
│ │ └── server.ts
│ ├── smithery.ts
│ ├── test-utils
│ │ └── mock-executors.ts
│ ├── types
│ │ └── common.ts
│ ├── utils
│ │ ├── __tests__
│ │ │ ├── build-utils-suppress-warnings.test.ts
│ │ │ ├── build-utils.test.ts
│ │ │ ├── debugger-simctl.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
│ │ │ └── workflow-selection.test.ts
│ │ ├── axe
│ │ │ └── index.ts
│ │ ├── axe-helpers.ts
│ │ ├── build
│ │ │ └── index.ts
│ │ ├── build-utils.ts
│ │ ├── capabilities.ts
│ │ ├── command.ts
│ │ ├── CommandExecutor.ts
│ │ ├── debugger
│ │ │ ├── __tests__
│ │ │ │ └── debugger-manager-dap.test.ts
│ │ │ ├── backends
│ │ │ │ ├── __tests__
│ │ │ │ │ └── dap-backend.test.ts
│ │ │ │ ├── dap-backend.ts
│ │ │ │ ├── DebuggerBackend.ts
│ │ │ │ └── lldb-cli-backend.ts
│ │ │ ├── dap
│ │ │ │ ├── __tests__
│ │ │ │ │ └── transport-framing.test.ts
│ │ │ │ ├── adapter-discovery.ts
│ │ │ │ ├── transport.ts
│ │ │ │ └── types.ts
│ │ │ ├── debugger-manager.ts
│ │ │ ├── index.ts
│ │ │ ├── simctl.ts
│ │ │ ├── tool-context.ts
│ │ │ ├── types.ts
│ │ │ └── ui-automation-guard.ts
│ │ ├── environment.ts
│ │ ├── errors.ts
│ │ ├── execution
│ │ │ ├── index.ts
│ │ │ └── interactive-process.ts
│ │ ├── FileSystemExecutor.ts
│ │ ├── log_capture.ts
│ │ ├── log-capture
│ │ │ ├── device-log-sessions.ts
│ │ │ └── index.ts
│ │ ├── logger.ts
│ │ ├── logging
│ │ │ └── index.ts
│ │ ├── plugin-registry
│ │ │ └── index.ts
│ │ ├── responses
│ │ │ └── index.ts
│ │ ├── runtime-registry.ts
│ │ ├── schema-helpers.ts
│ │ ├── sentry.ts
│ │ ├── session-status.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
│ │ ├── workflow-selection.ts
│ │ ├── xcode.ts
│ │ ├── xcodemake
│ │ │ └── index.ts
│ │ └── xcodemake.ts
│ └── version.ts
├── tsconfig.json
├── tsconfig.test.json
├── tsconfig.tests.json
├── tsup.config.ts
├── vitest.config.ts
└── XcodeBuildMCP.code-workspace
```
# Files
--------------------------------------------------------------------------------
/src/utils/debugger/dap/transport.ts:
--------------------------------------------------------------------------------
```typescript
import { EventEmitter } from 'node:events';
import type { InteractiveProcess, InteractiveSpawner } from '../../execution/index.ts';
import { log } from '../../logging/index.ts';
import type { DapEvent, DapRequest, DapResponse } from './types.ts';
const DEFAULT_LOG_PREFIX = '[DAP Transport]';
export type DapTransportOptions = {
spawner: InteractiveSpawner;
adapterCommand: string[];
env?: Record<string, string>;
cwd?: string;
logPrefix?: string;
};
type PendingRequest = {
command: string;
resolve: (body: unknown) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
};
export class DapTransport {
private readonly process: InteractiveProcess;
private readonly logPrefix: string;
private readonly pending = new Map<number, PendingRequest>();
private readonly events = new EventEmitter();
private nextSeq = 1;
private buffer = Buffer.alloc(0);
private disposed = false;
private exited = false;
constructor(options: DapTransportOptions) {
this.logPrefix = options.logPrefix ?? DEFAULT_LOG_PREFIX;
this.process = options.spawner(options.adapterCommand, {
env: options.env,
cwd: options.cwd,
});
this.process.process.stdout?.on('data', (data: Buffer) => this.handleStdout(data));
this.process.process.stderr?.on('data', (data: Buffer) => this.handleStderr(data));
this.process.process.on('exit', (code, signal) => this.handleExit(code, signal));
this.process.process.on('error', (error) => this.handleError(error));
}
sendRequest<A, B>(command: string, args?: A, opts?: { timeoutMs?: number }): Promise<B> {
if (this.disposed || this.exited) {
return Promise.reject(new Error('DAP transport is not available'));
}
const seq = this.nextSeq++;
const request: DapRequest<A> = {
seq,
type: 'request',
command,
arguments: args,
};
const payload = JSON.stringify(request);
const message = `Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`;
return new Promise<B>((resolve, reject) => {
const timeoutMs = opts?.timeoutMs ?? 30_000;
const timeout = setTimeout(() => {
this.pending.delete(seq);
reject(new Error(`DAP request timed out after ${timeoutMs}ms (${command})`));
}, timeoutMs);
this.pending.set(seq, {
command,
resolve: (body) => resolve(body as B),
reject,
timeout,
});
try {
this.process.write(message);
} catch (error) {
clearTimeout(timeout);
this.pending.delete(seq);
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
onEvent(handler: (event: DapEvent) => void): () => void {
this.events.on('event', handler);
return () => {
this.events.off('event', handler);
};
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.failAllPending(new Error('DAP transport disposed'));
try {
this.process.dispose();
} catch (error) {
log('debug', `${this.logPrefix} dispose error: ${String(error)}`);
}
}
private handleStdout(data: Buffer): void {
if (this.disposed) return;
this.buffer = Buffer.concat([this.buffer, data]);
this.processBuffer();
}
private handleStderr(data: Buffer): void {
if (this.disposed) return;
const message = data.toString('utf8').trim();
if (!message) return;
log('debug', `${this.logPrefix} stderr: ${message}`);
}
private handleExit(code: number | null, signal: NodeJS.Signals | null): void {
this.exited = true;
const detail = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
this.failAllPending(new Error(`DAP adapter exited (${detail})`));
}
private handleError(error: Error): void {
this.exited = true;
this.failAllPending(new Error(`DAP adapter error: ${error.message}`));
}
private processBuffer(): void {
while (true) {
const headerEnd = this.buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) {
return;
}
const header = this.buffer.slice(0, headerEnd).toString('utf8');
const contentLength = this.parseContentLength(header);
if (contentLength == null) {
log('error', `${this.logPrefix} invalid DAP header: ${header}`);
this.buffer = this.buffer.slice(headerEnd + 4);
continue;
}
const messageStart = headerEnd + 4;
const messageEnd = messageStart + contentLength;
if (this.buffer.length < messageEnd) {
return;
}
const bodyBuffer = this.buffer.slice(messageStart, messageEnd);
this.buffer = this.buffer.slice(messageEnd);
try {
const message = JSON.parse(bodyBuffer.toString('utf8')) as
| DapResponse
| DapEvent
| DapRequest;
this.handleMessage(message);
} catch (error) {
log('error', `${this.logPrefix} failed to parse DAP message: ${String(error)}`);
}
}
}
private handleMessage(message: DapResponse | DapEvent | DapRequest): void {
if (message.type === 'response') {
const pending = this.pending.get(message.request_seq);
if (!pending) {
log('debug', `${this.logPrefix} received response without pending request`);
return;
}
this.pending.delete(message.request_seq);
clearTimeout(pending.timeout);
if (!message.success) {
const detail = message.message ?? 'DAP request failed';
pending.reject(new Error(`${pending.command} failed: ${detail}`));
return;
}
pending.resolve(message.body ?? {});
return;
}
if (message.type === 'event') {
this.events.emit('event', message);
return;
}
log('debug', `${this.logPrefix} ignoring DAP request: ${message.command ?? 'unknown'}`);
}
private parseContentLength(header: string): number | null {
const lines = header.split(/\r?\n/);
for (const line of lines) {
const match = line.match(/^Content-Length:\s*(\d+)/i);
if (match) {
const length = Number(match[1]);
return Number.isFinite(length) ? length : null;
}
}
return null;
}
private failAllPending(error: Error): void {
for (const [seq, pending] of this.pending.entries()) {
clearTimeout(pending.timeout);
pending.reject(error);
this.pending.delete(seq);
}
}
}
```
--------------------------------------------------------------------------------
/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift:
--------------------------------------------------------------------------------
```swift
import Foundation
// MARK: - Calculator Business Logic Service
/// Handles all calculator operations and state management
/// Separated from UI concerns for better testability and modularity
@Observable
public final class CalculatorService {
// MARK: - Public Properties
public private(set) var display: String = "0"
public private(set) var expressionDisplay: String = ""
public private(set) var hasError: Bool = false
// MARK: - Private State
private var currentNumber: Double = 0
private var previousNumber: Double = 0
private var operation: Operation?
private var shouldResetDisplay = false
private var isNewCalculation = true
private var lastOperation: Operation?
private var lastOperand: Double = 0
// MARK: - Operations
public enum Operation: String, CaseIterable, Sendable {
case add = "+"
case subtract = "-"
case multiply = "×"
case divide = "÷"
public func calculate(_ a: Double, _ b: Double) -> Double {
switch self {
case .add: return a + b
case .subtract: return a - b
case .multiply: return a * b
case .divide: return b != 0 ? a / b : 0
}
}
}
public init() {}
// MARK: - Public Interface
public func inputNumber(_ digit: String) {
guard !hasError else { clear(); return }
if shouldResetDisplay || isNewCalculation {
display = digit
shouldResetDisplay = false
isNewCalculation = false
} else if display.count < 12 {
display = display == "0" ? digit : display + digit
}
currentNumber = Double(display) ?? 0
updateExpressionDisplay()
}
/// Inputs a decimal point into the display
public func inputDecimal() {
guard !hasError else {
clear(); return
}
if shouldResetDisplay || isNewCalculation {
display = "0."
shouldResetDisplay = false
isNewCalculation = false
} else if !display.contains("."), display.count < 11 {
display += "."
}
updateExpressionDisplay()
}
public func setOperation(_ op: Operation) {
guard !hasError else { return }
if operation != nil, !shouldResetDisplay {
calculate()
if hasError { return }
}
previousNumber = currentNumber
operation = op
shouldResetDisplay = true
isNewCalculation = false
updateExpressionDisplay()
}
public func calculate() {
guard let op = operation ?? lastOperation else { return }
let operand = (operation != nil) ? currentNumber : lastOperand
#if DEBUG
if op == .add && previousNumber == 21 && operand == 21 {
fatalError("Intentional crash for debugger smoke test")
}
#endif
let result = op.calculate(previousNumber, operand)
// Error handling
if result.isNaN || result.isInfinite {
setError("Cannot divide by zero")
return
}
if abs(result) > 1e12 {
setError("Number too large")
return
}
// Success path
let prevFormatted = formatNumber(previousNumber)
let currFormatted = formatNumber(operand)
display = formatNumber(result)
expressionDisplay = "\(prevFormatted) \(op.rawValue) \(currFormatted) ="
previousNumber = result
if operation != nil {
lastOperand = currentNumber
}
lastOperation = op
operation = nil
currentNumber = result
shouldResetDisplay = true
isNewCalculation = false
}
public func toggleSign() {
guard !hasError, currentNumber != 0 else { return }
currentNumber *= -1
display = formatNumber(currentNumber)
updateExpressionDisplay()
}
public func percentage() {
guard !hasError else { return }
currentNumber /= 100
display = formatNumber(currentNumber)
updateExpressionDisplay()
}
public func clear() {
display = "0"
expressionDisplay = ""
currentNumber = 0
previousNumber = 0
operation = nil
shouldResetDisplay = false
hasError = false
isNewCalculation = true
}
public func deleteLastDigit() {
guard !hasError else { clear(); return }
if shouldResetDisplay || isNewCalculation {
display = "0"
shouldResetDisplay = false
isNewCalculation = false
} else if display.count > 1 {
display.removeLast()
if display == "-" { display = "0" }
} else {
display = "0"
}
currentNumber = Double(display) ?? 0
updateExpressionDisplay()
}
// MARK: - Private Helpers
private func setError(_ message: String) {
hasError = true
display = "Error"
expressionDisplay = message
}
private func updateExpressionDisplay() {
guard !hasError else { return }
if let op = operation {
let prevFormatted = formatNumber(previousNumber)
expressionDisplay = "\(prevFormatted) \(op.rawValue)"
} else if isNewCalculation {
expressionDisplay = ""
}
}
private func formatNumber(_ number: Double) -> String {
guard !number.isNaN && !number.isInfinite else { return "Error" }
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 8
formatter.minimumFractionDigits = 0
// For integers, don't show decimal places
if number == floor(number) && abs(number) < 1e10 {
formatter.maximumFractionDigits = 0
}
// For very small decimals, use scientific notation
if abs(number) < 0.000001 && number != 0 {
formatter.numberStyle = .scientific
formatter.maximumFractionDigits = 2
}
return formatter.string(from: NSNumber(value: number)) ?? "0"
}
}
// MARK: - Testing Support
public extension CalculatorService {
var currentValue: Double { currentNumber }
var previousValue: Double { previousNumber }
var currentOperation: Operation? { operation }
var willResetDisplay: Bool { shouldResetDisplay }
}
```
--------------------------------------------------------------------------------
/src/mcp/tools/debugging/debug_attach_sim.ts:
--------------------------------------------------------------------------------
```typescript
import * as z from 'zod';
import { ToolResponse } from '../../../types/common.ts';
import { log } from '../../../utils/logging/index.ts';
import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts';
import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts';
import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts';
import {
createSessionAwareToolWithContext,
getSessionAwareToolSchemaShape,
} from '../../../utils/typed-tool-factory.ts';
import {
getDefaultDebuggerToolContext,
resolveSimulatorAppPid,
type DebuggerToolContext,
} from '../../../utils/debugger/index.ts';
const baseSchemaObject = z.object({
simulatorId: z
.string()
.optional()
.describe(
'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both',
),
simulatorName: z
.string()
.optional()
.describe(
"Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both",
),
bundleId: z
.string()
.optional()
.describe("Bundle identifier of the app to attach (e.g., 'com.example.MyApp')"),
pid: z.number().int().positive().optional().describe('Process ID to attach directly'),
waitFor: z.boolean().optional().describe('Wait for the process to appear when attaching'),
continueOnAttach: z
.boolean()
.optional()
.default(true)
.describe('Resume execution automatically after attaching (default: true)'),
makeCurrent: z
.boolean()
.optional()
.default(true)
.describe('Set this debug session as the current session (default: true)'),
});
const debugAttachSchema = z.preprocess(
nullifyEmptyStrings,
baseSchemaObject
.refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, {
message: 'Either simulatorId or simulatorName is required.',
})
.refine((val) => !(val.simulatorId && val.simulatorName), {
message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.',
})
.refine((val) => val.bundleId !== undefined || val.pid !== undefined, {
message: 'Provide either bundleId or pid to attach.',
})
.refine((val) => !(val.bundleId && val.pid), {
message: 'bundleId and pid are mutually exclusive. Provide only one.',
}),
);
export type DebugAttachSimParams = z.infer<typeof debugAttachSchema>;
export async function debug_attach_simLogic(
params: DebugAttachSimParams,
ctx: DebuggerToolContext,
): Promise<ToolResponse> {
const { executor, debugger: debuggerManager } = ctx;
const simResult = await determineSimulatorUuid(
{ simulatorId: params.simulatorId, simulatorName: params.simulatorName },
executor,
);
if (simResult.error) {
return simResult.error;
}
const simulatorId = simResult.uuid;
if (!simulatorId) {
return createErrorResponse('Simulator resolution failed', 'Unable to determine simulator UUID');
}
let pid = params.pid;
if (!pid && params.bundleId) {
try {
pid = await resolveSimulatorAppPid({
executor,
simulatorId,
bundleId: params.bundleId,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return createErrorResponse('Failed to resolve simulator PID', message);
}
}
if (!pid) {
return createErrorResponse('Missing PID', 'Unable to resolve process ID to attach');
}
try {
const session = await debuggerManager.createSession({
simulatorId,
pid,
waitFor: params.waitFor,
});
const isCurrent = params.makeCurrent ?? true;
if (isCurrent) {
debuggerManager.setCurrentSession(session.id);
}
const shouldContinue = params.continueOnAttach ?? true;
if (shouldContinue) {
try {
await debuggerManager.resumeSession(session.id);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
try {
await debuggerManager.detachSession(session.id);
} catch (detachError) {
const detachMessage =
detachError instanceof Error ? detachError.message : String(detachError);
log('warn', `Failed to detach debugger session after resume failure: ${detachMessage}`);
}
return createErrorResponse('Failed to resume debugger after attach', message);
}
}
const warningText = simResult.warning ? `⚠️ ${simResult.warning}\n\n` : '';
const currentText = isCurrent
? 'This session is now the current debug session.'
: 'This session is not set as the current session.';
const resumeText = shouldContinue
? 'Execution resumed after attach.'
: 'Execution is paused. Use debug_continue to resume before UI automation.';
const backendLabel = session.backend === 'dap' ? 'DAP debugger' : 'LLDB';
return createTextResponse(
`${warningText}✅ Attached ${backendLabel} to simulator process ${pid} (${simulatorId}).\n\n` +
`Debug session ID: ${session.id}\n` +
`${currentText}\n` +
`${resumeText}\n\n` +
`Next steps:\n` +
`1. debug_breakpoint_add({ debugSessionId: "${session.id}", file: "...", line: 123 })\n` +
`2. debug_continue({ debugSessionId: "${session.id}" })\n` +
`3. debug_stack({ debugSessionId: "${session.id}" })`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log('error', `Failed to attach LLDB: ${message}`);
return createErrorResponse('Failed to attach debugger', message);
}
}
const publicSchemaObject = z.strictObject(
baseSchemaObject.omit({
simulatorId: true,
simulatorName: true,
}).shape,
);
export default {
name: 'debug_attach_sim',
description:
'Attach LLDB to a running iOS simulator app. Provide bundleId or pid, plus simulator defaults.',
schema: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: baseSchemaObject,
}),
handler: createSessionAwareToolWithContext<DebugAttachSimParams, DebuggerToolContext>({
internalSchema: debugAttachSchema as unknown as z.ZodType<DebugAttachSimParams, unknown>,
logicFunction: debug_attach_simLogic,
getContext: getDefaultDebuggerToolContext,
requirements: [
{ oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' },
],
exclusivePairs: [['simulatorId', 'simulatorName']],
}),
};
```
--------------------------------------------------------------------------------
/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Tests for get_sim_app_path plugin (session-aware version)
* Mirrors patterns from other simulator session-aware migrations.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { ChildProcess } from 'child_process';
import * as z from 'zod';
import { createMockExecutor } from '../../../../test-utils/mock-executors.ts';
import { sessionStore } from '../../../../utils/session-store.ts';
import getSimAppPath, { get_sim_app_pathLogic } from '../get_sim_app_path.ts';
import type { CommandExecutor } from '../../../../utils/CommandExecutor.ts';
describe('get_sim_app_path tool', () => {
beforeEach(() => {
sessionStore.clear();
});
describe('Export Field Validation (Literal)', () => {
it('should have correct name', () => {
expect(getSimAppPath.name).toBe('get_sim_app_path');
});
it('should have concise description', () => {
expect(getSimAppPath.description).toBe('Retrieves the built app path for an iOS simulator.');
});
it('should have handler function', () => {
expect(typeof getSimAppPath.handler).toBe('function');
});
it('should expose only platform in public schema', () => {
const schema = z.object(getSimAppPath.schema);
expect(schema.safeParse({ platform: 'iOS Simulator' }).success).toBe(true);
expect(schema.safeParse({}).success).toBe(false);
expect(schema.safeParse({ platform: 'iOS' }).success).toBe(false);
const schemaKeys = Object.keys(getSimAppPath.schema).sort();
expect(schemaKeys).toEqual(['platform']);
});
});
describe('Handler Requirements', () => {
it('should require scheme when not provided', async () => {
const result = await getSimAppPath.handler({
platform: 'iOS Simulator',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('scheme is required');
});
it('should require project or workspace when scheme default exists', async () => {
sessionStore.setDefaults({ scheme: 'MyScheme' });
const result = await getSimAppPath.handler({
platform: 'iOS Simulator',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Provide a project or workspace');
});
it('should require simulator identifier when scheme and project defaults exist', async () => {
sessionStore.setDefaults({
scheme: 'MyScheme',
projectPath: '/path/to/project.xcodeproj',
});
const result = await getSimAppPath.handler({
platform: 'iOS Simulator',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Provide simulatorId or simulatorName');
});
it('should error when both projectPath and workspacePath provided explicitly', async () => {
sessionStore.setDefaults({ scheme: 'MyScheme' });
const result = await getSimAppPath.handler({
platform: 'iOS Simulator',
projectPath: '/path/project.xcodeproj',
workspacePath: '/path/workspace.xcworkspace',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
expect(result.content[0].text).toContain('projectPath');
expect(result.content[0].text).toContain('workspacePath');
});
it('should error when both simulatorId and simulatorName provided explicitly', async () => {
sessionStore.setDefaults({
scheme: 'MyScheme',
workspacePath: '/path/to/workspace.xcworkspace',
});
const result = await getSimAppPath.handler({
platform: 'iOS Simulator',
simulatorId: 'SIM-UUID',
simulatorName: 'iPhone 16',
});
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', () => {
it('should return app path with simulator name destination', async () => {
const callHistory: Array<{
command: string[];
logPrefix?: string;
useShell?: boolean;
opts?: unknown;
}> = [];
const trackingExecutor: CommandExecutor = async (
command,
logPrefix,
useShell,
opts,
): Promise<{
success: boolean;
output: string;
process: ChildProcess;
}> => {
callHistory.push({ command, logPrefix, useShell, opts });
return {
success: true,
output:
' BUILT_PRODUCTS_DIR = /tmp/DerivedData/Build\n FULL_PRODUCT_NAME = MyApp.app\n',
process: { pid: 12345 } as unknown as ChildProcess,
};
};
const result = await get_sim_app_pathLogic(
{
workspacePath: '/path/to/workspace.xcworkspace',
scheme: 'MyScheme',
platform: 'iOS Simulator',
simulatorName: 'iPhone 16',
useLatestOS: true,
},
trackingExecutor,
);
expect(callHistory).toHaveLength(1);
expect(callHistory[0].logPrefix).toBe('Get App Path');
expect(callHistory[0].useShell).toBe(true);
expect(callHistory[0].command).toEqual([
'xcodebuild',
'-showBuildSettings',
'-workspace',
'/path/to/workspace.xcworkspace',
'-scheme',
'MyScheme',
'-configuration',
'Debug',
'-destination',
'platform=iOS Simulator,name=iPhone 16,OS=latest',
]);
expect(result.isError).toBe(false);
expect(result.content[0].text).toContain(
'✅ App path retrieved successfully: /tmp/DerivedData/Build/MyApp.app',
);
});
it('should surface executor failures when build settings cannot be retrieved', async () => {
const mockExecutor = createMockExecutor({
success: false,
error: 'Failed to run xcodebuild',
});
const result = await get_sim_app_pathLogic(
{
projectPath: '/path/to/project.xcodeproj',
scheme: 'MyScheme',
platform: 'iOS Simulator',
simulatorId: 'SIM-UUID',
},
mockExecutor,
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Failed to get app path');
expect(result.content[0].text).toContain('Failed to run xcodebuild');
});
});
});
```
--------------------------------------------------------------------------------
/src/mcp/tools/device/get_device_app_path.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Device Shared Plugin: Get Device App Path (Unified)
*
* Gets the app bundle path for a physical device application (iOS, watchOS, tvOS, visionOS) using either a project or workspace.
* Accepts mutually exclusive `projectPath` or `workspacePath`.
*/
import * as z from 'zod';
import { ToolResponse, XcodePlatform } from '../../../types/common.ts';
import { log } from '../../../utils/logging/index.ts';
import { createTextResponse } from '../../../utils/responses/index.ts';
import type { CommandExecutor } from '../../../utils/execution/index.ts';
import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
import {
createSessionAwareTool,
getSessionAwareToolSchemaShape,
} 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.)'),
platform: z
.enum(['iOS', 'watchOS', 'tvOS', 'visionOS'])
.optional()
.describe('Target platform (defaults to iOS)'),
};
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 getDeviceAppPathSchema = z.preprocess(
nullifyEmptyStrings,
baseSchemaObject
.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 GetDeviceAppPathParams = z.infer<typeof getDeviceAppPathSchema>;
const publicSchemaObject = baseSchemaObject.omit({
projectPath: true,
workspacePath: true,
scheme: true,
configuration: true,
} as const);
export async function get_device_app_pathLogic(
params: GetDeviceAppPathParams,
executor: CommandExecutor,
): Promise<ToolResponse> {
const platformMap = {
iOS: XcodePlatform.iOS,
watchOS: XcodePlatform.watchOS,
tvOS: XcodePlatform.tvOS,
visionOS: XcodePlatform.visionOS,
};
const platform = platformMap[params.platform ?? 'iOS'];
const configuration = params.configuration ?? 'Debug';
log('info', `Getting app path for scheme ${params.scheme} on platform ${platform}`);
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);
// Handle destination based on platform
let destinationString = '';
if (platform === XcodePlatform.iOS) {
destinationString = 'generic/platform=iOS';
} else if (platform === XcodePlatform.watchOS) {
destinationString = 'generic/platform=watchOS';
} else if (platform === XcodePlatform.tvOS) {
destinationString = 'generic/platform=tvOS';
} else if (platform === XcodePlatform.visionOS) {
destinationString = 'generic/platform=visionOS';
} else {
return createTextResponse(`Unsupported platform: ${platform}`, true);
}
command.push('-destination', destinationString);
// Execute the command directly
const result = await executor(command, 'Get App Path', true);
if (!result.success) {
return createTextResponse(`Failed to get app path: ${result.error}`, true);
}
if (!result.output) {
return createTextResponse('Failed to extract build settings output from the result.', 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 createTextResponse(
'Failed to extract app path from build settings. Make sure the app has been built first.',
true,
);
}
const builtProductsDir = builtProductsDirMatch[1].trim();
const fullProductName = fullProductNameMatch[1].trim();
const appPath = `${builtProductsDir}/${fullProductName}`;
const nextStepsText = `Next Steps:
1. Get bundle ID: get_app_bundle_id({ appPath: "${appPath}" })
2. Install app on device: install_app_device({ deviceId: "DEVICE_UDID", appPath: "${appPath}" })
3. Launch app on device: launch_app_device({ deviceId: "DEVICE_UDID", bundleId: "BUNDLE_ID" })`;
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 createTextResponse(`Error retrieving app path: ${errorMessage}`, true);
}
}
export default {
name: 'get_device_app_path',
description: 'Retrieves the built app path for a connected device.',
schema: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: baseSchemaObject,
}),
annotations: {
title: 'Get Device App Path',
readOnlyHint: true,
},
handler: createSessionAwareTool<GetDeviceAppPathParams>({
internalSchema: getDeviceAppPathSchema as unknown as z.ZodType<GetDeviceAppPathParams, unknown>,
logicFunction: get_device_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/video_capture.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Video capture utility for simulator recordings using AXe.
*
* Manages long-running AXe "record-video" processes keyed by simulator UUID.
* It aggregates stdout/stderr to parse the generated MP4 path on stop.
*/
import type { ChildProcess } from 'child_process';
import { log } from './logging/index.ts';
import { getAxePath, getBundledAxeEnvironment } from './axe-helpers.ts';
import type { CommandExecutor } from './execution/index.ts';
type Session = {
process: unknown;
sessionId: string;
startedAt: number;
buffer: string;
ended: boolean;
};
const sessions = new Map<string, Session>();
let signalHandlersAttached = false;
export interface AxeHelpers {
getAxePath: () => string | null;
getBundledAxeEnvironment: () => Record<string, string>;
}
function ensureSignalHandlersAttached(): void {
if (signalHandlersAttached) return;
signalHandlersAttached = true;
const stopAll = (): void => {
for (const [simulatorUuid, sess] of sessions) {
try {
const child = sess.process as ChildProcess | undefined;
child?.kill?.('SIGINT');
} catch {
// ignore
} finally {
sessions.delete(simulatorUuid);
}
}
};
try {
process.on('SIGINT', stopAll);
process.on('SIGTERM', stopAll);
process.on('exit', stopAll);
} catch {
// Non-Node environments may not support process signals; ignore
}
}
function parseLastAbsoluteMp4Path(buffer: string | undefined): string | null {
if (!buffer) return null;
const matches = [...buffer.matchAll(/(\s|^)(\/[^\s'"]+\.mp4)\b/gi)];
if (matches.length === 0) return null;
const last = matches[matches.length - 1];
return last?.[2] ?? null;
}
function createSessionId(simulatorUuid: string): string {
return `${simulatorUuid}:${Date.now()}`;
}
/**
* Start recording video for a simulator using AXe.
*/
export async function startSimulatorVideoCapture(
params: { simulatorUuid: string; fps?: number },
executor: CommandExecutor,
axeHelpers?: AxeHelpers,
): Promise<{ started: boolean; sessionId?: string; warning?: string; error?: string }> {
const simulatorUuid = params.simulatorUuid;
if (!simulatorUuid) {
return { started: false, error: 'simulatorUuid is required' };
}
if (sessions.has(simulatorUuid)) {
return {
started: false,
error: 'A video recording session is already active for this simulator. Stop it first.',
};
}
const helpers = axeHelpers ?? {
getAxePath,
getBundledAxeEnvironment,
};
const axeBinary = helpers.getAxePath();
if (!axeBinary) {
return { started: false, error: 'Bundled AXe binary not found' };
}
const fps = Number.isFinite(params.fps as number) ? Number(params.fps) : 30;
const command = [axeBinary, 'record-video', '--udid', simulatorUuid, '--fps', String(fps)];
const env = helpers.getBundledAxeEnvironment?.() ?? {};
log('info', `Starting AXe video recording for simulator ${simulatorUuid} at ${fps} fps`);
const result = await executor(command, 'Start Simulator Video Capture', true, { env }, true);
if (!result.success || !result.process) {
return {
started: false,
error: result.error ?? 'Failed to start video capture process',
};
}
const child = result.process as ChildProcess;
const session: Session = {
process: child,
sessionId: createSessionId(simulatorUuid),
startedAt: Date.now(),
buffer: '',
ended: false,
};
try {
child.stdout?.on('data', (d: unknown) => {
try {
session.buffer += String(d ?? '');
} catch {
// ignore
}
});
child.stderr?.on('data', (d: unknown) => {
try {
session.buffer += String(d ?? '');
} catch {
// ignore
}
});
} catch {
// ignore stream listener setup failures
}
// Track when the child process naturally ends, so stop can short-circuit
try {
child.once?.('exit', () => {
session.ended = true;
});
child.once?.('close', () => {
session.ended = true;
});
} catch {
// ignore
}
sessions.set(simulatorUuid, session);
ensureSignalHandlersAttached();
return {
started: true,
sessionId: session.sessionId,
warning: fps !== (params.fps ?? 30) ? `FPS coerced to ${fps}` : undefined,
};
}
/**
* Stop recording video for a simulator. Returns aggregated output and parsed MP4 path if found.
*/
export async function stopSimulatorVideoCapture(
params: { simulatorUuid: string },
executor: CommandExecutor,
): Promise<{
stopped: boolean;
sessionId?: string;
stdout?: string;
parsedPath?: string;
error?: string;
}> {
// Mark executor as used to satisfy lint rule
void executor;
const simulatorUuid = params.simulatorUuid;
if (!simulatorUuid) {
return { stopped: false, error: 'simulatorUuid is required' };
}
const session = sessions.get(simulatorUuid);
if (!session) {
return { stopped: false, error: 'No active video recording session for this simulator' };
}
const child = session.process as ChildProcess | undefined;
// Attempt graceful shutdown
try {
child?.kill?.('SIGINT');
} catch {
try {
child?.kill?.();
} catch {
// ignore
}
}
// Wait for process to close (avoid hanging if it already exited)
await new Promise<void>((resolve): void => {
if (!child) return resolve();
// If process has already ended, resolve immediately
const alreadyEnded = (session as Session).ended === true;
const hasExitCode = (child as ChildProcess).exitCode !== null;
const hasSignal = (child as unknown as { signalCode?: string | null }).signalCode != null;
if (alreadyEnded || hasExitCode || hasSignal) {
return resolve();
}
let resolved = false;
const finish = (): void => {
if (!resolved) {
resolved = true;
resolve();
}
};
try {
child.once('close', finish);
child.once('exit', finish);
} catch {
return finish();
}
// Safety timeout to prevent indefinite hangs
setTimeout(finish, 5000);
});
const combinedOutput = session.buffer;
const parsedPath = parseLastAbsoluteMp4Path(combinedOutput) ?? undefined;
sessions.delete(simulatorUuid);
log(
'info',
`Stopped AXe video recording for simulator ${simulatorUuid}. ${parsedPath ? `Detected file: ${parsedPath}` : 'No file detected in output.'}`,
);
return {
stopped: true,
sessionId: session.sessionId,
stdout: combinedOutput,
parsedPath,
};
}
```
--------------------------------------------------------------------------------
/src/mcp/tools/ui-testing/key_sequence.ts:
--------------------------------------------------------------------------------
```typescript
/**
* UI Testing Plugin: Key Sequence
*
* Press key sequence using HID keycodes on iOS simulator with configurable delay.
*/
import * as 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 { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts';
import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts';
import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts';
import {
createAxeNotAvailableResponse,
getAxePath,
getBundledAxeEnvironment,
} from '../../../utils/axe/index.ts';
import {
createSessionAwareTool,
getSessionAwareToolSchemaShape,
} from '../../../utils/typed-tool-factory.ts';
// Define schema as ZodObject
const keySequenceSchema = z.object({
simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }),
keyCodes: z
.array(z.number().int().min(0).max(255))
.min(1, { message: 'At least one key code required' }),
delay: z.number().min(0, { message: 'Delay must be non-negative' }).optional(),
});
// Use z.infer for type safety
type KeySequenceParams = z.infer<typeof keySequenceSchema>;
export interface AxeHelpers {
getAxePath: () => string | null;
getBundledAxeEnvironment: () => Record<string, string>;
createAxeNotAvailableResponse: () => ToolResponse;
}
const LOG_PREFIX = '[AXe]';
export async function key_sequenceLogic(
params: KeySequenceParams,
executor: CommandExecutor,
axeHelpers: AxeHelpers = {
getAxePath,
getBundledAxeEnvironment,
createAxeNotAvailableResponse,
},
debuggerManager: DebuggerManager = getDefaultDebuggerManager(),
): Promise<ToolResponse> {
const toolName = 'key_sequence';
const { simulatorId, keyCodes, delay } = params;
const guard = await guardUiAutomationAgainstStoppedDebugger({
debugger: debuggerManager,
simulatorId,
toolName,
});
if (guard.blockedResponse) return guard.blockedResponse;
const commandArgs = ['key-sequence', '--keycodes', keyCodes.join(',')];
if (delay !== undefined) {
commandArgs.push('--delay', String(delay));
}
log(
'info',
`${LOG_PREFIX}/${toolName}: Starting key sequence [${keyCodes.join(',')}] on ${simulatorId}`,
);
try {
await executeAxeCommand(commandArgs, simulatorId, 'key-sequence', executor, axeHelpers);
log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`);
const message = `Key sequence [${keyCodes.join(',')}] executed successfully.`;
if (guard.warningText) {
return createTextResponse(`${message}\n\n${guard.warningText}`);
}
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 execute key sequence: ${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)}`,
);
}
}
const publicSchemaObject = z.strictObject(
keySequenceSchema.omit({ simulatorId: true } as const).shape,
);
export default {
name: 'key_sequence',
description: 'Press key sequence using HID keycodes on iOS simulator with configurable delay',
schema: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: keySequenceSchema,
}),
annotations: {
title: 'Key Sequence',
destructiveHint: true,
},
handler: createSessionAwareTool<KeySequenceParams>({
internalSchema: keySequenceSchema as unknown as z.ZodType<KeySequenceParams, unknown>,
logicFunction: (params: KeySequenceParams, executor: CommandExecutor) =>
key_sequenceLogic(params, executor, {
getAxePath,
getBundledAxeEnvironment,
createAxeNotAvailableResponse,
}),
getExecutor: getDefaultCommandExecutor,
requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }],
}),
};
// Helper function for executing axe commands (inlined from src/tools/axe/index.ts)
async function executeAxeCommand(
commandArgs: string[],
simulatorId: 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', simulatorId];
// 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 ? { env: axeEnv } : undefined,
);
if (!result.success) {
throw new AxeError(
`axe command '${commandName}' failed.`,
commandName,
result.error ?? result.output,
simulatorId,
);
}
// 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/utils/debugger/debugger-manager.ts:
--------------------------------------------------------------------------------
```typescript
import { v4 as uuidv4 } from 'uuid';
import type { DebuggerBackend } from './backends/DebuggerBackend.ts';
import { createDapBackend } from './backends/dap-backend.ts';
import { createLldbCliBackend } from './backends/lldb-cli-backend.ts';
import type {
BreakpointInfo,
BreakpointSpec,
DebugExecutionState,
DebugSessionInfo,
DebuggerBackendKind,
} from './types.ts';
export type DebuggerBackendFactory = (kind: DebuggerBackendKind) => Promise<DebuggerBackend>;
export class DebuggerManager {
private readonly backendFactory: DebuggerBackendFactory;
private readonly sessions = new Map<
string,
{ info: DebugSessionInfo; backend: DebuggerBackend }
>();
private currentSessionId: string | null = null;
constructor(options: { backendFactory?: DebuggerBackendFactory } = {}) {
this.backendFactory = options.backendFactory ?? defaultBackendFactory;
}
async createSession(opts: {
simulatorId: string;
pid: number;
backend?: DebuggerBackendKind;
waitFor?: boolean;
}): Promise<DebugSessionInfo> {
const backendKind = resolveBackendKind(opts.backend);
const backend = await this.backendFactory(backendKind);
try {
await backend.attach({ pid: opts.pid, simulatorId: opts.simulatorId, waitFor: opts.waitFor });
} catch (error) {
try {
await backend.dispose();
} catch {
// Best-effort cleanup; keep original attach error.
}
throw error;
}
const now = Date.now();
const info: DebugSessionInfo = {
id: uuidv4(),
backend: backendKind,
simulatorId: opts.simulatorId,
pid: opts.pid,
createdAt: now,
lastUsedAt: now,
};
this.sessions.set(info.id, { info, backend });
return info;
}
getSession(id?: string): { info: DebugSessionInfo; backend: DebuggerBackend } | null {
const resolvedId = id ?? this.currentSessionId;
if (!resolvedId) return null;
return this.sessions.get(resolvedId) ?? null;
}
setCurrentSession(id: string): void {
if (!this.sessions.has(id)) {
throw new Error(`Debug session not found: ${id}`);
}
this.currentSessionId = id;
}
getCurrentSessionId(): string | null {
return this.currentSessionId;
}
listSessions(): DebugSessionInfo[] {
return Array.from(this.sessions.values()).map((session) => ({ ...session.info }));
}
findSessionForSimulator(simulatorId: string): DebugSessionInfo | null {
if (!simulatorId) return null;
if (this.currentSessionId) {
const current = this.sessions.get(this.currentSessionId);
if (current?.info.simulatorId === simulatorId) {
return current.info;
}
}
for (const session of this.sessions.values()) {
if (session.info.simulatorId === simulatorId) {
return session.info;
}
}
return null;
}
async detachSession(id?: string): Promise<void> {
const session = this.requireSession(id);
try {
await session.backend.detach();
} finally {
await session.backend.dispose();
this.sessions.delete(session.info.id);
if (this.currentSessionId === session.info.id) {
this.currentSessionId = null;
}
}
}
async disposeAll(): Promise<void> {
await Promise.allSettled(
Array.from(this.sessions.values()).map(async (session) => {
try {
await session.backend.detach();
} catch {
// Best-effort cleanup; detach can fail if the process exited.
} finally {
await session.backend.dispose();
}
}),
);
this.sessions.clear();
this.currentSessionId = null;
}
async addBreakpoint(
id: string | undefined,
spec: BreakpointSpec,
opts?: { condition?: string },
): Promise<BreakpointInfo> {
const session = this.requireSession(id);
const result = await session.backend.addBreakpoint(spec, opts);
this.touch(session.info.id);
return result;
}
async removeBreakpoint(id: string | undefined, breakpointId: number): Promise<string> {
const session = this.requireSession(id);
const result = await session.backend.removeBreakpoint(breakpointId);
this.touch(session.info.id);
return result;
}
async getStack(
id: string | undefined,
opts?: { threadIndex?: number; maxFrames?: number },
): Promise<string> {
const session = this.requireSession(id);
const result = await session.backend.getStack(opts);
this.touch(session.info.id);
return result;
}
async getVariables(id: string | undefined, opts?: { frameIndex?: number }): Promise<string> {
const session = this.requireSession(id);
const result = await session.backend.getVariables(opts);
this.touch(session.info.id);
return result;
}
async getExecutionState(
id: string | undefined,
opts?: { timeoutMs?: number },
): Promise<DebugExecutionState> {
const session = this.requireSession(id);
const result = await session.backend.getExecutionState(opts);
this.touch(session.info.id);
return result;
}
async runCommand(
id: string | undefined,
command: string,
opts?: { timeoutMs?: number },
): Promise<string> {
const session = this.requireSession(id);
const result = await session.backend.runCommand(command, opts);
this.touch(session.info.id);
return result;
}
async resumeSession(id?: string, opts?: { threadId?: number }): Promise<void> {
const session = this.requireSession(id);
await session.backend.resume(opts);
this.touch(session.info.id);
}
private requireSession(id?: string): { info: DebugSessionInfo; backend: DebuggerBackend } {
const session = this.getSession(id);
if (!session) {
throw new Error('No active debug session. Provide debugSessionId or attach first.');
}
return session;
}
private touch(id: string): void {
const session = this.sessions.get(id);
if (!session) return;
session.info.lastUsedAt = Date.now();
}
}
function resolveBackendKind(explicit?: DebuggerBackendKind): DebuggerBackendKind {
if (explicit) return explicit;
const envValue = process.env.XCODEBUILDMCP_DEBUGGER_BACKEND;
if (!envValue) return 'dap';
const normalized = envValue.trim().toLowerCase();
if (normalized === 'lldb-cli' || normalized === 'lldb') return 'lldb-cli';
if (normalized === 'dap') return 'dap';
throw new Error(`Unsupported debugger backend: ${envValue}`);
}
const defaultBackendFactory: DebuggerBackendFactory = async (kind) => {
switch (kind) {
case 'lldb-cli':
return createLldbCliBackend();
case 'dap':
return createDapBackend();
default:
throw new Error(`Unsupported debugger backend: ${kind}`);
}
};
```
--------------------------------------------------------------------------------
/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/simulator/launch_app_sim.ts:
--------------------------------------------------------------------------------
```typescript
import * as 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 { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts';
import {
createSessionAwareTool,
getSessionAwareToolSchemaShape,
} from '../../../utils/typed-tool-factory.ts';
const baseSchemaObject = z.object({
simulatorId: z
.string()
.optional()
.describe(
'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both',
),
simulatorName: z
.string()
.optional()
.describe(
"Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both",
),
bundleId: z
.string()
.describe("Bundle identifier of the app to launch (e.g., 'com.example.MyApp')"),
args: z.array(z.string()).optional().describe('Additional arguments to pass to the app'),
});
const launchAppSimSchema = z.preprocess(
nullifyEmptyStrings,
baseSchemaObject
.refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, {
message: 'Either simulatorId or simulatorName is required.',
})
.refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), {
message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.',
}),
);
export type LaunchAppSimParams = z.infer<typeof launchAppSimSchema>;
export async function launch_app_simLogic(
params: LaunchAppSimParams,
executor: CommandExecutor,
): Promise<ToolResponse> {
let simulatorId = params.simulatorId;
let simulatorDisplayName = simulatorId ?? '';
if (params.simulatorName && !simulatorId) {
log('info', `Looking up simulator by name: ${params.simulatorName}`);
const simulatorListResult = await executor(
['xcrun', 'simctl', 'list', 'devices', 'available', '--json'],
'List Simulators',
true,
);
if (!simulatorListResult.success) {
return {
content: [
{
type: 'text',
text: `Failed to list simulators: ${simulatorListResult.error}`,
},
],
isError: true,
};
}
const simulatorsData = JSON.parse(simulatorListResult.output) as {
devices: Record<string, Array<{ udid: string; name: string }>>;
};
let foundSimulator: { udid: string; name: string } | null = null;
for (const runtime in simulatorsData.devices) {
const devices = simulatorsData.devices[runtime];
const simulator = devices.find((device) => device.name === params.simulatorName);
if (simulator) {
foundSimulator = simulator;
break;
}
}
if (!foundSimulator) {
return {
content: [
{
type: 'text',
text: `Simulator named "${params.simulatorName}" not found. Use list_sims to see available simulators.`,
},
],
isError: true,
};
}
simulatorId = foundSimulator.udid;
simulatorDisplayName = `"${params.simulatorName}" (${foundSimulator.udid})`;
}
if (!simulatorId) {
return {
content: [
{
type: 'text',
text: 'No simulator identifier provided',
},
],
isError: true,
};
}
log('info', `Starting xcrun simctl launch request for simulator ${simulatorId}`);
try {
const getAppContainerCmd = [
'xcrun',
'simctl',
'get_app_container',
simulatorId,
params.bundleId,
'app',
];
const getAppContainerResult = await executor(
getAppContainerCmd,
'Check App Installed',
true,
undefined,
);
if (!getAppContainerResult.success) {
return {
content: [
{
type: 'text',
text: `App is not installed on the simulator. Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`,
},
],
isError: true,
};
}
} catch {
return {
content: [
{
type: 'text',
text: `App is not installed on the simulator (check failed). Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`,
},
],
isError: true,
};
}
try {
const command = ['xcrun', 'simctl', 'launch', simulatorId, params.bundleId];
if (params.args && params.args.length > 0) {
command.push(...params.args);
}
const result = await executor(command, 'Launch App in Simulator', true, undefined);
if (!result.success) {
return {
content: [
{
type: 'text',
text: `Launch app in simulator operation failed: ${result.error}`,
},
],
};
}
const userParamName = params.simulatorId ? 'simulatorId' : 'simulatorName';
const userParamValue = params.simulatorId ?? params.simulatorName ?? simulatorId;
return {
content: [
{
type: 'text',
text: `✅ App launched successfully in simulator ${simulatorDisplayName || simulatorId}.\n\nNext Steps:\n1. To see simulator: open_sim()\n2. Log capture: start_sim_log_cap({ ${userParamName}: "${userParamValue}", bundleId: "${params.bundleId}" })\n With console: start_sim_log_cap({ ${userParamName}: "${userParamValue}", bundleId: "${params.bundleId}", captureConsole: true })\n3. Stop logs: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`,
},
],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log('error', `Error during launch app in simulator operation: ${errorMessage}`);
return {
content: [
{
type: 'text',
text: `Launch app in simulator operation failed: ${errorMessage}`,
},
],
};
}
}
const publicSchemaObject = z.strictObject(
baseSchemaObject.omit({
simulatorId: true,
simulatorName: true,
} as const).shape,
);
export default {
name: 'launch_app_sim',
description: 'Launches an app in an iOS simulator.',
schema: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: baseSchemaObject,
}),
annotations: {
title: 'Launch App Simulator',
destructiveHint: true,
},
handler: createSessionAwareTool<LaunchAppSimParams>({
internalSchema: launchAppSimSchema as unknown as z.ZodType<LaunchAppSimParams, unknown>,
logicFunction: launch_app_simLogic,
getExecutor: getDefaultCommandExecutor,
requirements: [
{ oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' },
],
exclusivePairs: [['simulatorId', 'simulatorName']],
}),
};
```
--------------------------------------------------------------------------------
/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 * as 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,
getSessionAwareToolSchemaShape,
} 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 publicSchemaObject = baseSchemaObject.omit({
projectPath: true,
workspacePath: true,
scheme: true,
configuration: true,
arch: true,
} as const);
const getMacosAppPathSchema = z.preprocess(
nullifyEmptyStrings,
baseSchemaObject
.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: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: baseSchemaObject,
}),
annotations: {
title: 'Get macOS App Path',
readOnlyHint: true,
},
handler: createSessionAwareTool<GetMacosAppPathParams>({
internalSchema: getMacosAppPathSchema as unknown as z.ZodType<GetMacosAppPathParams, unknown>,
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/mcp/tools/simulator/list_sims.ts:
--------------------------------------------------------------------------------
```typescript
import * as 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({ simulatorId: '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' })\n";
responseText +=
"Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).";
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
annotations: {
title: 'List Simulators',
readOnlyHint: true,
},
handler: createTypedTool(listSimsSchema, list_simsLogic, getDefaultCommandExecutor),
};
```
--------------------------------------------------------------------------------
/.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
environment: production
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'
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: npm install -g npm@latest
- 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 Smithery bundle
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"
- 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 }}
### 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 }}
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/log_capture.ts:
--------------------------------------------------------------------------------
```typescript
import * as path from 'path';
import type { ChildProcess } from 'child_process';
import { v4 as uuidv4 } from 'uuid';
import { log } from '../utils/logger.ts';
import {
CommandExecutor,
getDefaultCommandExecutor,
getDefaultFileSystemExecutor,
} from './command.ts';
import { FileSystemExecutor } from './FileSystemExecutor.ts';
/**
* Log file retention policy:
* - Old log files (older than LOG_RETENTION_DAYS) are automatically deleted from the temp directory
* - Cleanup runs on every new log capture start
*/
const LOG_RETENTION_DAYS = 3;
const LOG_FILE_PREFIX = 'xcodemcp_sim_log_';
export interface LogSession {
processes: ChildProcess[];
logFilePath: string;
simulatorUuid: string;
bundleId: string;
}
export const activeLogSessions: Map<string, LogSession> = new Map();
/**
* Start a log capture session for an iOS simulator.
* Returns { sessionId, logFilePath, processes, error? }
*/
export async function startLogCapture(
params: {
simulatorUuid: string;
bundleId: string;
captureConsole?: boolean;
args?: string[];
},
executor: CommandExecutor = getDefaultCommandExecutor(),
fileSystem: FileSystemExecutor = getDefaultFileSystemExecutor(),
): Promise<{ sessionId: string; logFilePath: string; processes: ChildProcess[]; error?: string }> {
// Clean up old logs before starting a new session
await cleanOldLogs(fileSystem);
const { simulatorUuid, bundleId, captureConsole = false, args = [] } = params;
const logSessionId = uuidv4();
const logFileName = `${LOG_FILE_PREFIX}${logSessionId}.log`;
const logFilePath = path.join(fileSystem.tmpdir(), logFileName);
try {
await fileSystem.mkdir(fileSystem.tmpdir(), { recursive: true });
await fileSystem.writeFile(logFilePath, '');
const logStream = fileSystem.createWriteStream(logFilePath, { flags: 'a' });
const processes: ChildProcess[] = [];
logStream.write('\n--- Log capture for bundle ID: ' + bundleId + ' ---\n');
if (captureConsole) {
const launchCommand = [
'xcrun',
'simctl',
'launch',
'--console-pty',
'--terminate-running-process',
simulatorUuid,
bundleId,
];
if (args.length > 0) {
launchCommand.push(...args);
}
const stdoutLogResult = await executor(
launchCommand,
'Console Log Capture',
true, // useShell
undefined, // env
true, // detached - don't wait for this streaming process to complete
);
if (!stdoutLogResult.success) {
return {
sessionId: '',
logFilePath: '',
processes: [],
error: stdoutLogResult.error ?? 'Failed to start console log capture',
};
}
stdoutLogResult.process.stdout?.pipe(logStream);
stdoutLogResult.process.stderr?.pipe(logStream);
processes.push(stdoutLogResult.process);
}
const osLogResult = await executor(
[
'xcrun',
'simctl',
'spawn',
simulatorUuid,
'log',
'stream',
'--level=debug',
'--predicate',
`subsystem == "${bundleId}"`,
],
'OS Log Capture',
true, // useShell
undefined, // env
true, // detached - don't wait for this streaming process to complete
);
if (!osLogResult.success) {
return {
sessionId: '',
logFilePath: '',
processes: [],
error: osLogResult.error ?? 'Failed to start OS log capture',
};
}
osLogResult.process.stdout?.pipe(logStream);
osLogResult.process.stderr?.pipe(logStream);
processes.push(osLogResult.process);
for (const process of processes) {
process.on('close', (code) => {
log('info', `A log capture process for session ${logSessionId} exited with code ${code}.`);
});
}
activeLogSessions.set(logSessionId, {
processes,
logFilePath,
simulatorUuid,
bundleId,
});
log('info', `Log capture started with session ID: ${logSessionId}`);
return { sessionId: logSessionId, logFilePath, processes };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log('error', `Failed to start log capture: ${message}`);
return { sessionId: '', logFilePath: '', processes: [], error: message };
}
}
/**
* Stop a log capture session and retrieve the log content.
*/
export async function stopLogCapture(
logSessionId: string,
fileSystem: FileSystemExecutor = getDefaultFileSystemExecutor(),
): Promise<{ logContent: string; error?: string }> {
const session = activeLogSessions.get(logSessionId);
if (!session) {
log('warning', `Log session not found: ${logSessionId}`);
return { logContent: '', error: `Log capture session not found: ${logSessionId}` };
}
try {
log('info', `Attempting to stop log capture session: ${logSessionId}`);
const logFilePath = session.logFilePath;
for (const process of session.processes) {
if (!process.killed && process.exitCode === null) {
process.kill('SIGTERM');
}
}
activeLogSessions.delete(logSessionId);
log(
'info',
`Log capture session ${logSessionId} stopped. Log file retained at: ${logFilePath}`,
);
if (!fileSystem.existsSync(logFilePath)) {
throw new Error(`Log file not found: ${logFilePath}`);
}
const fileContent = await fileSystem.readFile(logFilePath, 'utf-8');
log('info', `Successfully read log content from ${logFilePath}`);
return { logContent: fileContent };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log('error', `Failed to stop log capture session ${logSessionId}: ${message}`);
return { logContent: '', error: message };
}
}
/**
* Deletes log files older than LOG_RETENTION_DAYS from the temp directory.
* Runs quietly; errors are logged but do not throw.
*/
async function cleanOldLogs(fileSystem: FileSystemExecutor): Promise<void> {
const tempDir = fileSystem.tmpdir();
let files: unknown[];
try {
files = await fileSystem.readdir(tempDir);
} catch (err) {
log(
'warn',
`Could not read temp dir for log cleanup: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
const now = Date.now();
const retentionMs = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
const fileNames = files.filter((file): file is string => typeof file === 'string');
await Promise.all(
fileNames
.filter((f) => f.startsWith(LOG_FILE_PREFIX) && f.endsWith('.log'))
.map(async (f) => {
const filePath = path.join(tempDir, f);
try {
const stat = await fileSystem.stat(filePath);
if (now - stat.mtimeMs > retentionMs) {
await fileSystem.rm(filePath, { force: true });
log('info', `Deleted old log file: ${filePath}`);
}
} catch (err) {
log(
'warn',
`Error during log cleanup for ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}),
);
}
```
--------------------------------------------------------------------------------
/src/mcp/tools/ui-testing/describe_ui.ts:
--------------------------------------------------------------------------------
```typescript
import * as z from 'zod';
import { ToolResponse } from '../../../types/common.ts';
import { log } from '../../../utils/logging/index.ts';
import { 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 { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts';
import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts';
import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts';
import {
createAxeNotAvailableResponse,
getAxePath,
getBundledAxeEnvironment,
} from '../../../utils/axe-helpers.ts';
import {
createSessionAwareTool,
getSessionAwareToolSchemaShape,
} from '../../../utils/typed-tool-factory.ts';
// Define schema as ZodObject
const describeUiSchema = z.object({
simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }),
});
// Use z.infer for type safety
type DescribeUiParams = z.infer<typeof describeUiSchema>;
export interface AxeHelpers {
getAxePath: () => string | null;
getBundledAxeEnvironment: () => Record<string, string>;
createAxeNotAvailableResponse: () => ToolResponse;
}
const LOG_PREFIX = '[AXe]';
// Session tracking for describe_ui warnings (shared across UI tools)
const describeUITimestamps = new Map<string, { timestamp: number; simulatorId: string }>();
function recordDescribeUICall(simulatorId: string): void {
describeUITimestamps.set(simulatorId, {
timestamp: Date.now(),
simulatorId,
});
}
/**
* Core business logic for describe_ui functionality
*/
export async function describe_uiLogic(
params: DescribeUiParams,
executor: CommandExecutor,
axeHelpers: AxeHelpers = {
getAxePath,
getBundledAxeEnvironment,
createAxeNotAvailableResponse,
},
debuggerManager: DebuggerManager = getDefaultDebuggerManager(),
): Promise<ToolResponse> {
const toolName = 'describe_ui';
const { simulatorId } = params;
const commandArgs = ['describe-ui'];
const guard = await guardUiAutomationAgainstStoppedDebugger({
debugger: debuggerManager,
simulatorId,
toolName,
});
if (guard.blockedResponse) return guard.blockedResponse;
log('info', `${LOG_PREFIX}/${toolName}: Starting for ${simulatorId}`);
try {
const responseText = await executeAxeCommand(
commandArgs,
simulatorId,
'describe-ui',
executor,
axeHelpers,
);
// Record the describe_ui call for warning system
recordDescribeUICall(simulatorId);
log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`);
const response: ToolResponse = {
content: [
{
type: 'text',
text:
'Accessibility hierarchy retrieved successfully:\n```json\n' + responseText + '\n```',
},
{
type: 'text',
text: `Next Steps:
- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)
- Re-run describe_ui after layout changes
- If a debugger is attached, ensure the app is running (not stopped on breakpoints)
- Screenshots are for visual verification only`,
},
],
};
if (guard.warningText) {
response.content.push({ type: 'text', text: guard.warningText });
}
return response;
} 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 get accessibility hierarchy: ${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)}`,
);
}
}
const publicSchemaObject = z.strictObject(
describeUiSchema.omit({ simulatorId: true } as const).shape,
);
export default {
name: 'describe_ui',
description:
'Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation. Requires the target process to be running; paused debugger/breakpoints can yield an empty tree.',
schema: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: describeUiSchema,
}),
annotations: {
title: 'Describe UI',
readOnlyHint: true,
},
handler: createSessionAwareTool<DescribeUiParams>({
internalSchema: describeUiSchema as unknown as z.ZodType<DescribeUiParams, unknown>,
logicFunction: (params: DescribeUiParams, executor: CommandExecutor) =>
describe_uiLogic(params, executor, {
getAxePath,
getBundledAxeEnvironment,
createAxeNotAvailableResponse,
}),
getExecutor: getDefaultCommandExecutor,
requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }],
}),
};
// Helper function for executing axe commands (inlined from src/tools/axe/index.ts)
async function executeAxeCommand(
commandArgs: string[],
simulatorId: string,
commandName: string,
executor: CommandExecutor = getDefaultCommandExecutor(),
axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse },
): Promise<string> {
// 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', simulatorId];
// 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 ? { env: axeEnv } : undefined,
);
if (!result.success) {
throw new AxeError(
`axe command '${commandName}' failed.`,
commandName,
result.error ?? result.output,
simulatorId,
);
}
// 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}`,
);
}
return result.output.trim();
} 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/record_sim_video.ts:
--------------------------------------------------------------------------------
```typescript
import * as 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,
getSessionAwareToolSchemaShape,
} from '../../../utils/typed-tool-factory.ts';
import { dirname } from 'path';
// Base schema object (used for MCP schema exposure)
const recordSimVideoSchemaObject = z.object({
simulatorId: z
.uuid({ message: '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 = z.strictObject(
recordSimVideoSchemaObject.omit({ simulatorId: true } as const).shape,
);
export default {
name: 'record_sim_video',
description: 'Starts or stops video capture for an iOS simulator.',
schema: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: recordSimVideoSchemaObject,
}),
annotations: {
title: 'Record Simulator Video',
destructiveHint: true,
},
handler: createSessionAwareTool<RecordSimVideoParams>({
internalSchema: recordSimVideoSchema as unknown as z.ZodType<RecordSimVideoParams, unknown>,
logicFunction: record_sim_videoLogic,
getExecutor: getDefaultCommandExecutor,
requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }],
}),
};
```
--------------------------------------------------------------------------------
/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Tests for sim_statusbar plugin
* Following CLAUDE.md testing standards with literal validation
* Using dependency injection for deterministic testing
*/
import { describe, it, expect } from 'vitest';
import * as z from 'zod';
import {
createMockCommandResponse,
createMockExecutor,
type CommandExecutor,
} from '../../../../test-utils/mock-executors.ts';
import simStatusbar, { sim_statusbarLogic } from '../sim_statusbar.ts';
describe('sim_statusbar tool', () => {
describe('Export Field Validation (Literal)', () => {
it('should have correct name', () => {
expect(simStatusbar.name).toBe('sim_statusbar');
});
it('should have correct description', () => {
expect(simStatusbar.description).toBe(
'Sets the data network indicator in the iOS simulator status bar. Use "clear" to reset all overrides, or specify a network type (hide, wifi, 3g, 4g, lte, lte-a, lte+, 5g, 5g+, 5g-uwb, 5g-uc).',
);
});
it('should have handler function', () => {
expect(typeof simStatusbar.handler).toBe('function');
});
it('should expose public schema without simulatorId field', () => {
const schema = z.object(simStatusbar.schema);
expect(schema.safeParse({ dataNetwork: 'wifi' }).success).toBe(true);
expect(schema.safeParse({ dataNetwork: 'clear' }).success).toBe(true);
expect(schema.safeParse({ dataNetwork: 'invalid' }).success).toBe(false);
const withSimId = schema.safeParse({ simulatorId: 'test-uuid', dataNetwork: 'wifi' });
expect(withSimId.success).toBe(true);
expect('simulatorId' in (withSimId.data as any)).toBe(false);
});
});
describe('Handler Behavior (Complete Literal Returns)', () => {
it('should handle successful status bar data network setting', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Status bar set successfully',
});
const result = await sim_statusbarLogic(
{
simulatorId: 'test-uuid-123',
dataNetwork: 'wifi',
},
mockExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Successfully set simulator test-uuid-123 status bar data network to wifi',
},
],
});
});
it('should handle minimal valid parameters (Zod handles validation)', async () => {
// Note: With createTypedTool, Zod validation happens before the logic function is called
// So we test with a valid minimal parameter set since validation is handled upstream
const mockExecutor = createMockExecutor({
success: true,
output: 'Status bar set successfully',
});
const result = await sim_statusbarLogic(
{
simulatorId: 'test-uuid-123',
dataNetwork: 'wifi',
},
mockExecutor,
);
// The logic function should execute normally with valid parameters
// Zod validation errors are handled by createTypedTool wrapper
expect(result.isError).toBe(undefined);
expect(result.content[0].text).toContain('Successfully set simulator');
});
it('should handle command failure', async () => {
const mockExecutor = createMockExecutor({
success: false,
error: 'Simulator not found',
});
const result = await sim_statusbarLogic(
{
simulatorId: 'invalid-uuid',
dataNetwork: '3g',
},
mockExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Failed to set status bar: Simulator not found',
},
],
isError: true,
});
});
it('should handle exception with Error object', async () => {
const mockExecutor: CommandExecutor = async () => {
throw new Error('Connection failed');
};
const result = await sim_statusbarLogic(
{
simulatorId: 'test-uuid-123',
dataNetwork: '4g',
},
mockExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Failed to set status bar: Connection failed',
},
],
isError: true,
});
});
it('should handle exception with string error', async () => {
const mockExecutor: CommandExecutor = async () => {
throw 'String error';
};
const result = await sim_statusbarLogic(
{
simulatorId: 'test-uuid-123',
dataNetwork: 'lte',
},
mockExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Failed to set status bar: String error',
},
],
isError: true,
});
});
it('should verify command generation with mock executor for override', async () => {
const calls: Array<{
command: string[];
operationDescription?: string;
keepAlive?: boolean;
opts?: { cwd?: string };
}> = [];
const mockExecutor: CommandExecutor = async (
command,
operationDescription,
keepAlive,
opts,
detached,
) => {
calls.push({ command, operationDescription, keepAlive, opts });
void detached;
return createMockCommandResponse({
success: true,
output: 'Status bar set successfully',
error: undefined,
});
};
await sim_statusbarLogic(
{
simulatorId: 'test-uuid-123',
dataNetwork: 'wifi',
},
mockExecutor,
);
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({
command: [
'xcrun',
'simctl',
'status_bar',
'test-uuid-123',
'override',
'--dataNetwork',
'wifi',
],
operationDescription: 'Set Status Bar',
keepAlive: true,
opts: undefined,
});
});
it('should verify command generation for clear operation', async () => {
const calls: Array<{
command: string[];
operationDescription?: string;
keepAlive?: boolean;
opts?: { cwd?: string };
}> = [];
const mockExecutor: CommandExecutor = async (
command,
operationDescription,
keepAlive,
opts,
detached,
) => {
calls.push({ command, operationDescription, keepAlive, opts });
void detached;
return createMockCommandResponse({
success: true,
output: 'Status bar cleared successfully',
error: undefined,
});
};
await sim_statusbarLogic(
{
simulatorId: 'test-uuid-123',
dataNetwork: 'clear',
},
mockExecutor,
);
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({
command: ['xcrun', 'simctl', 'status_bar', 'test-uuid-123', 'clear'],
operationDescription: 'Set Status Bar',
keepAlive: true,
opts: undefined,
});
});
it('should handle successful clear operation', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: 'Status bar cleared successfully',
});
const result = await sim_statusbarLogic(
{
simulatorId: 'test-uuid-123',
dataNetwork: 'clear',
},
mockExecutor,
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Successfully cleared status bar overrides for simulator test-uuid-123',
},
],
});
});
});
});
```
--------------------------------------------------------------------------------
/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/__tests__/session-aware-tool-factory.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import * as 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('uses opt-out messaging when session defaults schema is disabled', async () => {
const original = process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS;
process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS = 'true';
try {
const result = await handler({ projectPath: '/p.xcodeproj', simulatorId: 'SIM-1' });
expect(result.isError).toBe(true);
const text = result.content[0].text;
expect(text).toContain('Missing required parameters');
expect(text).toContain('scheme is required');
expect(text).not.toContain('session defaults');
} finally {
if (original === undefined) {
delete process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS;
} else {
process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS = original;
}
}
});
it('should surface Zod validation errors 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');
});
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/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/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 * as 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 { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts';
import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts';
import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts';
import {
createAxeNotAvailableResponse,
getAxePath,
getBundledAxeEnvironment,
} from '../../../utils/axe/index.ts';
import {
createSessionAwareTool,
getSessionAwareToolSchemaShape,
} from '../../../utils/typed-tool-factory.ts';
// Define schema as ZodObject
const longPressSchema = z.object({
simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }),
x: z.number().int({ message: 'X coordinate for the long press' }),
y: z.number().int({ message: 'Y coordinate for the long press' }),
duration: z.number().positive({ message: 'Duration of the long press in milliseconds' }),
});
// Use z.infer for type safety
type LongPressParams = z.infer<typeof longPressSchema>;
const publicSchemaObject = z.strictObject(
longPressSchema.omit({ simulatorId: true } as const).shape,
);
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,
},
debuggerManager: DebuggerManager = getDefaultDebuggerManager(),
): Promise<ToolResponse> {
const toolName = 'long_press';
const { simulatorId, x, y, duration } = params;
const guard = await guardUiAutomationAgainstStoppedDebugger({
debugger: debuggerManager,
simulatorId,
toolName,
});
if (guard.blockedResponse) return guard.blockedResponse;
// 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 ${simulatorId}`,
);
try {
await executeAxeCommand(commandArgs, simulatorId, 'touch', executor, axeHelpers);
log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`);
const coordinateWarning = getCoordinateWarning(simulatorId);
const message = `Long press at (${x}, ${y}) for ${duration}ms simulated successfully.`;
const warnings = [guard.warningText, coordinateWarning].filter(Boolean).join('\n\n');
if (warnings) {
return createTextResponse(`${message}\n\n${warnings}`);
}
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: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: longPressSchema,
}),
annotations: {
title: 'Long Press',
destructiveHint: true,
},
handler: createSessionAwareTool<LongPressParams>({
internalSchema: longPressSchema as unknown as z.ZodType<LongPressParams, unknown>,
logicFunction: (params: LongPressParams, executor: CommandExecutor) =>
long_pressLogic(params, executor, {
getAxePath,
getBundledAxeEnvironment,
createAxeNotAvailableResponse,
}),
getExecutor: getDefaultCommandExecutor,
requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }],
}),
};
// Session tracking for describe_ui warnings
interface DescribeUISession {
timestamp: number;
simulatorId: string;
}
const describeUITimestamps = new Map<string, DescribeUISession>();
const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds
function getCoordinateWarning(simulatorId: string): string | null {
const session = describeUITimestamps.get(simulatorId);
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[],
simulatorId: 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', simulatorId];
// 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 ? { env: axeEnv } : undefined,
);
if (!result.success) {
throw new AxeError(
`axe command '${commandName}' failed.`,
commandName,
result.error ?? result.output,
simulatorId,
);
}
// 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 * as 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 { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts';
import type { DebuggerManager } from '../../../utils/debugger/debugger-manager.ts';
import { guardUiAutomationAgainstStoppedDebugger } from '../../../utils/debugger/ui-automation-guard.ts';
import {
createAxeNotAvailableResponse,
getAxePath,
getBundledAxeEnvironment,
} from '../../../utils/axe-helpers.ts';
import { ToolResponse } from '../../../types/common.ts';
import {
createSessionAwareTool,
getSessionAwareToolSchemaShape,
} from '../../../utils/typed-tool-factory.ts';
// Define schema as ZodObject
const touchSchema = z.object({
simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }),
x: z.number().int({ message: 'X coordinate must be an integer' }),
y: z.number().int({ message: 'Y coordinate must be an integer' }),
down: z.boolean().optional(),
up: z.boolean().optional(),
delay: z.number().min(0, { message: 'Delay must be non-negative' }).optional(),
});
// Use z.infer for type safety
type TouchParams = z.infer<typeof touchSchema>;
const publicSchemaObject = z.strictObject(touchSchema.omit({ simulatorId: true } as const).shape);
interface AxeHelpers {
getAxePath: () => string | null;
getBundledAxeEnvironment: () => Record<string, string>;
}
const LOG_PREFIX = '[AXe]';
export async function touchLogic(
params: TouchParams,
executor: CommandExecutor,
axeHelpers?: AxeHelpers,
debuggerManager: DebuggerManager = getDefaultDebuggerManager(),
): Promise<ToolResponse> {
const toolName = 'touch';
// Params are already validated by createTypedTool - use directly
const { simulatorId, 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 guard = await guardUiAutomationAgainstStoppedDebugger({
debugger: debuggerManager,
simulatorId,
toolName,
});
if (guard.blockedResponse) return guard.blockedResponse;
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 ${simulatorId}`,
);
try {
await executeAxeCommand(commandArgs, simulatorId, 'touch', executor, axeHelpers);
log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`);
const coordinateWarning = getCoordinateWarning(simulatorId);
const message = `Touch event (${actionText}) at (${x}, ${y}) executed successfully.`;
const warnings = [guard.warningText, coordinateWarning].filter(Boolean).join('\n\n');
if (warnings) {
return createTextResponse(`${message}\n\n${warnings}`);
}
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: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: touchSchema,
}),
annotations: {
title: 'Touch',
destructiveHint: true,
},
handler: createSessionAwareTool<TouchParams>({
internalSchema: touchSchema as unknown as z.ZodType<TouchParams, unknown>,
logicFunction: (params: TouchParams, executor: CommandExecutor) => touchLogic(params, executor),
getExecutor: getDefaultCommandExecutor,
requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }],
}),
};
// Session tracking for describe_ui warnings
interface DescribeUISession {
timestamp: number;
simulatorId: string;
}
const describeUITimestamps = new Map<string, DescribeUISession>();
const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds
function getCoordinateWarning(simulatorId: string): string | null {
const session = describeUITimestamps.get(simulatorId);
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[],
simulatorId: 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', simulatorId];
// 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 ? { env: axeEnv } : undefined,
);
if (!result.success) {
throw new AxeError(
`axe command '${commandName}' failed.`,
commandName,
result.error ?? result.output,
simulatorId,
);
}
// 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/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 { createWriteStream, 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);
},
createWriteStream(path: string, options?: { flags?: string }) {
return createWriteStream(path, options);
},
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; mtimeMs: number }> {
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/dev/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/dev/TESTING.md for proper testing patterns.`,
);
}
return defaultFileSystemExecutor;
}
```
--------------------------------------------------------------------------------
/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();
```
--------------------------------------------------------------------------------
/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import * as z from 'zod';
import {
createMockExecutor,
createMockCommandResponse,
} from '../../../../test-utils/mock-executors.ts';
import type { CommandExecutor } from '../../../../utils/execution/index.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']);
const withSessionDefaults = schema.safeParse({
simulatorId: 'SIM-UUID',
simulatorName: 'iPhone 16',
bundleId: 'com.example.app',
});
expect(withSessionDefaults.success).toBe(true);
const parsed = withSessionDefaults.data as Record<string, unknown>;
expect(parsed.simulatorId).toBeUndefined();
expect(parsed.simulatorName).toBeUndefined();
});
});
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: Invalid input: expected string, received undefined',
);
});
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 surface error when simulator name is missing', async () => {
const result = await stop_app_simLogic(
{
simulatorName: 'Missing Simulator',
bundleId: 'com.example.App',
},
async () => ({
success: true,
output: JSON.stringify({ devices: {} }),
error: '',
process: {} as any,
}),
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Simulator named "Missing 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[];
logPrefix?: string;
useShell?: boolean;
opts?: { env?: Record<string, string>; cwd?: string };
detached?: boolean;
}> = [];
const trackingExecutor: CommandExecutor = async (
command,
logPrefix,
useShell,
opts,
detached,
) => {
calls.push({ command, logPrefix, useShell, opts, detached });
return createMockCommandResponse({
success: true,
output: '',
error: undefined,
});
};
await stop_app_simLogic(
{
simulatorId: 'test-uuid',
bundleId: 'com.example.App',
},
trackingExecutor,
);
expect(calls).toEqual([
{
command: ['xcrun', 'simctl', 'terminate', 'test-uuid', 'com.example.App'],
logPrefix: 'Stop App in Simulator',
useShell: true,
opts: undefined,
detached: undefined,
},
]);
});
});
});
```
--------------------------------------------------------------------------------
/docs/dev/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_TYPE_SAFETY.md](ESLINT_TYPE_SAFETY.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
```