#
tokens: 49881/50000 8/393 files (page 13/16)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 13 of 16. Use http://codebase.md/cameroncooke/xcodebuildmcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .axe-version
├── .claude
│   └── agents
│       └── xcodebuild-mcp-qa-tester.md
├── .cursor
│   ├── BUGBOT.md
│   └── environment.json
├── .cursorrules
├── .github
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE
│   │   ├── bug_report.yml
│   │   ├── config.yml
│   │   └── feature_request.yml
│   └── workflows
│       ├── ci.yml
│       ├── 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/mcp/tools/ui-testing/__tests__/key_press.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Tests for key_press tool plugin
  3 |  */
  4 | 
  5 | import { describe, it, expect, beforeEach } from 'vitest';
  6 | import * as z from 'zod';
  7 | import {
  8 |   createMockCommandResponse,
  9 |   createMockExecutor,
 10 |   createMockFileSystemExecutor,
 11 |   createNoopExecutor,
 12 |   mockProcess,
 13 | } from '../../../../test-utils/mock-executors.ts';
 14 | import { sessionStore } from '../../../../utils/session-store.ts';
 15 | import keyPressPlugin, { key_pressLogic } from '../key_press.ts';
 16 | 
 17 | describe('Key Press Plugin', () => {
 18 |   beforeEach(() => {
 19 |     sessionStore.clear();
 20 |   });
 21 | 
 22 |   describe('Export Field Validation (Literal)', () => {
 23 |     it('should have correct name', () => {
 24 |       expect(keyPressPlugin.name).toBe('key_press');
 25 |     });
 26 | 
 27 |     it('should have correct description', () => {
 28 |       expect(keyPressPlugin.description).toBe(
 29 |         'Press a single key by keycode on the simulator. Common keycodes: 40=Return, 42=Backspace, 43=Tab, 44=Space, 58-67=F1-F10.',
 30 |       );
 31 |     });
 32 | 
 33 |     it('should have handler function', () => {
 34 |       expect(typeof keyPressPlugin.handler).toBe('function');
 35 |     });
 36 | 
 37 |     it('should expose public schema without simulatorId field', () => {
 38 |       const schema = z.object(keyPressPlugin.schema);
 39 | 
 40 |       expect(schema.safeParse({ keyCode: 40 }).success).toBe(true);
 41 |       expect(schema.safeParse({ keyCode: 40, duration: 1.5 }).success).toBe(true);
 42 |       expect(schema.safeParse({ keyCode: 'invalid' }).success).toBe(false);
 43 |       expect(schema.safeParse({ keyCode: -1 }).success).toBe(false);
 44 |       expect(schema.safeParse({ keyCode: 256 }).success).toBe(false);
 45 | 
 46 |       const withSimId = schema.safeParse({
 47 |         simulatorId: '12345678-1234-4234-8234-123456789012',
 48 |         keyCode: 40,
 49 |       });
 50 |       expect(withSimId.success).toBe(true);
 51 |       expect('simulatorId' in (withSimId.data as any)).toBe(false);
 52 | 
 53 |       expect(schema.safeParse({}).success).toBe(false);
 54 |     });
 55 |   });
 56 | 
 57 |   describe('Handler Requirements', () => {
 58 |     it('should require simulatorId session default when not provided', async () => {
 59 |       const result = await keyPressPlugin.handler({ keyCode: 40 });
 60 | 
 61 |       expect(result.isError).toBe(true);
 62 |       const message = result.content[0].text;
 63 |       expect(message).toContain('Missing required session defaults');
 64 |       expect(message).toContain('simulatorId is required');
 65 |       expect(message).toContain('session-set-defaults');
 66 |     });
 67 | 
 68 |     it('should surface validation errors once simulator default exists', async () => {
 69 |       sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' });
 70 | 
 71 |       const result = await keyPressPlugin.handler({});
 72 | 
 73 |       expect(result.isError).toBe(true);
 74 |       const message = result.content[0].text;
 75 |       expect(message).toContain('Parameter validation failed');
 76 |       expect(message).toContain('keyCode: Invalid input: expected number, received undefined');
 77 |     });
 78 |   });
 79 | 
 80 |   describe('Command Generation', () => {
 81 |     it('should generate correct axe command for basic key press', async () => {
 82 |       let capturedCommand: string[] = [];
 83 |       const trackingExecutor = async (command: string[]) => {
 84 |         capturedCommand = command;
 85 |         return createMockCommandResponse({
 86 |           success: true,
 87 |           output: 'key press completed',
 88 |           error: undefined,
 89 |           process: mockProcess,
 90 |         });
 91 |       };
 92 | 
 93 |       const mockAxeHelpers = {
 94 |         getAxePath: () => '/usr/local/bin/axe',
 95 |         getBundledAxeEnvironment: () => ({}),
 96 |         createAxeNotAvailableResponse: () => ({
 97 |           content: [
 98 |             {
 99 |               type: 'text' as const,
100 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
101 |             },
102 |           ],
103 |           isError: true,
104 |         }),
105 |       };
106 | 
107 |       await key_pressLogic(
108 |         {
109 |           simulatorId: '12345678-1234-4234-8234-123456789012',
110 |           keyCode: 40,
111 |         },
112 |         trackingExecutor,
113 |         mockAxeHelpers,
114 |       );
115 | 
116 |       expect(capturedCommand).toEqual([
117 |         '/usr/local/bin/axe',
118 |         'key',
119 |         '40',
120 |         '--udid',
121 |         '12345678-1234-4234-8234-123456789012',
122 |       ]);
123 |     });
124 | 
125 |     it('should generate correct axe command for key press with duration', async () => {
126 |       let capturedCommand: string[] = [];
127 |       const trackingExecutor = async (command: string[]) => {
128 |         capturedCommand = command;
129 |         return createMockCommandResponse({
130 |           success: true,
131 |           output: 'key press completed',
132 |           error: undefined,
133 |           process: mockProcess,
134 |         });
135 |       };
136 | 
137 |       const mockAxeHelpers = {
138 |         getAxePath: () => '/usr/local/bin/axe',
139 |         getBundledAxeEnvironment: () => ({}),
140 |         createAxeNotAvailableResponse: () => ({
141 |           content: [
142 |             {
143 |               type: 'text' as const,
144 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
145 |             },
146 |           ],
147 |           isError: true,
148 |         }),
149 |       };
150 | 
151 |       await key_pressLogic(
152 |         {
153 |           simulatorId: '12345678-1234-4234-8234-123456789012',
154 |           keyCode: 42,
155 |           duration: 1.5,
156 |         },
157 |         trackingExecutor,
158 |         mockAxeHelpers,
159 |       );
160 | 
161 |       expect(capturedCommand).toEqual([
162 |         '/usr/local/bin/axe',
163 |         'key',
164 |         '42',
165 |         '--duration',
166 |         '1.5',
167 |         '--udid',
168 |         '12345678-1234-4234-8234-123456789012',
169 |       ]);
170 |     });
171 | 
172 |     it('should generate correct axe command for different key codes', async () => {
173 |       let capturedCommand: string[] = [];
174 |       const trackingExecutor = async (command: string[]) => {
175 |         capturedCommand = command;
176 |         return createMockCommandResponse({
177 |           success: true,
178 |           output: 'key press completed',
179 |           error: undefined,
180 |           process: mockProcess,
181 |         });
182 |       };
183 | 
184 |       const mockAxeHelpers = {
185 |         getAxePath: () => '/usr/local/bin/axe',
186 |         getBundledAxeEnvironment: () => ({}),
187 |         createAxeNotAvailableResponse: () => ({
188 |           content: [
189 |             {
190 |               type: 'text' as const,
191 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
192 |             },
193 |           ],
194 |           isError: true,
195 |         }),
196 |       };
197 | 
198 |       await key_pressLogic(
199 |         {
200 |           simulatorId: '12345678-1234-4234-8234-123456789012',
201 |           keyCode: 255,
202 |         },
203 |         trackingExecutor,
204 |         mockAxeHelpers,
205 |       );
206 | 
207 |       expect(capturedCommand).toEqual([
208 |         '/usr/local/bin/axe',
209 |         'key',
210 |         '255',
211 |         '--udid',
212 |         '12345678-1234-4234-8234-123456789012',
213 |       ]);
214 |     });
215 | 
216 |     it('should generate correct axe command with bundled axe path', async () => {
217 |       let capturedCommand: string[] = [];
218 |       const trackingExecutor = async (command: string[]) => {
219 |         capturedCommand = command;
220 |         return createMockCommandResponse({
221 |           success: true,
222 |           output: 'key press completed',
223 |           error: undefined,
224 |           process: mockProcess,
225 |         });
226 |       };
227 | 
228 |       const mockAxeHelpers = {
229 |         getAxePath: () => '/path/to/bundled/axe',
230 |         getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }),
231 |         createAxeNotAvailableResponse: () => ({
232 |           content: [
233 |             {
234 |               type: 'text' as const,
235 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
236 |             },
237 |           ],
238 |           isError: true,
239 |         }),
240 |       };
241 | 
242 |       await key_pressLogic(
243 |         {
244 |           simulatorId: '12345678-1234-4234-8234-123456789012',
245 |           keyCode: 44,
246 |         },
247 |         trackingExecutor,
248 |         mockAxeHelpers,
249 |       );
250 | 
251 |       expect(capturedCommand).toEqual([
252 |         '/path/to/bundled/axe',
253 |         'key',
254 |         '44',
255 |         '--udid',
256 |         '12345678-1234-4234-8234-123456789012',
257 |       ]);
258 |     });
259 |   });
260 | 
261 |   describe('Handler Behavior (Complete Literal Returns)', () => {
262 |     // Note: Parameter validation is now handled by Zod schema validation in createTypedTool wrapper.
263 |     // The key_pressLogic function expects valid parameters and focuses on business logic testing.
264 | 
265 |     it('should return success for valid key press execution', async () => {
266 |       const mockExecutor = createMockExecutor({
267 |         success: true,
268 |         output: 'key press completed',
269 |         error: '',
270 |       });
271 | 
272 |       const mockAxeHelpers = {
273 |         getAxePath: () => '/usr/local/bin/axe',
274 |         getBundledAxeEnvironment: () => ({}),
275 |         createAxeNotAvailableResponse: () => ({
276 |           content: [
277 |             {
278 |               type: 'text' as const,
279 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
280 |             },
281 |           ],
282 |           isError: true,
283 |         }),
284 |       };
285 | 
286 |       const result = await key_pressLogic(
287 |         {
288 |           simulatorId: '12345678-1234-4234-8234-123456789012',
289 |           keyCode: 40,
290 |         },
291 |         mockExecutor,
292 |         mockAxeHelpers,
293 |       );
294 | 
295 |       expect(result).toEqual({
296 |         content: [{ type: 'text' as const, text: 'Key press (code: 40) simulated successfully.' }],
297 |         isError: false,
298 |       });
299 |     });
300 | 
301 |     it('should return success for key press with duration', async () => {
302 |       const mockExecutor = createMockExecutor({
303 |         success: true,
304 |         output: 'key press completed',
305 |         error: '',
306 |       });
307 | 
308 |       const mockAxeHelpers = {
309 |         getAxePath: () => '/usr/local/bin/axe',
310 |         getBundledAxeEnvironment: () => ({}),
311 |         createAxeNotAvailableResponse: () => ({
312 |           content: [
313 |             {
314 |               type: 'text' as const,
315 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
316 |             },
317 |           ],
318 |           isError: true,
319 |         }),
320 |       };
321 | 
322 |       const result = await key_pressLogic(
323 |         {
324 |           simulatorId: '12345678-1234-4234-8234-123456789012',
325 |           keyCode: 42,
326 |           duration: 1.5,
327 |         },
328 |         mockExecutor,
329 |         mockAxeHelpers,
330 |       );
331 | 
332 |       expect(result).toEqual({
333 |         content: [{ type: 'text' as const, text: 'Key press (code: 42) simulated successfully.' }],
334 |         isError: false,
335 |       });
336 |     });
337 | 
338 |     it('should handle DependencyError when axe is not available', async () => {
339 |       const mockAxeHelpers = {
340 |         getAxePath: () => null,
341 |         getBundledAxeEnvironment: () => ({}),
342 |         createAxeNotAvailableResponse: () => ({
343 |           content: [
344 |             {
345 |               type: 'text' as const,
346 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
347 |             },
348 |           ],
349 |           isError: true,
350 |         }),
351 |       };
352 | 
353 |       const result = await key_pressLogic(
354 |         {
355 |           simulatorId: '12345678-1234-4234-8234-123456789012',
356 |           keyCode: 40,
357 |         },
358 |         createNoopExecutor(),
359 |         mockAxeHelpers,
360 |       );
361 | 
362 |       expect(result).toEqual({
363 |         content: [
364 |           {
365 |             type: 'text' as const,
366 |             text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
367 |           },
368 |         ],
369 |         isError: true,
370 |       });
371 |     });
372 | 
373 |     it('should handle AxeError from failed command execution', async () => {
374 |       const mockExecutor = createMockExecutor({
375 |         success: false,
376 |         output: '',
377 |         error: 'axe command failed',
378 |       });
379 | 
380 |       const mockAxeHelpers = {
381 |         getAxePath: () => '/usr/local/bin/axe',
382 |         getBundledAxeEnvironment: () => ({}),
383 |         createAxeNotAvailableResponse: () => ({
384 |           content: [
385 |             {
386 |               type: 'text' as const,
387 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
388 |             },
389 |           ],
390 |           isError: true,
391 |         }),
392 |       };
393 | 
394 |       const result = await key_pressLogic(
395 |         {
396 |           simulatorId: '12345678-1234-4234-8234-123456789012',
397 |           keyCode: 40,
398 |         },
399 |         mockExecutor,
400 |         mockAxeHelpers,
401 |       );
402 | 
403 |       expect(result).toEqual({
404 |         content: [
405 |           {
406 |             type: 'text' as const,
407 |             text: "Error: Failed to simulate key press (code: 40): axe command 'key' failed.\nDetails: axe command failed",
408 |           },
409 |         ],
410 |         isError: true,
411 |       });
412 |     });
413 | 
414 |     it('should handle SystemError from command execution', async () => {
415 |       const mockExecutor = () => {
416 |         throw new Error('System error occurred');
417 |       };
418 | 
419 |       const mockAxeHelpers = {
420 |         getAxePath: () => '/usr/local/bin/axe',
421 |         getBundledAxeEnvironment: () => ({}),
422 |         createAxeNotAvailableResponse: () => ({
423 |           content: [
424 |             {
425 |               type: 'text' as const,
426 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
427 |             },
428 |           ],
429 |           isError: true,
430 |         }),
431 |       };
432 | 
433 |       const result = await key_pressLogic(
434 |         {
435 |           simulatorId: '12345678-1234-4234-8234-123456789012',
436 |           keyCode: 40,
437 |         },
438 |         mockExecutor,
439 |         mockAxeHelpers,
440 |       );
441 | 
442 |       expect(result.isError).toBe(true);
443 |       expect(result.content[0].text).toContain(
444 |         'Error: System error executing axe: Failed to execute axe command: System error occurred',
445 |       );
446 |     });
447 | 
448 |     it('should handle unexpected Error objects', async () => {
449 |       const mockExecutor = () => {
450 |         throw new Error('Unexpected error');
451 |       };
452 | 
453 |       const mockAxeHelpers = {
454 |         getAxePath: () => '/usr/local/bin/axe',
455 |         getBundledAxeEnvironment: () => ({}),
456 |         createAxeNotAvailableResponse: () => ({
457 |           content: [
458 |             {
459 |               type: 'text' as const,
460 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
461 |             },
462 |           ],
463 |           isError: true,
464 |         }),
465 |       };
466 | 
467 |       const result = await key_pressLogic(
468 |         {
469 |           simulatorId: '12345678-1234-4234-8234-123456789012',
470 |           keyCode: 40,
471 |         },
472 |         mockExecutor,
473 |         mockAxeHelpers,
474 |       );
475 | 
476 |       expect(result.isError).toBe(true);
477 |       expect(result.content[0].text).toContain(
478 |         'Error: System error executing axe: Failed to execute axe command: Unexpected error',
479 |       );
480 |     });
481 | 
482 |     it('should handle unexpected string errors', async () => {
483 |       const mockExecutor = () => {
484 |         throw 'String error';
485 |       };
486 | 
487 |       const mockAxeHelpers = {
488 |         getAxePath: () => '/usr/local/bin/axe',
489 |         getBundledAxeEnvironment: () => ({}),
490 |         createAxeNotAvailableResponse: () => ({
491 |           content: [
492 |             {
493 |               type: 'text' as const,
494 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
495 |             },
496 |           ],
497 |           isError: true,
498 |         }),
499 |       };
500 | 
501 |       const result = await key_pressLogic(
502 |         {
503 |           simulatorId: '12345678-1234-4234-8234-123456789012',
504 |           keyCode: 40,
505 |         },
506 |         mockExecutor,
507 |         mockAxeHelpers,
508 |       );
509 | 
510 |       expect(result).toEqual({
511 |         content: [
512 |           {
513 |             type: 'text' as const,
514 |             text: 'Error: System error executing axe: Failed to execute axe command: String error',
515 |           },
516 |         ],
517 |         isError: true,
518 |       });
519 |     });
520 |   });
521 | });
522 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Utilities Plugin: Scaffold iOS Project
  3 |  *
  4 |  * Scaffold a new iOS project from templates.
  5 |  */
  6 | 
  7 | import * as z from 'zod';
  8 | import { join, dirname, basename } from 'path';
  9 | import { log } from '../../../utils/logging/index.ts';
 10 | import { ValidationError } from '../../../utils/responses/index.ts';
 11 | import { TemplateManager } from '../../../utils/template/index.ts';
 12 | import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts';
 13 | import {
 14 |   getDefaultCommandExecutor,
 15 |   getDefaultFileSystemExecutor,
 16 | } from '../../../utils/execution/index.ts';
 17 | import { ToolResponse } from '../../../types/common.ts';
 18 | 
 19 | // Common base schema for both iOS and macOS
 20 | const BaseScaffoldSchema = z.object({
 21 |   projectName: z.string().min(1).describe('Name of the new project'),
 22 |   outputPath: z.string().describe('Path where the project should be created'),
 23 |   bundleIdentifier: z
 24 |     .string()
 25 |     .optional()
 26 |     .describe(
 27 |       'Bundle identifier (e.g., com.example.myapp). If not provided, will use com.example.projectname',
 28 |     ),
 29 |   displayName: z
 30 |     .string()
 31 |     .optional()
 32 |     .describe(
 33 |       'App display name (shown on home screen/dock). If not provided, will use projectName',
 34 |     ),
 35 |   marketingVersion: z
 36 |     .string()
 37 |     .optional()
 38 |     .describe('Marketing version (e.g., 1.0, 2.1.3). If not provided, will use 1.0'),
 39 |   currentProjectVersion: z
 40 |     .string()
 41 |     .optional()
 42 |     .describe('Build number (e.g., 1, 42, 100). If not provided, will use 1'),
 43 |   customizeNames: z
 44 |     .boolean()
 45 |     .default(true)
 46 |     .describe('Whether to customize project names and identifiers. Default is true.'),
 47 | });
 48 | 
 49 | // iOS-specific schema
 50 | const ScaffoldiOSProjectSchema = BaseScaffoldSchema.extend({
 51 |   deploymentTarget: z
 52 |     .string()
 53 |     .optional()
 54 |     .describe('iOS deployment target (e.g., 18.4, 17.0). If not provided, will use 18.4'),
 55 |   targetedDeviceFamily: z
 56 |     .array(z.enum(['iphone', 'ipad', 'universal']))
 57 |     .optional()
 58 |     .describe('Targeted device families'),
 59 |   supportedOrientations: z
 60 |     .array(z.enum(['portrait', 'landscape-left', 'landscape-right', 'portrait-upside-down']))
 61 |     .optional()
 62 |     .describe('Supported orientations for iPhone'),
 63 |   supportedOrientationsIpad: z
 64 |     .array(z.enum(['portrait', 'landscape-left', 'landscape-right', 'portrait-upside-down']))
 65 |     .optional()
 66 |     .describe('Supported orientations for iPad'),
 67 | });
 68 | 
 69 | /**
 70 |  * Convert orientation enum to iOS constant
 71 |  */
 72 | function orientationToIOSConstant(orientation: string): string {
 73 |   switch (orientation) {
 74 |     case 'Portrait':
 75 |       return 'UIInterfaceOrientationPortrait';
 76 |     case 'PortraitUpsideDown':
 77 |       return 'UIInterfaceOrientationPortraitUpsideDown';
 78 |     case 'LandscapeLeft':
 79 |       return 'UIInterfaceOrientationLandscapeLeft';
 80 |     case 'LandscapeRight':
 81 |       return 'UIInterfaceOrientationLandscapeRight';
 82 |     default:
 83 |       return orientation;
 84 |   }
 85 | }
 86 | 
 87 | /**
 88 |  * Convert device family enum to numeric value
 89 |  */
 90 | function deviceFamilyToNumeric(family: string): string {
 91 |   switch (family) {
 92 |     case 'iPhone':
 93 |       return '1';
 94 |     case 'iPad':
 95 |       return '2';
 96 |     case 'iPhone+iPad':
 97 |       return '1,2';
 98 |     default:
 99 |       return '1,2';
100 |   }
101 | }
102 | 
103 | /**
104 |  * Update Package.swift file with deployment target
105 |  */
106 | function updatePackageSwiftFile(content: string, params: Record<string, unknown>): string {
107 |   let result = content;
108 | 
109 |   const projectName = params.projectName as string;
110 |   const platform = params.platform as string;
111 |   const deploymentTarget = params.deploymentTarget as string | undefined;
112 | 
113 |   // Update ALL target name references in Package.swift
114 |   const featureName = `${projectName}Feature`;
115 |   const testName = `${projectName}FeatureTests`;
116 | 
117 |   // Replace ALL occurrences of MyProjectFeatureTests first (more specific)
118 |   result = result.replace(/MyProjectFeatureTests/g, testName);
119 |   // Then replace ALL occurrences of MyProjectFeature (less specific, so comes after)
120 |   result = result.replace(/MyProjectFeature/g, featureName);
121 | 
122 |   // Update deployment targets based on platform
123 |   if (platform === 'iOS') {
124 |     if (deploymentTarget) {
125 |       // Extract major version (e.g., "17.0" -> "17")
126 |       const majorVersion = deploymentTarget.split('.')[0];
127 |       result = result.replace(/\.iOS\(\.v\d+\)/, `.iOS(.v${majorVersion})`);
128 |     }
129 |   }
130 | 
131 |   return result;
132 | }
133 | 
134 | /**
135 |  * Update XCConfig file with scaffold parameters
136 |  */
137 | function updateXCConfigFile(content: string, params: Record<string, unknown>): string {
138 |   let result = content;
139 | 
140 |   const projectName = params.projectName as string;
141 |   const displayName = params.displayName as string | undefined;
142 |   const bundleIdentifier = params.bundleIdentifier as string | undefined;
143 |   const marketingVersion = params.marketingVersion as string | undefined;
144 |   const currentProjectVersion = params.currentProjectVersion as string | undefined;
145 |   const platform = params.platform as string;
146 |   const deploymentTarget = params.deploymentTarget as string | undefined;
147 |   const targetedDeviceFamily = params.targetedDeviceFamily as string | undefined;
148 |   const supportedOrientations = params.supportedOrientations as string[] | undefined;
149 |   const supportedOrientationsIpad = params.supportedOrientationsIpad as string[] | undefined;
150 | 
151 |   // Update project identity settings
152 |   result = result.replace(/PRODUCT_NAME = .+/g, `PRODUCT_NAME = ${projectName}`);
153 |   result = result.replace(
154 |     /PRODUCT_DISPLAY_NAME = .+/g,
155 |     `PRODUCT_DISPLAY_NAME = ${displayName ?? projectName}`,
156 |   );
157 |   result = result.replace(
158 |     /PRODUCT_BUNDLE_IDENTIFIER = .+/g,
159 |     `PRODUCT_BUNDLE_IDENTIFIER = ${bundleIdentifier ?? `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`}`,
160 |   );
161 |   result = result.replace(
162 |     /MARKETING_VERSION = .+/g,
163 |     `MARKETING_VERSION = ${marketingVersion ?? '1.0'}`,
164 |   );
165 |   result = result.replace(
166 |     /CURRENT_PROJECT_VERSION = .+/g,
167 |     `CURRENT_PROJECT_VERSION = ${currentProjectVersion ?? '1'}`,
168 |   );
169 | 
170 |   // Platform-specific updates
171 |   if (platform === 'iOS') {
172 |     // iOS deployment target
173 |     if (deploymentTarget) {
174 |       result = result.replace(
175 |         /IPHONEOS_DEPLOYMENT_TARGET = .+/g,
176 |         `IPHONEOS_DEPLOYMENT_TARGET = ${deploymentTarget}`,
177 |       );
178 |     }
179 | 
180 |     // Device family
181 |     if (targetedDeviceFamily) {
182 |       const deviceFamilyValue = deviceFamilyToNumeric(targetedDeviceFamily);
183 |       result = result.replace(
184 |         /TARGETED_DEVICE_FAMILY = .+/g,
185 |         `TARGETED_DEVICE_FAMILY = ${deviceFamilyValue}`,
186 |       );
187 |     }
188 | 
189 |     // iPhone orientations
190 |     if (supportedOrientations && supportedOrientations.length > 0) {
191 |       // Filter out any empty strings and validate
192 |       const validOrientations = supportedOrientations.filter((o: string) => o && o.trim() !== '');
193 |       if (validOrientations.length > 0) {
194 |         const orientations = validOrientations.map(orientationToIOSConstant).join(' ');
195 |         result = result.replace(
196 |           /INFOPLIST_KEY_UISupportedInterfaceOrientations = .+/g,
197 |           `INFOPLIST_KEY_UISupportedInterfaceOrientations = ${orientations}`,
198 |         );
199 |       }
200 |     }
201 | 
202 |     // iPad orientations
203 |     if (supportedOrientationsIpad && supportedOrientationsIpad.length > 0) {
204 |       // Filter out any empty strings and validate
205 |       const validOrientations = supportedOrientationsIpad.filter(
206 |         (o: string) => o && o.trim() !== '',
207 |       );
208 |       if (validOrientations.length > 0) {
209 |         const orientations = validOrientations.map(orientationToIOSConstant).join(' ');
210 |         result = result.replace(
211 |           /INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = .+/g,
212 |           `INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = ${orientations}`,
213 |         );
214 |       }
215 |     }
216 | 
217 |     // Update entitlements path for iOS
218 |     result = result.replace(
219 |       /CODE_SIGN_ENTITLEMENTS = .+/g,
220 |       `CODE_SIGN_ENTITLEMENTS = Config/${projectName}.entitlements`,
221 |     );
222 |   }
223 | 
224 |   // Update test bundle identifier and target name
225 |   result = result.replace(/TEST_TARGET_NAME = .+/g, `TEST_TARGET_NAME = ${projectName}`);
226 | 
227 |   // Update comments that reference MyProject in entitlements paths
228 |   result = result.replace(/Config\/MyProject\.entitlements/g, `Config/${projectName}.entitlements`);
229 | 
230 |   return result;
231 | }
232 | 
233 | /**
234 |  * Replace placeholders in a string (for non-XCConfig files)
235 |  */
236 | function replacePlaceholders(
237 |   content: string,
238 |   projectName: string,
239 |   bundleIdentifier: string,
240 | ): string {
241 |   let result = content;
242 | 
243 |   // Replace project name
244 |   result = result.replace(/MyProject/g, projectName);
245 | 
246 |   // Replace bundle identifier - check for both patterns used in templates
247 |   if (bundleIdentifier) {
248 |     result = result.replace(/com\.example\.MyProject/g, bundleIdentifier);
249 |     result = result.replace(/com\.mycompany\.MyProject/g, bundleIdentifier);
250 |   }
251 | 
252 |   return result;
253 | }
254 | 
255 | /**
256 |  * Process a single file, replacing placeholders if it's a text file
257 |  */
258 | async function processFile(
259 |   sourcePath: string,
260 |   destPath: string,
261 |   params: Record<string, unknown>,
262 |   fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(),
263 | ): Promise<void> {
264 |   const projectName = params.projectName as string;
265 |   const bundleIdentifierParam = params.bundleIdentifier as string | undefined;
266 |   const customizeNames = params.customizeNames as boolean | undefined;
267 | 
268 |   // Determine the destination file path
269 |   let finalDestPath = destPath;
270 |   if (customizeNames) {
271 |     // Replace MyProject in file/directory names
272 |     const fileName = basename(destPath);
273 |     const dirName = dirname(destPath);
274 |     const newFileName = fileName.replace(/MyProject/g, projectName);
275 |     finalDestPath = join(dirName, newFileName);
276 |   }
277 | 
278 |   // Text file extensions that should be processed
279 |   const textExtensions = [
280 |     '.swift',
281 |     '.h',
282 |     '.m',
283 |     '.mm',
284 |     '.cpp',
285 |     '.c',
286 |     '.pbxproj',
287 |     '.plist',
288 |     '.xcscheme',
289 |     '.xctestplan',
290 |     '.xcworkspacedata',
291 |     '.xcconfig',
292 |     '.json',
293 |     '.xml',
294 |     '.entitlements',
295 |     '.storyboard',
296 |     '.xib',
297 |     '.md',
298 |   ];
299 | 
300 |   const ext = sourcePath.toLowerCase();
301 |   const isTextFile = textExtensions.some((textExt) => ext.endsWith(textExt));
302 |   const isXCConfig = sourcePath.endsWith('.xcconfig');
303 |   const isPackageSwift = sourcePath.endsWith('Package.swift');
304 | 
305 |   if (isTextFile && customizeNames) {
306 |     // Read the file content
307 |     const content = await fileSystemExecutor.readFile(sourcePath, 'utf-8');
308 | 
309 |     let processedContent;
310 | 
311 |     if (isXCConfig) {
312 |       // Use special XCConfig processing
313 |       processedContent = updateXCConfigFile(content, params);
314 |     } else if (isPackageSwift) {
315 |       // Use special Package.swift processing
316 |       processedContent = updatePackageSwiftFile(content, params);
317 |     } else {
318 |       // Use standard placeholder replacement
319 |       const bundleIdentifier =
320 |         bundleIdentifierParam ??
321 |         `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
322 |       processedContent = replacePlaceholders(content, projectName, bundleIdentifier);
323 |     }
324 | 
325 |     await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true });
326 |     await fileSystemExecutor.writeFile(finalDestPath, processedContent, 'utf-8');
327 |   } else {
328 |     // Copy binary files as-is
329 |     await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true });
330 |     await fileSystemExecutor.cp(sourcePath, finalDestPath);
331 |   }
332 | }
333 | 
334 | /**
335 |  * Recursively process a directory
336 |  */
337 | async function processDirectory(
338 |   sourceDir: string,
339 |   destDir: string,
340 |   params: Record<string, unknown>,
341 |   fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(),
342 | ): Promise<void> {
343 |   const entries = await fileSystemExecutor.readdir(sourceDir, { withFileTypes: true });
344 | 
345 |   for (const entry of entries) {
346 |     const entryTyped = entry as { name: string; isDirectory: () => boolean; isFile: () => boolean };
347 |     const sourcePath = join(sourceDir, entryTyped.name);
348 |     let destName = entryTyped.name;
349 | 
350 |     if (params.customizeNames) {
351 |       // Replace MyProject in directory names
352 |       destName = destName.replace(/MyProject/g, params.projectName as string);
353 |     }
354 | 
355 |     const destPath = join(destDir, destName);
356 | 
357 |     if (entryTyped.isDirectory()) {
358 |       // Skip certain directories
359 |       if (entryTyped.name === '.git' || entryTyped.name === 'xcuserdata') {
360 |         continue;
361 |       }
362 |       await fileSystemExecutor.mkdir(destPath, { recursive: true });
363 |       await processDirectory(sourcePath, destPath, params, fileSystemExecutor);
364 |     } else if (entryTyped.isFile()) {
365 |       // Skip certain files
366 |       if (entryTyped.name === '.DS_Store' || entryTyped.name.endsWith('.xcuserstate')) {
367 |         continue;
368 |       }
369 |       await processFile(sourcePath, destPath, params, fileSystemExecutor);
370 |     }
371 |   }
372 | }
373 | 
374 | // Use z.infer for type safety
375 | type ScaffoldIOSProjectParams = z.infer<typeof ScaffoldiOSProjectSchema>;
376 | 
377 | /**
378 |  * Logic function for scaffolding iOS projects
379 |  */
380 | export async function scaffold_ios_projectLogic(
381 |   params: ScaffoldIOSProjectParams,
382 |   commandExecutor: CommandExecutor,
383 |   fileSystemExecutor: FileSystemExecutor,
384 | ): Promise<ToolResponse> {
385 |   try {
386 |     const projectParams = { ...params, platform: 'iOS' };
387 |     const projectPath = await scaffoldProject(projectParams, commandExecutor, fileSystemExecutor);
388 | 
389 |     const response = {
390 |       success: true,
391 |       projectPath,
392 |       platform: 'iOS',
393 |       message: `Successfully scaffolded iOS project "${params.projectName}" in ${projectPath}`,
394 |       nextSteps: [
395 |         `Important: Before working on the project make sure to read the README.md file in the workspace root directory.`,
396 |         `Build for simulator: build_sim({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}", simulatorName: "iPhone 16" })`,
397 |         `Build and run on simulator: build_run_sim({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}", simulatorName: "iPhone 16" })`,
398 |       ],
399 |     };
400 | 
401 |     return {
402 |       content: [
403 |         {
404 |           type: 'text',
405 |           text: JSON.stringify(response, null, 2),
406 |         },
407 |       ],
408 |     };
409 |   } catch (error) {
410 |     log(
411 |       'error',
412 |       `Failed to scaffold iOS project: ${error instanceof Error ? error.message : String(error)}`,
413 |     );
414 | 
415 |     return {
416 |       content: [
417 |         {
418 |           type: 'text',
419 |           text: JSON.stringify(
420 |             {
421 |               success: false,
422 |               error: error instanceof Error ? error.message : 'Unknown error occurred',
423 |             },
424 |             null,
425 |             2,
426 |           ),
427 |         },
428 |       ],
429 |       isError: true,
430 |     };
431 |   }
432 | }
433 | 
434 | /**
435 |  * Scaffold a new iOS or macOS project
436 |  */
437 | async function scaffoldProject(
438 |   params: Record<string, unknown>,
439 |   commandExecutor?: CommandExecutor,
440 |   fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(),
441 | ): Promise<string> {
442 |   const projectName = params.projectName as string;
443 |   const outputPath = params.outputPath as string;
444 |   const platform = params.platform as 'iOS' | 'macOS';
445 |   const customizeNames = (params.customizeNames as boolean | undefined) ?? true;
446 | 
447 |   log('info', `Scaffolding project: ${projectName} (${platform}) at ${outputPath}`);
448 | 
449 |   // Validate project name
450 |   if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(projectName)) {
451 |     throw new ValidationError(
452 |       'Project name must start with a letter and contain only letters, numbers, and underscores',
453 |     );
454 |   }
455 | 
456 |   // Get template path from TemplateManager
457 |   let templatePath;
458 |   try {
459 |     // Use the default command executor if not provided
460 |     commandExecutor ??= getDefaultCommandExecutor();
461 | 
462 |     templatePath = await TemplateManager.getTemplatePath(
463 |       platform,
464 |       commandExecutor,
465 |       fileSystemExecutor,
466 |     );
467 |   } catch (error) {
468 |     throw new ValidationError(
469 |       `Failed to get template for ${platform}: ${error instanceof Error ? error.message : String(error)}`,
470 |     );
471 |   }
472 | 
473 |   // Use outputPath directly as the destination
474 |   const projectPath = outputPath;
475 | 
476 |   // Check if the output directory already has Xcode project files
477 |   const xcworkspaceExists = fileSystemExecutor.existsSync(
478 |     join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcworkspace`),
479 |   );
480 |   const xcodeprojExists = fileSystemExecutor.existsSync(
481 |     join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcodeproj`),
482 |   );
483 | 
484 |   if (xcworkspaceExists || xcodeprojExists) {
485 |     throw new ValidationError(`Xcode project files already exist in ${projectPath}`);
486 |   }
487 | 
488 |   try {
489 |     // Process the template directly into the output path
490 |     await processDirectory(templatePath, projectPath, params, fileSystemExecutor);
491 | 
492 |     return projectPath;
493 |   } finally {
494 |     // Clean up downloaded template if needed
495 |     await TemplateManager.cleanup(templatePath, fileSystemExecutor);
496 |   }
497 | }
498 | 
499 | export default {
500 |   name: 'scaffold_ios_project',
501 |   description:
502 |     'Scaffold a new iOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper iOS configuration.',
503 |   schema: ScaffoldiOSProjectSchema.shape,
504 |   annotations: {
505 |     title: 'Scaffold iOS Project',
506 |     destructiveHint: true,
507 |   },
508 |   async handler(args: Record<string, unknown>): Promise<ToolResponse> {
509 |     const params = ScaffoldiOSProjectSchema.parse(args);
510 |     return scaffold_ios_projectLogic(
511 |       params,
512 |       getDefaultCommandExecutor(),
513 |       getDefaultFileSystemExecutor(),
514 |     );
515 |   },
516 | };
517 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/ui-testing/__tests__/key_sequence.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Tests for key_sequence plugin
  3 |  */
  4 | 
  5 | import { describe, it, expect, beforeEach } from 'vitest';
  6 | import * as z from 'zod';
  7 | import {
  8 |   createMockExecutor,
  9 |   createNoopExecutor,
 10 |   mockProcess,
 11 | } from '../../../../test-utils/mock-executors.ts';
 12 | import { sessionStore } from '../../../../utils/session-store.ts';
 13 | import keySequencePlugin, { key_sequenceLogic } from '../key_sequence.ts';
 14 | 
 15 | describe('Key Sequence Plugin', () => {
 16 |   beforeEach(() => {
 17 |     sessionStore.clear();
 18 |   });
 19 | 
 20 |   describe('Export Field Validation (Literal)', () => {
 21 |     it('should have correct name', () => {
 22 |       expect(keySequencePlugin.name).toBe('key_sequence');
 23 |     });
 24 | 
 25 |     it('should have correct description', () => {
 26 |       expect(keySequencePlugin.description).toBe(
 27 |         'Press key sequence using HID keycodes on iOS simulator with configurable delay',
 28 |       );
 29 |     });
 30 | 
 31 |     it('should have handler function', () => {
 32 |       expect(typeof keySequencePlugin.handler).toBe('function');
 33 |     });
 34 | 
 35 |     it('should expose public schema without simulatorId field', () => {
 36 |       const schema = z.object(keySequencePlugin.schema);
 37 | 
 38 |       expect(schema.safeParse({ keyCodes: [40, 42, 44] }).success).toBe(true);
 39 |       expect(schema.safeParse({ keyCodes: [40], delay: 0.1 }).success).toBe(true);
 40 |       expect(schema.safeParse({ keyCodes: [] }).success).toBe(false);
 41 |       expect(schema.safeParse({ keyCodes: [-1] }).success).toBe(false);
 42 |       expect(schema.safeParse({ keyCodes: [256] }).success).toBe(false);
 43 |       expect(schema.safeParse({ keyCodes: [40], delay: -0.1 }).success).toBe(false);
 44 | 
 45 |       const withSimId = schema.safeParse({
 46 |         simulatorId: '12345678-1234-4234-8234-123456789012',
 47 |         keyCodes: [40],
 48 |       });
 49 |       expect(withSimId.success).toBe(true);
 50 |       expect('simulatorId' in (withSimId.data as any)).toBe(false);
 51 | 
 52 |       expect(schema.safeParse({}).success).toBe(false);
 53 |     });
 54 |   });
 55 | 
 56 |   describe('Handler Requirements', () => {
 57 |     it('should require simulatorId session default when not provided', async () => {
 58 |       const result = await keySequencePlugin.handler({ keyCodes: [40] });
 59 | 
 60 |       expect(result.isError).toBe(true);
 61 |       const message = result.content[0].text;
 62 |       expect(message).toContain('Missing required session defaults');
 63 |       expect(message).toContain('simulatorId is required');
 64 |       expect(message).toContain('session-set-defaults');
 65 |     });
 66 | 
 67 |     it('should surface validation errors once simulator defaults exist', async () => {
 68 |       sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' });
 69 | 
 70 |       const result = await keySequencePlugin.handler({ keyCodes: [] });
 71 | 
 72 |       expect(result.isError).toBe(true);
 73 |       const message = result.content[0].text;
 74 |       expect(message).toContain('Parameter validation failed');
 75 |       expect(message).toContain('keyCodes: At least one key code required');
 76 |     });
 77 |   });
 78 | 
 79 |   describe('Command Generation', () => {
 80 |     it('should generate correct axe command for basic key sequence', async () => {
 81 |       let capturedCommand: string[] = [];
 82 |       const trackingExecutor = async (command: string[]) => {
 83 |         capturedCommand = command;
 84 |         return {
 85 |           success: true,
 86 |           output: 'key sequence completed',
 87 |           error: undefined,
 88 |           process: mockProcess,
 89 |         };
 90 |       };
 91 | 
 92 |       const mockAxeHelpers = {
 93 |         getAxePath: () => '/usr/local/bin/axe',
 94 |         getBundledAxeEnvironment: () => ({}),
 95 |         createAxeNotAvailableResponse: () => ({
 96 |           content: [
 97 |             {
 98 |               type: 'text' as const,
 99 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
100 |             },
101 |           ],
102 |           isError: true,
103 |         }),
104 |       };
105 | 
106 |       await key_sequenceLogic(
107 |         {
108 |           simulatorId: '12345678-1234-4234-8234-123456789012',
109 |           keyCodes: [40, 42, 44],
110 |         },
111 |         trackingExecutor,
112 |         mockAxeHelpers,
113 |       );
114 | 
115 |       expect(capturedCommand).toEqual([
116 |         '/usr/local/bin/axe',
117 |         'key-sequence',
118 |         '--keycodes',
119 |         '40,42,44',
120 |         '--udid',
121 |         '12345678-1234-4234-8234-123456789012',
122 |       ]);
123 |     });
124 | 
125 |     it('should generate correct axe command for key sequence with delay', async () => {
126 |       let capturedCommand: string[] = [];
127 |       const trackingExecutor = async (command: string[]) => {
128 |         capturedCommand = command;
129 |         return {
130 |           success: true,
131 |           output: 'key sequence completed',
132 |           error: undefined,
133 |           process: mockProcess,
134 |         };
135 |       };
136 | 
137 |       const mockAxeHelpers = {
138 |         getAxePath: () => '/usr/local/bin/axe',
139 |         getBundledAxeEnvironment: () => ({}),
140 |         createAxeNotAvailableResponse: () => ({
141 |           content: [
142 |             {
143 |               type: 'text' as const,
144 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
145 |             },
146 |           ],
147 |           isError: true,
148 |         }),
149 |       };
150 | 
151 |       await key_sequenceLogic(
152 |         {
153 |           simulatorId: '12345678-1234-4234-8234-123456789012',
154 |           keyCodes: [58, 59, 60],
155 |           delay: 0.5,
156 |         },
157 |         trackingExecutor,
158 |         mockAxeHelpers,
159 |       );
160 | 
161 |       expect(capturedCommand).toEqual([
162 |         '/usr/local/bin/axe',
163 |         'key-sequence',
164 |         '--keycodes',
165 |         '58,59,60',
166 |         '--delay',
167 |         '0.5',
168 |         '--udid',
169 |         '12345678-1234-4234-8234-123456789012',
170 |       ]);
171 |     });
172 | 
173 |     it('should generate correct axe command for single key in sequence', async () => {
174 |       let capturedCommand: string[] = [];
175 |       const trackingExecutor = async (command: string[]) => {
176 |         capturedCommand = command;
177 |         return {
178 |           success: true,
179 |           output: 'key sequence completed',
180 |           error: undefined,
181 |           process: mockProcess,
182 |         };
183 |       };
184 | 
185 |       const mockAxeHelpers = {
186 |         getAxePath: () => '/usr/local/bin/axe',
187 |         getBundledAxeEnvironment: () => ({}),
188 |         createAxeNotAvailableResponse: () => ({
189 |           content: [
190 |             {
191 |               type: 'text' as const,
192 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
193 |             },
194 |           ],
195 |           isError: true,
196 |         }),
197 |       };
198 | 
199 |       await key_sequenceLogic(
200 |         {
201 |           simulatorId: '12345678-1234-4234-8234-123456789012',
202 |           keyCodes: [255],
203 |         },
204 |         trackingExecutor,
205 |         mockAxeHelpers,
206 |       );
207 | 
208 |       expect(capturedCommand).toEqual([
209 |         '/usr/local/bin/axe',
210 |         'key-sequence',
211 |         '--keycodes',
212 |         '255',
213 |         '--udid',
214 |         '12345678-1234-4234-8234-123456789012',
215 |       ]);
216 |     });
217 | 
218 |     it('should generate correct axe command with bundled axe path', async () => {
219 |       let capturedCommand: string[] = [];
220 |       const trackingExecutor = async (command: string[]) => {
221 |         capturedCommand = command;
222 |         return {
223 |           success: true,
224 |           output: 'key sequence completed',
225 |           error: undefined,
226 |           process: mockProcess,
227 |         };
228 |       };
229 | 
230 |       const mockAxeHelpers = {
231 |         getAxePath: () => '/path/to/bundled/axe',
232 |         getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }),
233 |         createAxeNotAvailableResponse: () => ({
234 |           content: [
235 |             {
236 |               type: 'text' as const,
237 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
238 |             },
239 |           ],
240 |           isError: true,
241 |         }),
242 |       };
243 | 
244 |       await key_sequenceLogic(
245 |         {
246 |           simulatorId: '12345678-1234-4234-8234-123456789012',
247 |           keyCodes: [0, 1, 2, 3, 4],
248 |           delay: 1.0,
249 |         },
250 |         trackingExecutor,
251 |         mockAxeHelpers,
252 |       );
253 | 
254 |       expect(capturedCommand).toEqual([
255 |         '/path/to/bundled/axe',
256 |         'key-sequence',
257 |         '--keycodes',
258 |         '0,1,2,3,4',
259 |         '--delay',
260 |         '1',
261 |         '--udid',
262 |         '12345678-1234-4234-8234-123456789012',
263 |       ]);
264 |     });
265 |   });
266 | 
267 |   describe('Handler Behavior (Complete Literal Returns)', () => {
268 |     it('should surface session default requirement when simulatorId is missing', async () => {
269 |       const result = await keySequencePlugin.handler({ keyCodes: [40] });
270 | 
271 |       expect(result.isError).toBe(true);
272 |       expect(result.content[0].text).toContain('Missing required session defaults');
273 |       expect(result.content[0].text).toContain('simulatorId is required');
274 |     });
275 | 
276 |     it('should return success for valid key sequence execution', async () => {
277 |       const mockExecutor = createMockExecutor({
278 |         success: true,
279 |         output: 'Key sequence executed',
280 |         error: undefined,
281 |       });
282 | 
283 |       const mockAxeHelpers = {
284 |         getAxePath: () => '/usr/local/bin/axe',
285 |         getBundledAxeEnvironment: () => ({}),
286 |         createAxeNotAvailableResponse: () => ({
287 |           content: [
288 |             {
289 |               type: 'text' as const,
290 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
291 |             },
292 |           ],
293 |           isError: true,
294 |         }),
295 |       };
296 | 
297 |       const result = await key_sequenceLogic(
298 |         {
299 |           simulatorId: '12345678-1234-4234-8234-123456789012',
300 |           keyCodes: [40, 42, 44],
301 |           delay: 0.1,
302 |         },
303 |         mockExecutor,
304 |         mockAxeHelpers,
305 |       );
306 | 
307 |       expect(result).toEqual({
308 |         content: [
309 |           { type: 'text' as const, text: 'Key sequence [40,42,44] executed successfully.' },
310 |         ],
311 |         isError: false,
312 |       });
313 |     });
314 | 
315 |     it('should return success for key sequence without delay', async () => {
316 |       const mockExecutor = createMockExecutor({
317 |         success: true,
318 |         output: 'Key sequence executed',
319 |         error: undefined,
320 |       });
321 | 
322 |       const mockAxeHelpers = {
323 |         getAxePath: () => '/usr/local/bin/axe',
324 |         getBundledAxeEnvironment: () => ({}),
325 |         createAxeNotAvailableResponse: () => ({
326 |           content: [
327 |             {
328 |               type: 'text' as const,
329 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
330 |             },
331 |           ],
332 |           isError: true,
333 |         }),
334 |       };
335 | 
336 |       const result = await key_sequenceLogic(
337 |         {
338 |           simulatorId: '12345678-1234-4234-8234-123456789012',
339 |           keyCodes: [40],
340 |         },
341 |         mockExecutor,
342 |         mockAxeHelpers,
343 |       );
344 | 
345 |       expect(result).toEqual({
346 |         content: [{ type: 'text' as const, text: 'Key sequence [40] executed successfully.' }],
347 |         isError: false,
348 |       });
349 |     });
350 | 
351 |     it('should handle DependencyError when axe binary not found', async () => {
352 |       const mockAxeHelpers = {
353 |         getAxePath: () => null,
354 |         getBundledAxeEnvironment: () => ({}),
355 |         createAxeNotAvailableResponse: () => ({
356 |           content: [
357 |             {
358 |               type: 'text' as const,
359 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
360 |             },
361 |           ],
362 |           isError: true,
363 |         }),
364 |       };
365 | 
366 |       const result = await key_sequenceLogic(
367 |         {
368 |           simulatorId: '12345678-1234-4234-8234-123456789012',
369 |           keyCodes: [40],
370 |         },
371 |         createNoopExecutor(),
372 |         mockAxeHelpers,
373 |       );
374 | 
375 |       expect(result).toEqual({
376 |         content: [
377 |           {
378 |             type: 'text' as const,
379 |             text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
380 |           },
381 |         ],
382 |         isError: true,
383 |       });
384 |     });
385 | 
386 |     it('should handle AxeError from command execution', async () => {
387 |       const mockExecutor = createMockExecutor({
388 |         success: false,
389 |         output: '',
390 |         error: 'Simulator not found',
391 |       });
392 | 
393 |       const mockAxeHelpers = {
394 |         getAxePath: () => '/usr/local/bin/axe',
395 |         getBundledAxeEnvironment: () => ({}),
396 |         createAxeNotAvailableResponse: () => ({
397 |           content: [
398 |             {
399 |               type: 'text' as const,
400 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
401 |             },
402 |           ],
403 |           isError: true,
404 |         }),
405 |       };
406 | 
407 |       const result = await key_sequenceLogic(
408 |         {
409 |           simulatorId: '12345678-1234-4234-8234-123456789012',
410 |           keyCodes: [40],
411 |         },
412 |         mockExecutor,
413 |         mockAxeHelpers,
414 |       );
415 | 
416 |       expect(result).toEqual({
417 |         content: [
418 |           {
419 |             type: 'text' as const,
420 |             text: "Error: Failed to execute key sequence: axe command 'key-sequence' failed.\nDetails: Simulator not found",
421 |           },
422 |         ],
423 |         isError: true,
424 |       });
425 |     });
426 | 
427 |     it('should handle SystemError from command execution', async () => {
428 |       const mockExecutor = () => {
429 |         throw new Error('ENOENT: no such file or directory');
430 |       };
431 | 
432 |       const mockAxeHelpers = {
433 |         getAxePath: () => '/usr/local/bin/axe',
434 |         getBundledAxeEnvironment: () => ({}),
435 |         createAxeNotAvailableResponse: () => ({
436 |           content: [
437 |             {
438 |               type: 'text' as const,
439 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
440 |             },
441 |           ],
442 |           isError: true,
443 |         }),
444 |       };
445 | 
446 |       const result = await key_sequenceLogic(
447 |         {
448 |           simulatorId: '12345678-1234-4234-8234-123456789012',
449 |           keyCodes: [40],
450 |         },
451 |         mockExecutor,
452 |         mockAxeHelpers,
453 |       );
454 | 
455 |       expect(result.content[0].text).toMatch(
456 |         /^Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/,
457 |       );
458 |       expect(result.isError).toBe(true);
459 |     });
460 | 
461 |     it('should handle unexpected Error objects', async () => {
462 |       const mockExecutor = () => {
463 |         throw new Error('Unexpected error');
464 |       };
465 | 
466 |       const mockAxeHelpers = {
467 |         getAxePath: () => '/usr/local/bin/axe',
468 |         getBundledAxeEnvironment: () => ({}),
469 |         createAxeNotAvailableResponse: () => ({
470 |           content: [
471 |             {
472 |               type: 'text' as const,
473 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
474 |             },
475 |           ],
476 |           isError: true,
477 |         }),
478 |       };
479 | 
480 |       const result = await key_sequenceLogic(
481 |         {
482 |           simulatorId: '12345678-1234-4234-8234-123456789012',
483 |           keyCodes: [40],
484 |         },
485 |         mockExecutor,
486 |         mockAxeHelpers,
487 |       );
488 | 
489 |       expect(result.content[0].text).toMatch(
490 |         /^Error: System error executing axe: Failed to execute axe command: Unexpected error/,
491 |       );
492 |       expect(result.isError).toBe(true);
493 |     });
494 | 
495 |     it('should handle unexpected string errors', async () => {
496 |       const mockExecutor = () => {
497 |         throw 'String error';
498 |       };
499 | 
500 |       const mockAxeHelpers = {
501 |         getAxePath: () => '/usr/local/bin/axe',
502 |         getBundledAxeEnvironment: () => ({}),
503 |         createAxeNotAvailableResponse: () => ({
504 |           content: [
505 |             {
506 |               type: 'text' as const,
507 |               text: 'AXe tool not found. UI automation features are not available.\n\nInstall AXe (brew tap cameroncooke/axe && brew install axe) or set XCODEBUILDMCP_AXE_PATH.\nIf you installed via Smithery, ensure bundled artifacts are included or PATH is configured.',
508 |             },
509 |           ],
510 |           isError: true,
511 |         }),
512 |       };
513 | 
514 |       const result = await key_sequenceLogic(
515 |         {
516 |           simulatorId: '12345678-1234-4234-8234-123456789012',
517 |           keyCodes: [40],
518 |         },
519 |         mockExecutor,
520 |         mockAxeHelpers,
521 |       );
522 | 
523 |       expect(result).toEqual({
524 |         content: [
525 |           {
526 |             type: 'text' as const,
527 |             text: 'Error: System error executing axe: Failed to execute axe command: String error',
528 |           },
529 |         ],
530 |         isError: true,
531 |       });
532 |     });
533 |   });
534 | });
535 | 
```

--------------------------------------------------------------------------------
/src/core/generated-plugins.ts:
--------------------------------------------------------------------------------

```typescript
  1 | // AUTO-GENERATED - DO NOT EDIT
  2 | // This file is generated by the plugin discovery esbuild plugin
  3 | 
  4 | // Generated based on filesystem scan
  5 | export const WORKFLOW_LOADERS = {
  6 |   debugging: async () => {
  7 |     const { workflow } = await import('../mcp/tools/debugging/index.ts');
  8 |     const tool_0 = await import('../mcp/tools/debugging/debug_attach_sim.ts').then(
  9 |       (m) => m.default,
 10 |     );
 11 |     const tool_1 = await import('../mcp/tools/debugging/debug_breakpoint_add.ts').then(
 12 |       (m) => m.default,
 13 |     );
 14 |     const tool_2 = await import('../mcp/tools/debugging/debug_breakpoint_remove.ts').then(
 15 |       (m) => m.default,
 16 |     );
 17 |     const tool_3 = await import('../mcp/tools/debugging/debug_continue.ts').then((m) => m.default);
 18 |     const tool_4 = await import('../mcp/tools/debugging/debug_detach.ts').then((m) => m.default);
 19 |     const tool_5 = await import('../mcp/tools/debugging/debug_lldb_command.ts').then(
 20 |       (m) => m.default,
 21 |     );
 22 |     const tool_6 = await import('../mcp/tools/debugging/debug_stack.ts').then((m) => m.default);
 23 |     const tool_7 = await import('../mcp/tools/debugging/debug_variables.ts').then((m) => m.default);
 24 | 
 25 |     return {
 26 |       workflow,
 27 |       debug_attach_sim: tool_0,
 28 |       debug_breakpoint_add: tool_1,
 29 |       debug_breakpoint_remove: tool_2,
 30 |       debug_continue: tool_3,
 31 |       debug_detach: tool_4,
 32 |       debug_lldb_command: tool_5,
 33 |       debug_stack: tool_6,
 34 |       debug_variables: tool_7,
 35 |     };
 36 |   },
 37 |   device: async () => {
 38 |     const { workflow } = await import('../mcp/tools/device/index.ts');
 39 |     const tool_0 = await import('../mcp/tools/device/build_device.ts').then((m) => m.default);
 40 |     const tool_1 = await import('../mcp/tools/device/clean.ts').then((m) => m.default);
 41 |     const tool_2 = await import('../mcp/tools/device/discover_projs.ts').then((m) => m.default);
 42 |     const tool_3 = await import('../mcp/tools/device/get_app_bundle_id.ts').then((m) => m.default);
 43 |     const tool_4 = await import('../mcp/tools/device/get_device_app_path.ts').then(
 44 |       (m) => m.default,
 45 |     );
 46 |     const tool_5 = await import('../mcp/tools/device/install_app_device.ts').then((m) => m.default);
 47 |     const tool_6 = await import('../mcp/tools/device/launch_app_device.ts').then((m) => m.default);
 48 |     const tool_7 = await import('../mcp/tools/device/list_devices.ts').then((m) => m.default);
 49 |     const tool_8 = await import('../mcp/tools/device/list_schemes.ts').then((m) => m.default);
 50 |     const tool_9 = await import('../mcp/tools/device/show_build_settings.ts').then(
 51 |       (m) => m.default,
 52 |     );
 53 |     const tool_10 = await import('../mcp/tools/device/start_device_log_cap.ts').then(
 54 |       (m) => m.default,
 55 |     );
 56 |     const tool_11 = await import('../mcp/tools/device/stop_app_device.ts').then((m) => m.default);
 57 |     const tool_12 = await import('../mcp/tools/device/stop_device_log_cap.ts').then(
 58 |       (m) => m.default,
 59 |     );
 60 |     const tool_13 = await import('../mcp/tools/device/test_device.ts').then((m) => m.default);
 61 | 
 62 |     return {
 63 |       workflow,
 64 |       build_device: tool_0,
 65 |       clean: tool_1,
 66 |       discover_projs: tool_2,
 67 |       get_app_bundle_id: tool_3,
 68 |       get_device_app_path: tool_4,
 69 |       install_app_device: tool_5,
 70 |       launch_app_device: tool_6,
 71 |       list_devices: tool_7,
 72 |       list_schemes: tool_8,
 73 |       show_build_settings: tool_9,
 74 |       start_device_log_cap: tool_10,
 75 |       stop_app_device: tool_11,
 76 |       stop_device_log_cap: tool_12,
 77 |       test_device: tool_13,
 78 |     };
 79 |   },
 80 |   doctor: async () => {
 81 |     const { workflow } = await import('../mcp/tools/doctor/index.ts');
 82 |     const tool_0 = await import('../mcp/tools/doctor/doctor.ts').then((m) => m.default);
 83 | 
 84 |     return {
 85 |       workflow,
 86 |       doctor: tool_0,
 87 |     };
 88 |   },
 89 |   logging: async () => {
 90 |     const { workflow } = await import('../mcp/tools/logging/index.ts');
 91 |     const tool_0 = await import('../mcp/tools/logging/start_device_log_cap.ts').then(
 92 |       (m) => m.default,
 93 |     );
 94 |     const tool_1 = await import('../mcp/tools/logging/start_sim_log_cap.ts').then((m) => m.default);
 95 |     const tool_2 = await import('../mcp/tools/logging/stop_device_log_cap.ts').then(
 96 |       (m) => m.default,
 97 |     );
 98 |     const tool_3 = await import('../mcp/tools/logging/stop_sim_log_cap.ts').then((m) => m.default);
 99 | 
100 |     return {
101 |       workflow,
102 |       start_device_log_cap: tool_0,
103 |       start_sim_log_cap: tool_1,
104 |       stop_device_log_cap: tool_2,
105 |       stop_sim_log_cap: tool_3,
106 |     };
107 |   },
108 |   macos: async () => {
109 |     const { workflow } = await import('../mcp/tools/macos/index.ts');
110 |     const tool_0 = await import('../mcp/tools/macos/build_macos.ts').then((m) => m.default);
111 |     const tool_1 = await import('../mcp/tools/macos/build_run_macos.ts').then((m) => m.default);
112 |     const tool_2 = await import('../mcp/tools/macos/clean.ts').then((m) => m.default);
113 |     const tool_3 = await import('../mcp/tools/macos/discover_projs.ts').then((m) => m.default);
114 |     const tool_4 = await import('../mcp/tools/macos/get_mac_app_path.ts').then((m) => m.default);
115 |     const tool_5 = await import('../mcp/tools/macos/get_mac_bundle_id.ts').then((m) => m.default);
116 |     const tool_6 = await import('../mcp/tools/macos/launch_mac_app.ts').then((m) => m.default);
117 |     const tool_7 = await import('../mcp/tools/macos/list_schemes.ts').then((m) => m.default);
118 |     const tool_8 = await import('../mcp/tools/macos/show_build_settings.ts').then((m) => m.default);
119 |     const tool_9 = await import('../mcp/tools/macos/stop_mac_app.ts').then((m) => m.default);
120 |     const tool_10 = await import('../mcp/tools/macos/test_macos.ts').then((m) => m.default);
121 | 
122 |     return {
123 |       workflow,
124 |       build_macos: tool_0,
125 |       build_run_macos: tool_1,
126 |       clean: tool_2,
127 |       discover_projs: tool_3,
128 |       get_mac_app_path: tool_4,
129 |       get_mac_bundle_id: tool_5,
130 |       launch_mac_app: tool_6,
131 |       list_schemes: tool_7,
132 |       show_build_settings: tool_8,
133 |       stop_mac_app: tool_9,
134 |       test_macos: tool_10,
135 |     };
136 |   },
137 |   'project-discovery': async () => {
138 |     const { workflow } = await import('../mcp/tools/project-discovery/index.ts');
139 |     const tool_0 = await import('../mcp/tools/project-discovery/discover_projs.ts').then(
140 |       (m) => m.default,
141 |     );
142 |     const tool_1 = await import('../mcp/tools/project-discovery/get_app_bundle_id.ts').then(
143 |       (m) => m.default,
144 |     );
145 |     const tool_2 = await import('../mcp/tools/project-discovery/get_mac_bundle_id.ts').then(
146 |       (m) => m.default,
147 |     );
148 |     const tool_3 = await import('../mcp/tools/project-discovery/list_schemes.ts').then(
149 |       (m) => m.default,
150 |     );
151 |     const tool_4 = await import('../mcp/tools/project-discovery/show_build_settings.ts').then(
152 |       (m) => m.default,
153 |     );
154 | 
155 |     return {
156 |       workflow,
157 |       discover_projs: tool_0,
158 |       get_app_bundle_id: tool_1,
159 |       get_mac_bundle_id: tool_2,
160 |       list_schemes: tool_3,
161 |       show_build_settings: tool_4,
162 |     };
163 |   },
164 |   'project-scaffolding': async () => {
165 |     const { workflow } = await import('../mcp/tools/project-scaffolding/index.ts');
166 |     const tool_0 = await import('../mcp/tools/project-scaffolding/scaffold_ios_project.ts').then(
167 |       (m) => m.default,
168 |     );
169 |     const tool_1 = await import('../mcp/tools/project-scaffolding/scaffold_macos_project.ts').then(
170 |       (m) => m.default,
171 |     );
172 | 
173 |     return {
174 |       workflow,
175 |       scaffold_ios_project: tool_0,
176 |       scaffold_macos_project: tool_1,
177 |     };
178 |   },
179 |   'session-management': async () => {
180 |     const { workflow } = await import('../mcp/tools/session-management/index.ts');
181 |     const tool_0 = await import('../mcp/tools/session-management/session_clear_defaults.ts').then(
182 |       (m) => m.default,
183 |     );
184 |     const tool_1 = await import('../mcp/tools/session-management/session_set_defaults.ts').then(
185 |       (m) => m.default,
186 |     );
187 |     const tool_2 = await import('../mcp/tools/session-management/session_show_defaults.ts').then(
188 |       (m) => m.default,
189 |     );
190 | 
191 |     return {
192 |       workflow,
193 |       session_clear_defaults: tool_0,
194 |       session_set_defaults: tool_1,
195 |       session_show_defaults: tool_2,
196 |     };
197 |   },
198 |   simulator: async () => {
199 |     const { workflow } = await import('../mcp/tools/simulator/index.ts');
200 |     const tool_0 = await import('../mcp/tools/simulator/boot_sim.ts').then((m) => m.default);
201 |     const tool_1 = await import('../mcp/tools/simulator/build_run_sim.ts').then((m) => m.default);
202 |     const tool_2 = await import('../mcp/tools/simulator/build_sim.ts').then((m) => m.default);
203 |     const tool_3 = await import('../mcp/tools/simulator/clean.ts').then((m) => m.default);
204 |     const tool_4 = await import('../mcp/tools/simulator/describe_ui.ts').then((m) => m.default);
205 |     const tool_5 = await import('../mcp/tools/simulator/discover_projs.ts').then((m) => m.default);
206 |     const tool_6 = await import('../mcp/tools/simulator/get_app_bundle_id.ts').then(
207 |       (m) => m.default,
208 |     );
209 |     const tool_7 = await import('../mcp/tools/simulator/get_sim_app_path.ts').then(
210 |       (m) => m.default,
211 |     );
212 |     const tool_8 = await import('../mcp/tools/simulator/install_app_sim.ts').then((m) => m.default);
213 |     const tool_9 = await import('../mcp/tools/simulator/launch_app_logs_sim.ts').then(
214 |       (m) => m.default,
215 |     );
216 |     const tool_10 = await import('../mcp/tools/simulator/launch_app_sim.ts').then((m) => m.default);
217 |     const tool_11 = await import('../mcp/tools/simulator/list_schemes.ts').then((m) => m.default);
218 |     const tool_12 = await import('../mcp/tools/simulator/list_sims.ts').then((m) => m.default);
219 |     const tool_13 = await import('../mcp/tools/simulator/open_sim.ts').then((m) => m.default);
220 |     const tool_14 = await import('../mcp/tools/simulator/record_sim_video.ts').then(
221 |       (m) => m.default,
222 |     );
223 |     const tool_15 = await import('../mcp/tools/simulator/screenshot.ts').then((m) => m.default);
224 |     const tool_16 = await import('../mcp/tools/simulator/show_build_settings.ts').then(
225 |       (m) => m.default,
226 |     );
227 |     const tool_17 = await import('../mcp/tools/simulator/stop_app_sim.ts').then((m) => m.default);
228 |     const tool_18 = await import('../mcp/tools/simulator/test_sim.ts').then((m) => m.default);
229 | 
230 |     return {
231 |       workflow,
232 |       boot_sim: tool_0,
233 |       build_run_sim: tool_1,
234 |       build_sim: tool_2,
235 |       clean: tool_3,
236 |       describe_ui: tool_4,
237 |       discover_projs: tool_5,
238 |       get_app_bundle_id: tool_6,
239 |       get_sim_app_path: tool_7,
240 |       install_app_sim: tool_8,
241 |       launch_app_logs_sim: tool_9,
242 |       launch_app_sim: tool_10,
243 |       list_schemes: tool_11,
244 |       list_sims: tool_12,
245 |       open_sim: tool_13,
246 |       record_sim_video: tool_14,
247 |       screenshot: tool_15,
248 |       show_build_settings: tool_16,
249 |       stop_app_sim: tool_17,
250 |       test_sim: tool_18,
251 |     };
252 |   },
253 |   'simulator-management': async () => {
254 |     const { workflow } = await import('../mcp/tools/simulator-management/index.ts');
255 |     const tool_0 = await import('../mcp/tools/simulator-management/boot_sim.ts').then(
256 |       (m) => m.default,
257 |     );
258 |     const tool_1 = await import('../mcp/tools/simulator-management/erase_sims.ts').then(
259 |       (m) => m.default,
260 |     );
261 |     const tool_2 = await import('../mcp/tools/simulator-management/list_sims.ts').then(
262 |       (m) => m.default,
263 |     );
264 |     const tool_3 = await import('../mcp/tools/simulator-management/open_sim.ts').then(
265 |       (m) => m.default,
266 |     );
267 |     const tool_4 = await import('../mcp/tools/simulator-management/reset_sim_location.ts').then(
268 |       (m) => m.default,
269 |     );
270 |     const tool_5 = await import('../mcp/tools/simulator-management/set_sim_appearance.ts').then(
271 |       (m) => m.default,
272 |     );
273 |     const tool_6 = await import('../mcp/tools/simulator-management/set_sim_location.ts').then(
274 |       (m) => m.default,
275 |     );
276 |     const tool_7 = await import('../mcp/tools/simulator-management/sim_statusbar.ts').then(
277 |       (m) => m.default,
278 |     );
279 | 
280 |     return {
281 |       workflow,
282 |       boot_sim: tool_0,
283 |       erase_sims: tool_1,
284 |       list_sims: tool_2,
285 |       open_sim: tool_3,
286 |       reset_sim_location: tool_4,
287 |       set_sim_appearance: tool_5,
288 |       set_sim_location: tool_6,
289 |       sim_statusbar: tool_7,
290 |     };
291 |   },
292 |   'swift-package': async () => {
293 |     const { workflow } = await import('../mcp/tools/swift-package/index.ts');
294 |     const tool_0 = await import('../mcp/tools/swift-package/swift_package_build.ts').then(
295 |       (m) => m.default,
296 |     );
297 |     const tool_1 = await import('../mcp/tools/swift-package/swift_package_clean.ts').then(
298 |       (m) => m.default,
299 |     );
300 |     const tool_2 = await import('../mcp/tools/swift-package/swift_package_list.ts').then(
301 |       (m) => m.default,
302 |     );
303 |     const tool_3 = await import('../mcp/tools/swift-package/swift_package_run.ts').then(
304 |       (m) => m.default,
305 |     );
306 |     const tool_4 = await import('../mcp/tools/swift-package/swift_package_stop.ts').then(
307 |       (m) => m.default,
308 |     );
309 |     const tool_5 = await import('../mcp/tools/swift-package/swift_package_test.ts').then(
310 |       (m) => m.default,
311 |     );
312 | 
313 |     return {
314 |       workflow,
315 |       swift_package_build: tool_0,
316 |       swift_package_clean: tool_1,
317 |       swift_package_list: tool_2,
318 |       swift_package_run: tool_3,
319 |       swift_package_stop: tool_4,
320 |       swift_package_test: tool_5,
321 |     };
322 |   },
323 |   'ui-testing': async () => {
324 |     const { workflow } = await import('../mcp/tools/ui-testing/index.ts');
325 |     const tool_0 = await import('../mcp/tools/ui-testing/button.ts').then((m) => m.default);
326 |     const tool_1 = await import('../mcp/tools/ui-testing/describe_ui.ts').then((m) => m.default);
327 |     const tool_2 = await import('../mcp/tools/ui-testing/gesture.ts').then((m) => m.default);
328 |     const tool_3 = await import('../mcp/tools/ui-testing/key_press.ts').then((m) => m.default);
329 |     const tool_4 = await import('../mcp/tools/ui-testing/key_sequence.ts').then((m) => m.default);
330 |     const tool_5 = await import('../mcp/tools/ui-testing/long_press.ts').then((m) => m.default);
331 |     const tool_6 = await import('../mcp/tools/ui-testing/screenshot.ts').then((m) => m.default);
332 |     const tool_7 = await import('../mcp/tools/ui-testing/swipe.ts').then((m) => m.default);
333 |     const tool_8 = await import('../mcp/tools/ui-testing/tap.ts').then((m) => m.default);
334 |     const tool_9 = await import('../mcp/tools/ui-testing/touch.ts').then((m) => m.default);
335 |     const tool_10 = await import('../mcp/tools/ui-testing/type_text.ts').then((m) => m.default);
336 | 
337 |     return {
338 |       workflow,
339 |       button: tool_0,
340 |       describe_ui: tool_1,
341 |       gesture: tool_2,
342 |       key_press: tool_3,
343 |       key_sequence: tool_4,
344 |       long_press: tool_5,
345 |       screenshot: tool_6,
346 |       swipe: tool_7,
347 |       tap: tool_8,
348 |       touch: tool_9,
349 |       type_text: tool_10,
350 |     };
351 |   },
352 |   utilities: async () => {
353 |     const { workflow } = await import('../mcp/tools/utilities/index.ts');
354 |     const tool_0 = await import('../mcp/tools/utilities/clean.ts').then((m) => m.default);
355 | 
356 |     return {
357 |       workflow,
358 |       clean: tool_0,
359 |     };
360 |   },
361 | };
362 | 
363 | export type WorkflowName = keyof typeof WORKFLOW_LOADERS;
364 | 
365 | // Optional: Export workflow metadata for quick access
366 | export const WORKFLOW_METADATA = {
367 |   debugging: {
368 |     name: 'Simulator Debugging',
369 |     description:
370 |       'Interactive iOS Simulator debugging tools: attach LLDB, manage breakpoints, inspect stack/variables, and run LLDB commands.',
371 |   },
372 |   device: {
373 |     name: 'iOS Device Development',
374 |     description:
375 |       'Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Build, test, deploy, and debug apps on real hardware.',
376 |   },
377 |   doctor: {
378 |     name: 'System Doctor',
379 |     description:
380 |       'Debug tools and system doctor for troubleshooting XcodeBuildMCP server, development environment, and tool availability.',
381 |   },
382 |   logging: {
383 |     name: 'Log Capture & Management',
384 |     description:
385 |       'Log capture and management tools for iOS simulators and physical devices. Start, stop, and analyze application and system logs during development and testing.',
386 |   },
387 |   macos: {
388 |     name: 'macOS Development',
389 |     description:
390 |       'Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications.',
391 |   },
392 |   'project-discovery': {
393 |     name: 'Project Discovery',
394 |     description:
395 |       'Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information.',
396 |   },
397 |   'project-scaffolding': {
398 |     name: 'Project Scaffolding',
399 |     description:
400 |       'Tools for creating new iOS and macOS projects from templates. Bootstrap new applications with best practices, standard configurations, and modern project structures.',
401 |   },
402 |   'session-management': {
403 |     name: 'session-management',
404 |     description:
405 |       'Manage session defaults for projectPath/workspacePath, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS and arch. These defaults are required by many tools and must be set before attempting to call tools that would depend on these values.',
406 |   },
407 |   simulator: {
408 |     name: 'iOS Simulator Development',
409 |     description:
410 |       'Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators.',
411 |   },
412 |   'simulator-management': {
413 |     name: 'Simulator Management',
414 |     description:
415 |       'Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance.',
416 |   },
417 |   'swift-package': {
418 |     name: 'Swift Package Manager',
419 |     description:
420 |       'Swift Package Manager operations for building, testing, running, and managing Swift packages and dependencies. Complete SPM workflow support.',
421 |   },
422 |   'ui-testing': {
423 |     name: 'UI Testing & Automation',
424 |     description:
425 |       'UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows.',
426 |   },
427 |   utilities: {
428 |     name: 'Project Utilities',
429 |     description:
430 |       'Essential project maintenance utilities for cleaning and managing existing projects. Provides clean operations for both .xcodeproj and .xcworkspace files.',
431 |   },
432 | };
433 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Tests for build_run_sim plugin (unified)
  3 |  * Following CLAUDE.md testing standards with dependency injection and literal validation
  4 |  */
  5 | 
  6 | import { describe, it, expect, beforeEach } from 'vitest';
  7 | import * as z from 'zod';
  8 | import {
  9 |   createMockExecutor,
 10 |   createMockCommandResponse,
 11 | } from '../../../../test-utils/mock-executors.ts';
 12 | import type { CommandExecutor } from '../../../../utils/execution/index.ts';
 13 | import { sessionStore } from '../../../../utils/session-store.ts';
 14 | import buildRunSim, { build_run_simLogic } from '../build_run_sim.ts';
 15 | 
 16 | describe('build_run_sim tool', () => {
 17 |   beforeEach(() => {
 18 |     sessionStore.clear();
 19 |   });
 20 | 
 21 |   describe('Export Field Validation (Literal)', () => {
 22 |     it('should have correct name', () => {
 23 |       expect(buildRunSim.name).toBe('build_run_sim');
 24 |     });
 25 | 
 26 |     it('should have correct description', () => {
 27 |       expect(buildRunSim.description).toBe('Builds and runs an app on an iOS simulator.');
 28 |     });
 29 | 
 30 |     it('should have handler function', () => {
 31 |       expect(typeof buildRunSim.handler).toBe('function');
 32 |     });
 33 | 
 34 |     it('should expose only non-session fields in public schema', () => {
 35 |       const schema = z.object(buildRunSim.schema);
 36 | 
 37 |       expect(schema.safeParse({}).success).toBe(true);
 38 | 
 39 |       expect(
 40 |         schema.safeParse({
 41 |           derivedDataPath: '/path/to/derived',
 42 |           extraArgs: ['--verbose'],
 43 |           preferXcodebuild: false,
 44 |         }).success,
 45 |       ).toBe(true);
 46 | 
 47 |       expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false);
 48 |       expect(schema.safeParse({ extraArgs: [123] }).success).toBe(false);
 49 |       expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false);
 50 | 
 51 |       const schemaKeys = Object.keys(buildRunSim.schema).sort();
 52 |       expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild'].sort());
 53 |       expect(schemaKeys).not.toContain('scheme');
 54 |       expect(schemaKeys).not.toContain('simulatorName');
 55 |       expect(schemaKeys).not.toContain('projectPath');
 56 |     });
 57 |   });
 58 | 
 59 |   describe('Handler Behavior (Complete Literal Returns)', () => {
 60 |     // Note: Parameter validation is now handled by createTypedTool wrapper with Zod schema
 61 |     // The logic function receives validated parameters, so these tests focus on business logic
 62 | 
 63 |     it('should handle simulator not found', async () => {
 64 |       let callCount = 0;
 65 |       const mockExecutor: CommandExecutor = async (command) => {
 66 |         callCount++;
 67 |         if (callCount === 1) {
 68 |           // First call: build succeeds
 69 |           return createMockCommandResponse({
 70 |             success: true,
 71 |             output: 'BUILD SUCCEEDED',
 72 |           });
 73 |         } else if (callCount === 2) {
 74 |           // Second call: showBuildSettings fails to get app path
 75 |           return createMockCommandResponse({
 76 |             success: false,
 77 |             error: 'Could not get build settings',
 78 |           });
 79 |         }
 80 |         return createMockCommandResponse({
 81 |           success: false,
 82 |           error: 'Unexpected call',
 83 |         });
 84 |       };
 85 | 
 86 |       const result = await build_run_simLogic(
 87 |         {
 88 |           workspacePath: '/path/to/workspace',
 89 |           scheme: 'MyScheme',
 90 |           simulatorName: 'iPhone 16',
 91 |         },
 92 |         mockExecutor,
 93 |       );
 94 | 
 95 |       expect(result).toEqual({
 96 |         content: [
 97 |           {
 98 |             type: 'text',
 99 |             text: 'Build succeeded, but failed to get app path: Could not get build settings',
100 |           },
101 |         ],
102 |         isError: true,
103 |       });
104 |     });
105 | 
106 |     it('should handle build failure', async () => {
107 |       const mockExecutor = createMockExecutor({
108 |         success: false,
109 |         error: 'Build failed with error',
110 |       });
111 | 
112 |       const result = await build_run_simLogic(
113 |         {
114 |           workspacePath: '/path/to/workspace',
115 |           scheme: 'MyScheme',
116 |           simulatorName: 'iPhone 16',
117 |         },
118 |         mockExecutor,
119 |       );
120 | 
121 |       expect(result.isError).toBe(true);
122 |       expect(result.content).toBeDefined();
123 |       expect(Array.isArray(result.content)).toBe(true);
124 |     });
125 | 
126 |     it('should handle successful build and run', async () => {
127 |       // Create a mock executor that simulates full successful flow
128 |       let callCount = 0;
129 |       const mockExecutor: CommandExecutor = async (command) => {
130 |         callCount++;
131 | 
132 |         if (command.includes('xcodebuild') && command.includes('build')) {
133 |           // First call: build succeeds
134 |           return createMockCommandResponse({
135 |             success: true,
136 |             output: 'BUILD SUCCEEDED',
137 |           });
138 |         } else if (command.includes('xcodebuild') && command.includes('-showBuildSettings')) {
139 |           // Second call: build settings to get app path
140 |           return createMockCommandResponse({
141 |             success: true,
142 |             output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app\n',
143 |           });
144 |         } else if (command.includes('simctl') && command.includes('list')) {
145 |           // Find simulator calls
146 |           return createMockCommandResponse({
147 |             success: true,
148 |             output: JSON.stringify({
149 |               devices: {
150 |                 'iOS 16.0': [
151 |                   {
152 |                     udid: 'test-uuid-123',
153 |                     name: 'iPhone 16',
154 |                     state: 'Booted',
155 |                     isAvailable: true,
156 |                   },
157 |                 ],
158 |               },
159 |             }),
160 |           });
161 |         } else if (
162 |           command.includes('plutil') ||
163 |           command.includes('PlistBuddy') ||
164 |           command.includes('defaults')
165 |         ) {
166 |           // Bundle ID extraction
167 |           return createMockCommandResponse({
168 |             success: true,
169 |             output: 'com.example.MyApp',
170 |           });
171 |         } else {
172 |           // All other commands (boot, open, install, launch) succeed
173 |           return createMockCommandResponse({
174 |             success: true,
175 |             output: 'Success',
176 |           });
177 |         }
178 |       };
179 | 
180 |       const result = await build_run_simLogic(
181 |         {
182 |           workspacePath: '/path/to/workspace',
183 |           scheme: 'MyScheme',
184 |           simulatorName: 'iPhone 16',
185 |         },
186 |         mockExecutor,
187 |       );
188 | 
189 |       expect(result.content).toBeDefined();
190 |       expect(Array.isArray(result.content)).toBe(true);
191 |       expect(result.isError).toBe(false);
192 |     });
193 | 
194 |     it('should handle exception with Error object', async () => {
195 |       const mockExecutor = createMockExecutor({
196 |         success: false,
197 |         error: 'Command failed',
198 |       });
199 | 
200 |       const result = await build_run_simLogic(
201 |         {
202 |           workspacePath: '/path/to/workspace',
203 |           scheme: 'MyScheme',
204 |           simulatorName: 'iPhone 16',
205 |         },
206 |         mockExecutor,
207 |       );
208 | 
209 |       expect(result.isError).toBe(true);
210 |       expect(result.content).toBeDefined();
211 |       expect(Array.isArray(result.content)).toBe(true);
212 |     });
213 | 
214 |     it('should handle exception with string error', async () => {
215 |       const mockExecutor = createMockExecutor({
216 |         success: false,
217 |         error: 'String error',
218 |       });
219 | 
220 |       const result = await build_run_simLogic(
221 |         {
222 |           workspacePath: '/path/to/workspace',
223 |           scheme: 'MyScheme',
224 |           simulatorName: 'iPhone 16',
225 |         },
226 |         mockExecutor,
227 |       );
228 | 
229 |       expect(result.isError).toBe(true);
230 |       expect(result.content).toBeDefined();
231 |       expect(Array.isArray(result.content)).toBe(true);
232 |     });
233 |   });
234 | 
235 |   describe('Command Generation', () => {
236 |     it('should generate correct simctl list command with minimal parameters', async () => {
237 |       const callHistory: Array<{
238 |         command: string[];
239 |         logPrefix?: string;
240 |         useShell?: boolean;
241 |         opts?: { env?: Record<string, string>; cwd?: string };
242 |       }> = [];
243 | 
244 |       // Create tracking executor
245 |       const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => {
246 |         callHistory.push({ command, logPrefix, useShell, opts });
247 |         return createMockCommandResponse({
248 |           success: false,
249 |           output: '',
250 |           error: 'Test error to stop execution early',
251 |         });
252 |       };
253 | 
254 |       const result = await build_run_simLogic(
255 |         {
256 |           workspacePath: '/path/to/MyProject.xcworkspace',
257 |           scheme: 'MyScheme',
258 |           simulatorName: 'iPhone 16',
259 |         },
260 |         trackingExecutor,
261 |       );
262 | 
263 |       // Should generate the initial build command
264 |       expect(callHistory).toHaveLength(1);
265 |       expect(callHistory[0].command).toEqual([
266 |         'xcodebuild',
267 |         '-workspace',
268 |         '/path/to/MyProject.xcworkspace',
269 |         '-scheme',
270 |         'MyScheme',
271 |         '-configuration',
272 |         'Debug',
273 |         '-skipMacroValidation',
274 |         '-destination',
275 |         'platform=iOS Simulator,name=iPhone 16,OS=latest',
276 |         'build',
277 |       ]);
278 |       expect(callHistory[0].logPrefix).toBe('iOS Simulator Build');
279 |     });
280 | 
281 |     it('should generate correct build command after finding simulator', async () => {
282 |       const callHistory: Array<{
283 |         command: string[];
284 |         logPrefix?: string;
285 |         useShell?: boolean;
286 |         opts?: { env?: Record<string, string>; cwd?: string };
287 |       }> = [];
288 | 
289 |       let callCount = 0;
290 |       // Create tracking executor that succeeds on first call (list) and fails on second
291 |       const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => {
292 |         callHistory.push({ command, logPrefix, useShell, opts });
293 |         callCount++;
294 | 
295 |         if (callCount === 1) {
296 |           // First call: simulator list succeeds
297 |           return createMockCommandResponse({
298 |             success: true,
299 |             output: JSON.stringify({
300 |               devices: {
301 |                 'iOS 16.0': [
302 |                   {
303 |                     udid: 'test-uuid-123',
304 |                     name: 'iPhone 16',
305 |                     state: 'Booted',
306 |                   },
307 |                 ],
308 |               },
309 |             }),
310 |             error: undefined,
311 |           });
312 |         } else {
313 |           // Second call: build command fails to stop execution
314 |           return createMockCommandResponse({
315 |             success: false,
316 |             output: '',
317 |             error: 'Test error to stop execution',
318 |           });
319 |         }
320 |       };
321 | 
322 |       const result = await build_run_simLogic(
323 |         {
324 |           workspacePath: '/path/to/MyProject.xcworkspace',
325 |           scheme: 'MyScheme',
326 |           simulatorName: 'iPhone 16',
327 |         },
328 |         trackingExecutor,
329 |       );
330 | 
331 |       // Should generate build command and then build settings command
332 |       expect(callHistory).toHaveLength(2);
333 | 
334 |       // First call: build command
335 |       expect(callHistory[0].command).toEqual([
336 |         'xcodebuild',
337 |         '-workspace',
338 |         '/path/to/MyProject.xcworkspace',
339 |         '-scheme',
340 |         'MyScheme',
341 |         '-configuration',
342 |         'Debug',
343 |         '-skipMacroValidation',
344 |         '-destination',
345 |         'platform=iOS Simulator,name=iPhone 16,OS=latest',
346 |         'build',
347 |       ]);
348 |       expect(callHistory[0].logPrefix).toBe('iOS Simulator Build');
349 | 
350 |       // Second call: build settings command to get app path
351 |       expect(callHistory[1].command).toEqual([
352 |         'xcodebuild',
353 |         '-showBuildSettings',
354 |         '-workspace',
355 |         '/path/to/MyProject.xcworkspace',
356 |         '-scheme',
357 |         'MyScheme',
358 |         '-configuration',
359 |         'Debug',
360 |         '-destination',
361 |         'platform=iOS Simulator,name=iPhone 16,OS=latest',
362 |       ]);
363 |       expect(callHistory[1].logPrefix).toBe('Get App Path');
364 |     });
365 | 
366 |     it('should generate correct build settings command after successful build', async () => {
367 |       const callHistory: Array<{
368 |         command: string[];
369 |         logPrefix?: string;
370 |         useShell?: boolean;
371 |         opts?: { env?: Record<string, string>; cwd?: string };
372 |       }> = [];
373 | 
374 |       let callCount = 0;
375 |       // Create tracking executor that succeeds on first two calls and fails on third
376 |       const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => {
377 |         callHistory.push({ command, logPrefix, useShell, opts });
378 |         callCount++;
379 | 
380 |         if (callCount === 1) {
381 |           // First call: simulator list succeeds
382 |           return createMockCommandResponse({
383 |             success: true,
384 |             output: JSON.stringify({
385 |               devices: {
386 |                 'iOS 16.0': [
387 |                   {
388 |                     udid: 'test-uuid-123',
389 |                     name: 'iPhone 16',
390 |                     state: 'Booted',
391 |                   },
392 |                 ],
393 |               },
394 |             }),
395 |             error: undefined,
396 |           });
397 |         } else if (callCount === 2) {
398 |           // Second call: build command succeeds
399 |           return createMockCommandResponse({
400 |             success: true,
401 |             output: 'BUILD SUCCEEDED',
402 |             error: undefined,
403 |           });
404 |         } else {
405 |           // Third call: build settings command fails to stop execution
406 |           return createMockCommandResponse({
407 |             success: false,
408 |             output: '',
409 |             error: 'Test error to stop execution',
410 |           });
411 |         }
412 |       };
413 | 
414 |       const result = await build_run_simLogic(
415 |         {
416 |           workspacePath: '/path/to/MyProject.xcworkspace',
417 |           scheme: 'MyScheme',
418 |           simulatorName: 'iPhone 16',
419 |           configuration: 'Release',
420 |           useLatestOS: false,
421 |         },
422 |         trackingExecutor,
423 |       );
424 | 
425 |       // Should generate build command and build settings command
426 |       expect(callHistory).toHaveLength(2);
427 | 
428 |       // First call: build command
429 |       expect(callHistory[0].command).toEqual([
430 |         'xcodebuild',
431 |         '-workspace',
432 |         '/path/to/MyProject.xcworkspace',
433 |         '-scheme',
434 |         'MyScheme',
435 |         '-configuration',
436 |         'Release',
437 |         '-skipMacroValidation',
438 |         '-destination',
439 |         'platform=iOS Simulator,name=iPhone 16',
440 |         'build',
441 |       ]);
442 |       expect(callHistory[0].logPrefix).toBe('iOS Simulator Build');
443 | 
444 |       // Second call: build settings command
445 |       expect(callHistory[1].command).toEqual([
446 |         'xcodebuild',
447 |         '-showBuildSettings',
448 |         '-workspace',
449 |         '/path/to/MyProject.xcworkspace',
450 |         '-scheme',
451 |         'MyScheme',
452 |         '-configuration',
453 |         'Release',
454 |         '-destination',
455 |         'platform=iOS Simulator,name=iPhone 16',
456 |       ]);
457 |       expect(callHistory[1].logPrefix).toBe('Get App Path');
458 |     });
459 | 
460 |     it('should handle paths with spaces in command generation', async () => {
461 |       const callHistory: Array<{
462 |         command: string[];
463 |         logPrefix?: string;
464 |         useShell?: boolean;
465 |         opts?: { env?: Record<string, string>; cwd?: string };
466 |       }> = [];
467 | 
468 |       // Create tracking executor
469 |       const trackingExecutor: CommandExecutor = async (command, logPrefix, useShell, opts) => {
470 |         callHistory.push({ command, logPrefix, useShell, opts });
471 |         return createMockCommandResponse({
472 |           success: false,
473 |           output: '',
474 |           error: 'Test error to stop execution early',
475 |         });
476 |       };
477 | 
478 |       const result = await build_run_simLogic(
479 |         {
480 |           workspacePath: '/Users/dev/My Project/MyProject.xcworkspace',
481 |           scheme: 'My Scheme',
482 |           simulatorName: 'iPhone 16 Pro',
483 |         },
484 |         trackingExecutor,
485 |       );
486 | 
487 |       // Should generate build command first
488 |       expect(callHistory).toHaveLength(1);
489 |       expect(callHistory[0].command).toEqual([
490 |         'xcodebuild',
491 |         '-workspace',
492 |         '/Users/dev/My Project/MyProject.xcworkspace',
493 |         '-scheme',
494 |         'My Scheme',
495 |         '-configuration',
496 |         'Debug',
497 |         '-skipMacroValidation',
498 |         '-destination',
499 |         'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest',
500 |         'build',
501 |       ]);
502 |       expect(callHistory[0].logPrefix).toBe('iOS Simulator Build');
503 |     });
504 |   });
505 | 
506 |   describe('XOR Validation', () => {
507 |     it('should error when neither projectPath nor workspacePath provided', async () => {
508 |       const result = await buildRunSim.handler({
509 |         scheme: 'MyScheme',
510 |         simulatorName: 'iPhone 16',
511 |       });
512 |       expect(result.isError).toBe(true);
513 |       expect(result.content[0].text).toContain('Missing required session defaults');
514 |       expect(result.content[0].text).toContain('Provide a project or workspace');
515 |     });
516 | 
517 |     it('should error when both projectPath and workspacePath provided', async () => {
518 |       const result = await buildRunSim.handler({
519 |         projectPath: '/path/project.xcodeproj',
520 |         workspacePath: '/path/workspace.xcworkspace',
521 |         scheme: 'MyScheme',
522 |         simulatorName: 'iPhone 16',
523 |       });
524 |       expect(result.isError).toBe(true);
525 |       expect(result.content[0].text).toContain('Parameter validation failed');
526 |       expect(result.content[0].text).toContain('Mutually exclusive parameters provided');
527 |       expect(result.content[0].text).toContain('projectPath');
528 |       expect(result.content[0].text).toContain('workspacePath');
529 |     });
530 | 
531 |     it('should succeed with only projectPath', async () => {
532 |       // This test fails early due to build failure, which is expected behavior
533 |       const mockExecutor = createMockExecutor({
534 |         success: false,
535 |         error: 'Build failed',
536 |       });
537 | 
538 |       const result = await build_run_simLogic(
539 |         {
540 |           projectPath: '/path/project.xcodeproj',
541 |           scheme: 'MyScheme',
542 |           simulatorName: 'iPhone 16',
543 |         },
544 |         mockExecutor,
545 |       );
546 |       // The test succeeds if the logic function accepts the parameters and attempts to build
547 |       expect(result.isError).toBe(true);
548 |       expect(result.content[0].text).toContain('Build failed');
549 |     });
550 | 
551 |     it('should succeed with only workspacePath', async () => {
552 |       // This test fails early due to build failure, which is expected behavior
553 |       const mockExecutor = createMockExecutor({
554 |         success: false,
555 |         error: 'Build failed',
556 |       });
557 | 
558 |       const result = await build_run_simLogic(
559 |         {
560 |           workspacePath: '/path/workspace.xcworkspace',
561 |           scheme: 'MyScheme',
562 |           simulatorName: 'iPhone 16',
563 |         },
564 |         mockExecutor,
565 |       );
566 |       // The test succeeds if the logic function accepts the parameters and attempts to build
567 |       expect(result.isError).toBe(true);
568 |       expect(result.content[0].text).toContain('Build failed');
569 |     });
570 |   });
571 | });
572 | 
```

--------------------------------------------------------------------------------
/src/mcp/tools/simulator/build_run_sim.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Simulator Build & Run Plugin: Build Run Simulator (Unified)
  3 |  *
  4 |  * Builds and runs an app from a project or workspace on a specific simulator by UUID or name.
  5 |  * Accepts mutually exclusive `projectPath` or `workspacePath`.
  6 |  * Accepts mutually exclusive `simulatorId` or `simulatorName`.
  7 |  */
  8 | 
  9 | import * as z from 'zod';
 10 | import { ToolResponse, SharedBuildParams, XcodePlatform } from '../../../types/common.ts';
 11 | import { log } from '../../../utils/logging/index.ts';
 12 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
 13 | import {
 14 |   createSessionAwareTool,
 15 |   getSessionAwareToolSchemaShape,
 16 | } from '../../../utils/typed-tool-factory.ts';
 17 | import { createTextResponse } from '../../../utils/responses/index.ts';
 18 | import { executeXcodeBuildCommand } from '../../../utils/build/index.ts';
 19 | import type { CommandExecutor } from '../../../utils/execution/index.ts';
 20 | import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts';
 21 | import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts';
 22 | 
 23 | // Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName
 24 | const baseOptions = {
 25 |   scheme: z.string().describe('The scheme to use (Required)'),
 26 |   simulatorId: z
 27 |     .string()
 28 |     .optional()
 29 |     .describe(
 30 |       'UUID of the simulator (from list_sims). Provide EITHER this OR simulatorName, not both',
 31 |     ),
 32 |   simulatorName: z
 33 |     .string()
 34 |     .optional()
 35 |     .describe(
 36 |       "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both",
 37 |     ),
 38 |   configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'),
 39 |   derivedDataPath: z
 40 |     .string()
 41 |     .optional()
 42 |     .describe('Path where build products and other derived data will go'),
 43 |   extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'),
 44 |   useLatestOS: z
 45 |     .boolean()
 46 |     .optional()
 47 |     .describe('Whether to use the latest OS version for the named simulator'),
 48 |   preferXcodebuild: z
 49 |     .boolean()
 50 |     .optional()
 51 |     .describe(
 52 |       'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.',
 53 |     ),
 54 | };
 55 | 
 56 | const baseSchemaObject = z.object({
 57 |   projectPath: z
 58 |     .string()
 59 |     .optional()
 60 |     .describe('Path to .xcodeproj file. Provide EITHER this OR workspacePath, not both'),
 61 |   workspacePath: z
 62 |     .string()
 63 |     .optional()
 64 |     .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'),
 65 |   ...baseOptions,
 66 | });
 67 | 
 68 | const buildRunSimulatorSchema = z.preprocess(
 69 |   nullifyEmptyStrings,
 70 |   baseSchemaObject
 71 |     .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, {
 72 |       message: 'Either projectPath or workspacePath is required.',
 73 |     })
 74 |     .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), {
 75 |       message: 'projectPath and workspacePath are mutually exclusive. Provide only one.',
 76 |     })
 77 |     .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, {
 78 |       message: 'Either simulatorId or simulatorName is required.',
 79 |     })
 80 |     .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), {
 81 |       message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.',
 82 |     }),
 83 | );
 84 | 
 85 | export type BuildRunSimulatorParams = z.infer<typeof buildRunSimulatorSchema>;
 86 | 
 87 | // Internal logic for building Simulator apps.
 88 | async function _handleSimulatorBuildLogic(
 89 |   params: BuildRunSimulatorParams,
 90 |   executor: CommandExecutor,
 91 |   executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand,
 92 | ): Promise<ToolResponse> {
 93 |   const projectType = params.projectPath ? 'project' : 'workspace';
 94 |   const filePath = params.projectPath ?? params.workspacePath;
 95 | 
 96 |   // Log warning if useLatestOS is provided with simulatorId
 97 |   if (params.simulatorId && params.useLatestOS !== undefined) {
 98 |     log(
 99 |       'warning',
100 |       `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`,
101 |     );
102 |   }
103 | 
104 |   log(
105 |     'info',
106 |     `Starting iOS Simulator build for scheme ${params.scheme} from ${projectType}: ${filePath}`,
107 |   );
108 | 
109 |   // Create SharedBuildParams object with required configuration property
110 |   const sharedBuildParams: SharedBuildParams = {
111 |     workspacePath: params.workspacePath,
112 |     projectPath: params.projectPath,
113 |     scheme: params.scheme,
114 |     configuration: params.configuration ?? 'Debug',
115 |     derivedDataPath: params.derivedDataPath,
116 |     extraArgs: params.extraArgs,
117 |   };
118 | 
119 |   return executeXcodeBuildCommandFn(
120 |     sharedBuildParams,
121 |     {
122 |       platform: XcodePlatform.iOSSimulator,
123 |       simulatorId: params.simulatorId,
124 |       simulatorName: params.simulatorName,
125 |       useLatestOS: params.simulatorId ? false : params.useLatestOS,
126 |       logPrefix: 'iOS Simulator Build',
127 |     },
128 |     params.preferXcodebuild as boolean,
129 |     'build',
130 |     executor,
131 |   );
132 | }
133 | 
134 | // Exported business logic function for building and running iOS Simulator apps.
135 | export async function build_run_simLogic(
136 |   params: BuildRunSimulatorParams,
137 |   executor: CommandExecutor,
138 |   executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand,
139 | ): Promise<ToolResponse> {
140 |   const projectType = params.projectPath ? 'project' : 'workspace';
141 |   const filePath = params.projectPath ?? params.workspacePath;
142 | 
143 |   log(
144 |     'info',
145 |     `Starting iOS Simulator build and run for scheme ${params.scheme} from ${projectType}: ${filePath}`,
146 |   );
147 | 
148 |   try {
149 |     // --- Build Step ---
150 |     const buildResult = await _handleSimulatorBuildLogic(
151 |       params,
152 |       executor,
153 |       executeXcodeBuildCommandFn,
154 |     );
155 | 
156 |     if (buildResult.isError) {
157 |       return buildResult; // Return the build error
158 |     }
159 | 
160 |     // --- Get App Path Step ---
161 |     // Create the command array for xcodebuild with -showBuildSettings option
162 |     const command = ['xcodebuild', '-showBuildSettings'];
163 | 
164 |     // Add the workspace or project
165 |     if (params.workspacePath) {
166 |       command.push('-workspace', params.workspacePath);
167 |     } else if (params.projectPath) {
168 |       command.push('-project', params.projectPath);
169 |     }
170 | 
171 |     // Add the scheme and configuration
172 |     command.push('-scheme', params.scheme);
173 |     command.push('-configuration', params.configuration ?? 'Debug');
174 | 
175 |     // Handle destination for simulator
176 |     let destinationString: string;
177 |     if (params.simulatorId) {
178 |       destinationString = `platform=iOS Simulator,id=${params.simulatorId}`;
179 |     } else if (params.simulatorName) {
180 |       destinationString = `platform=iOS Simulator,name=${params.simulatorName}${(params.useLatestOS ?? true) ? ',OS=latest' : ''}`;
181 |     } else {
182 |       // This shouldn't happen due to validation, but handle it
183 |       destinationString = 'platform=iOS Simulator';
184 |     }
185 |     command.push('-destination', destinationString);
186 | 
187 |     // Add derived data path if provided
188 |     if (params.derivedDataPath) {
189 |       command.push('-derivedDataPath', params.derivedDataPath);
190 |     }
191 | 
192 |     // Add extra args if provided
193 |     if (params.extraArgs && params.extraArgs.length > 0) {
194 |       command.push(...params.extraArgs);
195 |     }
196 | 
197 |     // Execute the command directly
198 |     const result = await executor(command, 'Get App Path', true, undefined);
199 | 
200 |     // If there was an error with the command execution, return it
201 |     if (!result.success) {
202 |       return createTextResponse(
203 |         `Build succeeded, but failed to get app path: ${result.error ?? 'Unknown error'}`,
204 |         true,
205 |       );
206 |     }
207 | 
208 |     // Parse the output to extract the app path
209 |     const buildSettingsOutput = result.output;
210 | 
211 |     // Try both approaches to get app path - first the project approach (CODESIGNING_FOLDER_PATH)
212 |     let appBundlePath: string | null = null;
213 | 
214 |     // Project approach: Extract CODESIGNING_FOLDER_PATH from build settings to get app path
215 |     const appPathMatch = buildSettingsOutput.match(/CODESIGNING_FOLDER_PATH = (.+\.app)/);
216 |     if (appPathMatch?.[1]) {
217 |       appBundlePath = appPathMatch[1].trim();
218 |     } else {
219 |       // Workspace approach: Extract BUILT_PRODUCTS_DIR and FULL_PRODUCT_NAME
220 |       const builtProductsDirMatch = buildSettingsOutput.match(
221 |         /^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m,
222 |       );
223 |       const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m);
224 | 
225 |       if (builtProductsDirMatch && fullProductNameMatch) {
226 |         const builtProductsDir = builtProductsDirMatch[1].trim();
227 |         const fullProductName = fullProductNameMatch[1].trim();
228 |         appBundlePath = `${builtProductsDir}/${fullProductName}`;
229 |       }
230 |     }
231 | 
232 |     if (!appBundlePath) {
233 |       return createTextResponse(
234 |         `Build succeeded, but could not find app path in build settings.`,
235 |         true,
236 |       );
237 |     }
238 | 
239 |     log('info', `App bundle path for run: ${appBundlePath}`);
240 | 
241 |     // --- Find/Boot Simulator Step ---
242 |     // Use our helper to determine the simulator UUID
243 |     const uuidResult = await determineSimulatorUuid(
244 |       { simulatorId: params.simulatorId, simulatorName: params.simulatorName },
245 |       executor,
246 |     );
247 | 
248 |     if (uuidResult.error) {
249 |       return createTextResponse(`Build succeeded, but ${uuidResult.error.content[0].text}`, true);
250 |     }
251 | 
252 |     if (uuidResult.warning) {
253 |       log('warning', uuidResult.warning);
254 |     }
255 | 
256 |     const simulatorId = uuidResult.uuid;
257 | 
258 |     if (!simulatorId) {
259 |       return createTextResponse(
260 |         'Build succeeded, but no simulator specified and failed to find a suitable one.',
261 |         true,
262 |       );
263 |     }
264 | 
265 |     // Check simulator state and boot if needed
266 |     try {
267 |       log('info', `Checking simulator state for UUID: ${simulatorId}`);
268 |       const simulatorListResult = await executor(
269 |         ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'],
270 |         'List Simulators',
271 |       );
272 |       if (!simulatorListResult.success) {
273 |         throw new Error(simulatorListResult.error ?? 'Failed to list simulators');
274 |       }
275 | 
276 |       const simulatorsData = JSON.parse(simulatorListResult.output) as {
277 |         devices: Record<string, unknown[]>;
278 |       };
279 |       let targetSimulator: { udid: string; name: string; state: string } | null = null;
280 | 
281 |       // Find the target simulator
282 |       for (const runtime in simulatorsData.devices) {
283 |         const devices = simulatorsData.devices[runtime];
284 |         if (Array.isArray(devices)) {
285 |           for (const device of devices) {
286 |             if (
287 |               typeof device === 'object' &&
288 |               device !== null &&
289 |               'udid' in device &&
290 |               'name' in device &&
291 |               'state' in device &&
292 |               typeof device.udid === 'string' &&
293 |               typeof device.name === 'string' &&
294 |               typeof device.state === 'string' &&
295 |               device.udid === simulatorId
296 |             ) {
297 |               targetSimulator = {
298 |                 udid: device.udid,
299 |                 name: device.name,
300 |                 state: device.state,
301 |               };
302 |               break;
303 |             }
304 |           }
305 |           if (targetSimulator) break;
306 |         }
307 |       }
308 | 
309 |       if (!targetSimulator) {
310 |         return createTextResponse(
311 |           `Build succeeded, but could not find simulator with UUID: ${simulatorId}`,
312 |           true,
313 |         );
314 |       }
315 | 
316 |       // Boot if needed
317 |       if (targetSimulator.state !== 'Booted') {
318 |         log('info', `Booting simulator ${targetSimulator.name}...`);
319 |         const bootResult = await executor(
320 |           ['xcrun', 'simctl', 'boot', simulatorId],
321 |           'Boot Simulator',
322 |         );
323 |         if (!bootResult.success) {
324 |           throw new Error(bootResult.error ?? 'Failed to boot simulator');
325 |         }
326 |       } else {
327 |         log('info', `Simulator ${simulatorId} is already booted`);
328 |       }
329 |     } catch (error) {
330 |       const errorMessage = error instanceof Error ? error.message : String(error);
331 |       log('error', `Error checking/booting simulator: ${errorMessage}`);
332 |       return createTextResponse(
333 |         `Build succeeded, but error checking/booting simulator: ${errorMessage}`,
334 |         true,
335 |       );
336 |     }
337 | 
338 |     // --- Open Simulator UI Step ---
339 |     try {
340 |       log('info', 'Opening Simulator app');
341 |       const openResult = await executor(['open', '-a', 'Simulator'], 'Open Simulator App');
342 |       if (!openResult.success) {
343 |         throw new Error(openResult.error ?? 'Failed to open Simulator app');
344 |       }
345 |     } catch (error) {
346 |       const errorMessage = error instanceof Error ? error.message : String(error);
347 |       log('warning', `Warning: Could not open Simulator app: ${errorMessage}`);
348 |       // Don't fail the whole operation for this
349 |     }
350 | 
351 |     // --- Install App Step ---
352 |     try {
353 |       log('info', `Installing app at path: ${appBundlePath} to simulator: ${simulatorId}`);
354 |       const installResult = await executor(
355 |         ['xcrun', 'simctl', 'install', simulatorId, appBundlePath],
356 |         'Install App',
357 |       );
358 |       if (!installResult.success) {
359 |         throw new Error(installResult.error ?? 'Failed to install app');
360 |       }
361 |     } catch (error) {
362 |       const errorMessage = error instanceof Error ? error.message : String(error);
363 |       log('error', `Error installing app: ${errorMessage}`);
364 |       return createTextResponse(
365 |         `Build succeeded, but error installing app on simulator: ${errorMessage}`,
366 |         true,
367 |       );
368 |     }
369 | 
370 |     // --- Get Bundle ID Step ---
371 |     let bundleId;
372 |     try {
373 |       log('info', `Extracting bundle ID from app: ${appBundlePath}`);
374 | 
375 |       // Try multiple methods to get bundle ID - first PlistBuddy, then plutil, then defaults
376 |       let bundleIdResult = null;
377 | 
378 |       // Method 1: PlistBuddy (most reliable)
379 |       try {
380 |         bundleIdResult = await executor(
381 |           [
382 |             '/usr/libexec/PlistBuddy',
383 |             '-c',
384 |             'Print :CFBundleIdentifier',
385 |             `${appBundlePath}/Info.plist`,
386 |           ],
387 |           'Get Bundle ID with PlistBuddy',
388 |         );
389 |         if (bundleIdResult.success) {
390 |           bundleId = bundleIdResult.output.trim();
391 |         }
392 |       } catch {
393 |         // Continue to next method
394 |       }
395 | 
396 |       // Method 2: plutil (workspace approach)
397 |       if (!bundleId) {
398 |         try {
399 |           bundleIdResult = await executor(
400 |             ['plutil', '-extract', 'CFBundleIdentifier', 'raw', `${appBundlePath}/Info.plist`],
401 |             'Get Bundle ID with plutil',
402 |           );
403 |           if (bundleIdResult?.success) {
404 |             bundleId = bundleIdResult.output?.trim();
405 |           }
406 |         } catch {
407 |           // Continue to next method
408 |         }
409 |       }
410 | 
411 |       // Method 3: defaults (fallback)
412 |       if (!bundleId) {
413 |         try {
414 |           bundleIdResult = await executor(
415 |             ['defaults', 'read', `${appBundlePath}/Info`, 'CFBundleIdentifier'],
416 |             'Get Bundle ID with defaults',
417 |           );
418 |           if (bundleIdResult?.success) {
419 |             bundleId = bundleIdResult.output?.trim();
420 |           }
421 |         } catch {
422 |           // All methods failed
423 |         }
424 |       }
425 | 
426 |       if (!bundleId) {
427 |         throw new Error('Could not extract bundle ID from Info.plist using any method');
428 |       }
429 | 
430 |       log('info', `Bundle ID for run: ${bundleId}`);
431 |     } catch (error) {
432 |       const errorMessage = error instanceof Error ? error.message : String(error);
433 |       log('error', `Error getting bundle ID: ${errorMessage}`);
434 |       return createTextResponse(
435 |         `Build and install succeeded, but error getting bundle ID: ${errorMessage}`,
436 |         true,
437 |       );
438 |     }
439 | 
440 |     // --- Launch App Step ---
441 |     try {
442 |       log('info', `Launching app with bundle ID: ${bundleId} on simulator: ${simulatorId}`);
443 |       const launchResult = await executor(
444 |         ['xcrun', 'simctl', 'launch', simulatorId, bundleId],
445 |         'Launch App',
446 |       );
447 |       if (!launchResult.success) {
448 |         throw new Error(launchResult.error ?? 'Failed to launch app');
449 |       }
450 |     } catch (error) {
451 |       const errorMessage = error instanceof Error ? error.message : String(error);
452 |       log('error', `Error launching app: ${errorMessage}`);
453 |       return createTextResponse(
454 |         `Build and install succeeded, but error launching app on simulator: ${errorMessage}`,
455 |         true,
456 |       );
457 |     }
458 | 
459 |     // --- Success ---
460 |     log('info', '✅ iOS simulator build & run succeeded.');
461 | 
462 |     const target = params.simulatorId
463 |       ? `simulator UUID '${params.simulatorId}'`
464 |       : `simulator name '${params.simulatorName}'`;
465 |     const sourceType = params.projectPath ? 'project' : 'workspace';
466 |     const sourcePath = params.projectPath ?? params.workspacePath;
467 | 
468 |     return {
469 |       content: [
470 |         {
471 |           type: 'text',
472 |           text: `✅ iOS simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}.
473 |           
474 | The app (${bundleId}) is now running in the iOS Simulator. 
475 | If you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open.
476 | 
477 | Next Steps:
478 | - Option 1: Capture structured logs only (app continues running):
479 |   start_simulator_log_capture({ simulatorId: '${simulatorId}', bundleId: '${bundleId}' })
480 | - Option 2: Capture both console and structured logs (app will restart):
481 |   start_simulator_log_capture({ simulatorId: '${simulatorId}', bundleId: '${bundleId}', captureConsole: true })
482 | - Option 3: Launch app with logs in one step (for a fresh start):
483 |   launch_app_with_logs_in_simulator({ simulatorId: '${simulatorId}', bundleId: '${bundleId}' })
484 | 
485 | When done with any option, use: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`,
486 |         },
487 |       ],
488 |       isError: false,
489 |     };
490 |   } catch (error) {
491 |     const errorMessage = error instanceof Error ? error.message : String(error);
492 |     log('error', `Error in iOS Simulator build and run: ${errorMessage}`);
493 |     return createTextResponse(`Error in iOS Simulator build and run: ${errorMessage}`, true);
494 |   }
495 | }
496 | 
497 | const publicSchemaObject = baseSchemaObject.omit({
498 |   projectPath: true,
499 |   workspacePath: true,
500 |   scheme: true,
501 |   configuration: true,
502 |   simulatorId: true,
503 |   simulatorName: true,
504 |   useLatestOS: true,
505 | } as const);
506 | 
507 | export default {
508 |   name: 'build_run_sim',
509 |   description: 'Builds and runs an app on an iOS simulator.',
510 |   schema: getSessionAwareToolSchemaShape({
511 |     sessionAware: publicSchemaObject,
512 |     legacy: baseSchemaObject,
513 |   }),
514 |   annotations: {
515 |     title: 'Build Run Simulator',
516 |     destructiveHint: true,
517 |   },
518 |   handler: createSessionAwareTool<BuildRunSimulatorParams>({
519 |     internalSchema: buildRunSimulatorSchema as unknown as z.ZodType<
520 |       BuildRunSimulatorParams,
521 |       unknown
522 |     >,
523 |     logicFunction: build_run_simLogic,
524 |     getExecutor: getDefaultCommandExecutor,
525 |     requirements: [
526 |       { allOf: ['scheme'], message: 'scheme is required' },
527 |       { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
528 |       { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' },
529 |     ],
530 |     exclusivePairs: [
531 |       ['projectPath', 'workspacePath'],
532 |       ['simulatorId', 'simulatorName'],
533 |     ],
534 |   }),
535 | };
536 | 
```

--------------------------------------------------------------------------------
/src/utils/debugger/backends/dap-backend.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import type { DebuggerBackend } from './DebuggerBackend.ts';
  2 | import type { BreakpointInfo, BreakpointSpec, DebugExecutionState } from '../types.ts';
  3 | import type { CommandExecutor, InteractiveSpawner } from '../../execution/index.ts';
  4 | import { getDefaultCommandExecutor, getDefaultInteractiveSpawner } from '../../execution/index.ts';
  5 | import { log } from '../../logging/index.ts';
  6 | import type {
  7 |   DapEvent,
  8 |   EvaluateResponseBody,
  9 |   ScopesResponseBody,
 10 |   SetBreakpointsResponseBody,
 11 |   StackTraceResponseBody,
 12 |   StoppedEventBody,
 13 |   ThreadsResponseBody,
 14 |   VariablesResponseBody,
 15 | } from '../dap/types.ts';
 16 | import { DapTransport } from '../dap/transport.ts';
 17 | import { resolveLldbDapCommand } from '../dap/adapter-discovery.ts';
 18 | 
 19 | const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
 20 | const LOG_PREFIX = '[DAP Backend]';
 21 | 
 22 | type FileLineBreakpointRecord = { line: number; condition?: string; id?: number };
 23 | type FunctionBreakpointRecord = { name: string; condition?: string; id?: number };
 24 | 
 25 | type BreakpointRecord = {
 26 |   spec: BreakpointSpec;
 27 |   condition?: string;
 28 | };
 29 | 
 30 | class DapBackend implements DebuggerBackend {
 31 |   readonly kind = 'dap' as const;
 32 | 
 33 |   private readonly executor: CommandExecutor;
 34 |   private readonly spawner: InteractiveSpawner;
 35 |   private readonly requestTimeoutMs: number;
 36 |   private readonly logEvents: boolean;
 37 | 
 38 |   private transport: DapTransport | null = null;
 39 |   private unsubscribeEvents: (() => void) | null = null;
 40 |   private attached = false;
 41 |   private disposed = false;
 42 |   private queue: Promise<unknown> = Promise.resolve();
 43 | 
 44 |   private lastStoppedThreadId: number | null = null;
 45 |   private executionState: DebugExecutionState = { status: 'unknown' };
 46 |   private breakpointsById = new Map<number, BreakpointRecord>();
 47 |   private fileLineBreakpointsByFile = new Map<string, FileLineBreakpointRecord[]>();
 48 |   private functionBreakpoints: FunctionBreakpointRecord[] = [];
 49 |   private nextSyntheticId = -1;
 50 | 
 51 |   constructor(opts: {
 52 |     executor: CommandExecutor;
 53 |     spawner: InteractiveSpawner;
 54 |     requestTimeoutMs: number;
 55 |     logEvents: boolean;
 56 |   }) {
 57 |     this.executor = opts.executor;
 58 |     this.spawner = opts.spawner;
 59 |     this.requestTimeoutMs = opts.requestTimeoutMs;
 60 |     this.logEvents = opts.logEvents;
 61 |   }
 62 | 
 63 |   async attach(opts: { pid: number; simulatorId: string; waitFor?: boolean }): Promise<void> {
 64 |     void opts.simulatorId;
 65 |     return this.enqueue(async () => {
 66 |       if (this.disposed) {
 67 |         throw new Error('DAP backend disposed');
 68 |       }
 69 |       if (this.attached) {
 70 |         throw new Error('DAP backend already attached');
 71 |       }
 72 | 
 73 |       const adapterCommand = await resolveLldbDapCommand({ executor: this.executor });
 74 |       const transport = new DapTransport({
 75 |         spawner: this.spawner,
 76 |         adapterCommand,
 77 |         logPrefix: LOG_PREFIX,
 78 |       });
 79 |       this.transport = transport;
 80 |       this.unsubscribeEvents = transport.onEvent((event) => this.handleEvent(event));
 81 | 
 82 |       try {
 83 |         const init = await this.request<
 84 |           {
 85 |             clientID: string;
 86 |             clientName: string;
 87 |             adapterID: string;
 88 |             linesStartAt1: boolean;
 89 |             columnsStartAt1: boolean;
 90 |             pathFormat: string;
 91 |             supportsVariableType: boolean;
 92 |             supportsVariablePaging: boolean;
 93 |           },
 94 |           { supportsConfigurationDoneRequest?: boolean }
 95 |         >('initialize', {
 96 |           clientID: 'xcodebuildmcp',
 97 |           clientName: 'XcodeBuildMCP',
 98 |           adapterID: 'lldb-dap',
 99 |           linesStartAt1: true,
100 |           columnsStartAt1: true,
101 |           pathFormat: 'path',
102 |           supportsVariableType: true,
103 |           supportsVariablePaging: false,
104 |         });
105 | 
106 |         await this.request('attach', {
107 |           pid: opts.pid,
108 |           waitFor: opts.waitFor ?? false,
109 |         });
110 | 
111 |         if (init.supportsConfigurationDoneRequest !== false) {
112 |           await this.request('configurationDone', {});
113 |         }
114 | 
115 |         this.attached = true;
116 |         log('info', `${LOG_PREFIX} attached to pid ${opts.pid}`);
117 |       } catch (error) {
118 |         this.cleanupTransport();
119 |         throw error;
120 |       }
121 |     });
122 |   }
123 | 
124 |   async detach(): Promise<void> {
125 |     return this.enqueue(async () => {
126 |       if (!this.transport) return;
127 |       try {
128 |         await this.request('disconnect', { terminateDebuggee: false });
129 |       } finally {
130 |         this.cleanupTransport();
131 |       }
132 |     });
133 |   }
134 | 
135 |   async runCommand(command: string, opts?: { timeoutMs?: number }): Promise<string> {
136 |     this.ensureAttached();
137 | 
138 |     try {
139 |       const body = await this.request<
140 |         { expression: string; context: string },
141 |         EvaluateResponseBody
142 |       >('evaluate', { expression: command, context: 'repl' }, opts);
143 |       return formatEvaluateResult(body);
144 |     } catch (error) {
145 |       const message = error instanceof Error ? error.message : String(error);
146 |       if (/evaluate|repl|not supported/i.test(message)) {
147 |         throw new Error(
148 |           'DAP backend does not support LLDB command evaluation. Set XCODEBUILDMCP_DEBUGGER_BACKEND=lldb-cli to use the CLI backend.',
149 |         );
150 |       }
151 |       throw error;
152 |     }
153 |   }
154 | 
155 |   async resume(opts?: { threadId?: number }): Promise<void> {
156 |     return this.enqueue(async () => {
157 |       this.ensureAttached();
158 | 
159 |       let threadId = opts?.threadId;
160 |       if (!threadId) {
161 |         const thread = await this.resolveThread();
162 |         threadId = thread.id;
163 |       }
164 | 
165 |       await this.request('continue', { threadId });
166 |       this.executionState = { status: 'running' };
167 |       this.lastStoppedThreadId = null;
168 |     });
169 |   }
170 | 
171 |   async addBreakpoint(
172 |     spec: BreakpointSpec,
173 |     opts?: { condition?: string },
174 |   ): Promise<BreakpointInfo> {
175 |     return this.enqueue(async () => {
176 |       this.ensureAttached();
177 | 
178 |       if (spec.kind === 'file-line') {
179 |         const current = this.fileLineBreakpointsByFile.get(spec.file) ?? [];
180 |         const nextBreakpoints = [...current, { line: spec.line, condition: opts?.condition }];
181 |         const updated = await this.setFileBreakpoints(spec.file, nextBreakpoints);
182 |         const added = updated[nextBreakpoints.length - 1];
183 |         if (!added?.id) {
184 |           throw new Error('DAP breakpoint id missing for file breakpoint.');
185 |         }
186 |         return {
187 |           id: added.id,
188 |           spec,
189 |           rawOutput: `Set breakpoint ${added.id} at ${spec.file}:${spec.line}`,
190 |         };
191 |       }
192 | 
193 |       const nextBreakpoints = [
194 |         ...this.functionBreakpoints,
195 |         { name: spec.name, condition: opts?.condition },
196 |       ];
197 |       const updated = await this.setFunctionBreakpoints(nextBreakpoints);
198 |       const added = updated[nextBreakpoints.length - 1];
199 |       if (!added?.id) {
200 |         throw new Error('DAP breakpoint id missing for function breakpoint.');
201 |       }
202 |       return {
203 |         id: added.id,
204 |         spec,
205 |         rawOutput: `Set breakpoint ${added.id} on ${spec.name}`,
206 |       };
207 |     });
208 |   }
209 | 
210 |   async removeBreakpoint(id: number): Promise<string> {
211 |     return this.enqueue(async () => {
212 |       this.ensureAttached();
213 | 
214 |       const record = this.breakpointsById.get(id);
215 |       if (!record) {
216 |         throw new Error(`Breakpoint not found: ${id}`);
217 |       }
218 | 
219 |       if (record.spec.kind === 'file-line') {
220 |         const current = this.fileLineBreakpointsByFile.get(record.spec.file) ?? [];
221 |         const nextBreakpoints = current.filter((breakpoint) => breakpoint.id !== id);
222 |         await this.setFileBreakpoints(record.spec.file, nextBreakpoints);
223 |       } else {
224 |         const nextBreakpoints = this.functionBreakpoints.filter(
225 |           (breakpoint) => breakpoint.id !== id,
226 |         );
227 |         await this.setFunctionBreakpoints(nextBreakpoints);
228 |       }
229 | 
230 |       return `Removed breakpoint ${id}.`;
231 |     });
232 |   }
233 | 
234 |   async getStack(opts?: { threadIndex?: number; maxFrames?: number }): Promise<string> {
235 |     this.ensureAttached();
236 | 
237 |     try {
238 |       const thread = await this.resolveThread(opts?.threadIndex);
239 |       const stack = await this.request<
240 |         { threadId: number; startFrame?: number; levels?: number },
241 |         StackTraceResponseBody
242 |       >('stackTrace', {
243 |         threadId: thread.id,
244 |         startFrame: 0,
245 |         levels: opts?.maxFrames,
246 |       });
247 | 
248 |       if (!stack.stackFrames.length) {
249 |         return `Thread ${thread.id}: no stack frames.`;
250 |       }
251 | 
252 |       const threadLabel = thread.name
253 |         ? `Thread ${thread.id} (${thread.name})`
254 |         : `Thread ${thread.id}`;
255 |       const formatted = stack.stackFrames.map((frame, index) => {
256 |         const location = frame.source?.path ?? frame.source?.name ?? 'unknown';
257 |         const line = frame.line ?? 0;
258 |         return `frame #${index}: ${frame.name} at ${location}:${line}`;
259 |       });
260 | 
261 |       return [threadLabel, ...formatted].join('\n');
262 |     } catch (error) {
263 |       const message = error instanceof Error ? error.message : String(error);
264 |       if (/running|not stopped|no thread|no frames/i.test(message)) {
265 |         throw new Error('Process is running; pause or hit a breakpoint to fetch stack.');
266 |       }
267 |       throw error;
268 |     }
269 |   }
270 | 
271 |   async getVariables(opts?: { frameIndex?: number }): Promise<string> {
272 |     this.ensureAttached();
273 | 
274 |     try {
275 |       const thread = await this.resolveThread();
276 |       const frameIndex = opts?.frameIndex ?? 0;
277 |       const stack = await this.request<
278 |         { threadId: number; startFrame?: number; levels?: number },
279 |         StackTraceResponseBody
280 |       >('stackTrace', {
281 |         threadId: thread.id,
282 |         startFrame: 0,
283 |         levels: frameIndex + 1,
284 |       });
285 | 
286 |       if (stack.stackFrames.length <= frameIndex) {
287 |         throw new Error(`Frame index ${frameIndex} is out of range.`);
288 |       }
289 | 
290 |       const frame = stack.stackFrames[frameIndex];
291 |       const scopes = await this.request<{ frameId: number }, ScopesResponseBody>('scopes', {
292 |         frameId: frame.id,
293 |       });
294 | 
295 |       if (!scopes.scopes.length) {
296 |         return 'No scopes available.';
297 |       }
298 | 
299 |       const sections: string[] = [];
300 |       for (const scope of scopes.scopes) {
301 |         if (!scope.variablesReference) {
302 |           sections.push(`${scope.name}:\n  (no variables)`);
303 |           continue;
304 |         }
305 | 
306 |         const vars = await this.request<{ variablesReference: number }, VariablesResponseBody>(
307 |           'variables',
308 |           {
309 |             variablesReference: scope.variablesReference,
310 |           },
311 |         );
312 | 
313 |         if (!vars.variables.length) {
314 |           sections.push(`${scope.name}:\n  (no variables)`);
315 |           continue;
316 |         }
317 | 
318 |         const lines = vars.variables.map((variable) => `  ${formatVariable(variable)}`);
319 |         sections.push(`${scope.name}:\n${lines.join('\n')}`);
320 |       }
321 | 
322 |       return sections.join('\n\n');
323 |     } catch (error) {
324 |       const message = error instanceof Error ? error.message : String(error);
325 |       if (/running|not stopped|no thread/i.test(message)) {
326 |         throw new Error('Process is running; pause or hit a breakpoint to fetch variables.');
327 |       }
328 |       throw error;
329 |     }
330 |   }
331 | 
332 |   async getExecutionState(opts?: { timeoutMs?: number }): Promise<DebugExecutionState> {
333 |     return this.enqueue(async () => {
334 |       this.ensureAttached();
335 | 
336 |       if (this.executionState.status !== 'unknown') {
337 |         return this.executionState;
338 |       }
339 | 
340 |       try {
341 |         const body = await this.request<undefined, ThreadsResponseBody>('threads', undefined, opts);
342 |         const threads = body.threads ?? [];
343 |         if (!threads.length) {
344 |           return { status: 'unknown' };
345 |         }
346 | 
347 |         const threadId = threads[0].id;
348 |         try {
349 |           await this.request<
350 |             { threadId: number; startFrame?: number; levels?: number },
351 |             StackTraceResponseBody
352 |           >(
353 |             'stackTrace',
354 |             { threadId, startFrame: 0, levels: 1 },
355 |             { timeoutMs: opts?.timeoutMs ?? this.requestTimeoutMs },
356 |           );
357 |           const state: DebugExecutionState = { status: 'stopped', threadId };
358 |           this.executionState = state;
359 |           return state;
360 |         } catch (error) {
361 |           const message = error instanceof Error ? error.message : String(error);
362 |           if (/running|not stopped/i.test(message)) {
363 |             const state: DebugExecutionState = { status: 'running', description: message };
364 |             this.executionState = state;
365 |             return state;
366 |           }
367 |           return { status: 'unknown', description: message };
368 |         }
369 |       } catch (error) {
370 |         const message = error instanceof Error ? error.message : String(error);
371 |         if (/running|not stopped/i.test(message)) {
372 |           return { status: 'running', description: message };
373 |         }
374 |         return { status: 'unknown', description: message };
375 |       }
376 |     });
377 |   }
378 | 
379 |   async dispose(): Promise<void> {
380 |     if (this.disposed) return;
381 |     this.disposed = true;
382 |     try {
383 |       this.cleanupTransport();
384 |     } catch (error) {
385 |       log('debug', `${LOG_PREFIX} dispose failed: ${String(error)}`);
386 |     }
387 |   }
388 | 
389 |   private ensureAttached(): void {
390 |     if (!this.transport || !this.attached) {
391 |       throw new Error('No active DAP session. Attach first.');
392 |     }
393 |   }
394 | 
395 |   private async request<A, B>(
396 |     command: string,
397 |     args?: A,
398 |     opts?: { timeoutMs?: number },
399 |   ): Promise<B> {
400 |     const transport = this.transport;
401 |     if (!transport) {
402 |       throw new Error('DAP transport not initialized.');
403 |     }
404 | 
405 |     return transport.sendRequest<A, B>(command, args, {
406 |       timeoutMs: opts?.timeoutMs ?? this.requestTimeoutMs,
407 |     });
408 |   }
409 | 
410 |   private async resolveThread(threadIndex?: number): Promise<{ id: number; name?: string }> {
411 |     const body = await this.request<undefined, ThreadsResponseBody>('threads');
412 |     const threads = body.threads ?? [];
413 |     if (!threads.length) {
414 |       throw new Error('No threads available.');
415 |     }
416 | 
417 |     if (typeof threadIndex === 'number') {
418 |       if (threadIndex < 0 || threadIndex >= threads.length) {
419 |         throw new Error(`Thread index ${threadIndex} is out of range.`);
420 |       }
421 |       return threads[threadIndex];
422 |     }
423 | 
424 |     if (this.lastStoppedThreadId) {
425 |       const stopped = threads.find((thread) => thread.id === this.lastStoppedThreadId);
426 |       if (stopped) {
427 |         return stopped;
428 |       }
429 |     }
430 | 
431 |     return threads[0];
432 |   }
433 | 
434 |   private handleEvent(event: DapEvent): void {
435 |     if (this.logEvents) {
436 |       log('debug', `${LOG_PREFIX} event: ${JSON.stringify(event)}`);
437 |     }
438 | 
439 |     if (event.event === 'stopped') {
440 |       const body = event.body as StoppedEventBody | undefined;
441 |       this.executionState = {
442 |         status: 'stopped',
443 |         reason: body?.reason,
444 |         description: body?.description,
445 |         threadId: body?.threadId,
446 |       };
447 |       if (body?.threadId) {
448 |         this.lastStoppedThreadId = body.threadId;
449 |       }
450 |       return;
451 |     }
452 | 
453 |     if (event.event === 'continued') {
454 |       this.executionState = { status: 'running' };
455 |       this.lastStoppedThreadId = null;
456 |       return;
457 |     }
458 | 
459 |     if (event.event === 'exited' || event.event === 'terminated') {
460 |       this.executionState = { status: 'terminated' };
461 |       this.lastStoppedThreadId = null;
462 |     }
463 |   }
464 | 
465 |   private cleanupTransport(): void {
466 |     this.attached = false;
467 |     this.lastStoppedThreadId = null;
468 |     this.executionState = { status: 'unknown' };
469 |     this.unsubscribeEvents?.();
470 |     this.unsubscribeEvents = null;
471 | 
472 |     if (this.transport) {
473 |       this.transport.dispose();
474 |       this.transport = null;
475 |     }
476 |   }
477 | 
478 |   private async setFileBreakpoints(
479 |     file: string,
480 |     breakpoints: FileLineBreakpointRecord[],
481 |   ): Promise<FileLineBreakpointRecord[]> {
482 |     const response = await this.request<
483 |       { source: { path: string }; breakpoints: Array<{ line: number; condition?: string }> },
484 |       SetBreakpointsResponseBody
485 |     >('setBreakpoints', {
486 |       source: { path: file },
487 |       breakpoints: breakpoints.map((bp) => ({ line: bp.line, condition: bp.condition })),
488 |     });
489 | 
490 |     const updated = breakpoints.map((bp, index) => ({
491 |       ...bp,
492 |       id: resolveBreakpointId(response.breakpoints?.[index]?.id, () => this.nextSyntheticId--),
493 |     }));
494 | 
495 |     this.replaceFileBreakpoints(file, updated);
496 |     return updated;
497 |   }
498 | 
499 |   private replaceFileBreakpoints(file: string, breakpoints: FileLineBreakpointRecord[]): void {
500 |     const existing = this.fileLineBreakpointsByFile.get(file) ?? [];
501 |     for (const breakpoint of existing) {
502 |       if (breakpoint.id != null) {
503 |         this.breakpointsById.delete(breakpoint.id);
504 |       }
505 |     }
506 | 
507 |     this.fileLineBreakpointsByFile.set(file, breakpoints);
508 |     for (const breakpoint of breakpoints) {
509 |       if (breakpoint.id != null) {
510 |         this.breakpointsById.set(breakpoint.id, {
511 |           spec: { kind: 'file-line', file, line: breakpoint.line },
512 |           condition: breakpoint.condition,
513 |         });
514 |       }
515 |     }
516 |   }
517 | 
518 |   private async setFunctionBreakpoints(
519 |     breakpoints: FunctionBreakpointRecord[],
520 |   ): Promise<FunctionBreakpointRecord[]> {
521 |     const response = await this.request<
522 |       { breakpoints: Array<{ name: string; condition?: string }> },
523 |       SetBreakpointsResponseBody
524 |     >('setFunctionBreakpoints', {
525 |       breakpoints: breakpoints.map((bp) => ({ name: bp.name, condition: bp.condition })),
526 |     });
527 | 
528 |     const updated = breakpoints.map((bp, index) => ({
529 |       ...bp,
530 |       id: resolveBreakpointId(response.breakpoints?.[index]?.id, () => this.nextSyntheticId--),
531 |     }));
532 | 
533 |     this.replaceFunctionBreakpoints(updated);
534 |     return updated;
535 |   }
536 | 
537 |   private replaceFunctionBreakpoints(breakpoints: FunctionBreakpointRecord[]): void {
538 |     for (const breakpoint of this.functionBreakpoints) {
539 |       if (breakpoint.id != null) {
540 |         this.breakpointsById.delete(breakpoint.id);
541 |       }
542 |     }
543 | 
544 |     this.functionBreakpoints = breakpoints;
545 |     for (const breakpoint of breakpoints) {
546 |       if (breakpoint.id != null) {
547 |         this.breakpointsById.set(breakpoint.id, {
548 |           spec: { kind: 'function', name: breakpoint.name },
549 |           condition: breakpoint.condition,
550 |         });
551 |       }
552 |     }
553 |   }
554 | 
555 |   private enqueue<T>(work: () => Promise<T>): Promise<T> {
556 |     const next = this.queue.then(work, work) as Promise<T>;
557 |     this.queue = next.then(
558 |       () => undefined,
559 |       () => undefined,
560 |     );
561 |     return next;
562 |   }
563 | }
564 | 
565 | function resolveBreakpointId(id: number | undefined, fallback: () => number): number {
566 |   if (typeof id === 'number' && Number.isFinite(id)) {
567 |     return id;
568 |   }
569 |   return fallback();
570 | }
571 | 
572 | function formatEvaluateResult(body: EvaluateResponseBody): string {
573 |   const parts = [body.output, body.result].filter((value) => value && value.trim().length > 0);
574 |   return parts.join('\n');
575 | }
576 | 
577 | function formatVariable(variable: { name: string; value: string; type?: string }): string {
578 |   const typeSuffix = variable.type ? ` (${variable.type})` : '';
579 |   return `${variable.name}${typeSuffix} = ${variable.value}`;
580 | }
581 | 
582 | function parseRequestTimeoutMs(): number {
583 |   const raw = process.env.XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS;
584 |   if (!raw) return DEFAULT_REQUEST_TIMEOUT_MS;
585 |   const parsed = Number(raw);
586 |   if (!Number.isFinite(parsed) || parsed <= 0) {
587 |     return DEFAULT_REQUEST_TIMEOUT_MS;
588 |   }
589 |   return parsed;
590 | }
591 | 
592 | function parseLogEvents(): boolean {
593 |   return process.env.XCODEBUILDMCP_DAP_LOG_EVENTS === 'true';
594 | }
595 | 
596 | export async function createDapBackend(opts?: {
597 |   executor?: CommandExecutor;
598 |   spawner?: InteractiveSpawner;
599 |   requestTimeoutMs?: number;
600 | }): Promise<DebuggerBackend> {
601 |   const executor = opts?.executor ?? getDefaultCommandExecutor();
602 |   const spawner = opts?.spawner ?? getDefaultInteractiveSpawner();
603 |   const requestTimeoutMs = opts?.requestTimeoutMs ?? parseRequestTimeoutMs();
604 |   const backend = new DapBackend({
605 |     executor,
606 |     spawner,
607 |     requestTimeoutMs,
608 |     logEvents: parseLogEvents(),
609 |   });
610 |   return backend;
611 | }
612 | 
```

--------------------------------------------------------------------------------
/docs/dev/session_management_plan.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Stateful Session Defaults for MCP Tools — Design, Middleware, and Plan
  2 | 
  3 | Below is a concise architecture and implementation plan to introduce a session-aware defaults layer that removes repeated tool parameters from public schemas, while keeping all tool logic and tests unchanged.
  4 | 
  5 | ## Architecture Overview
  6 | 
  7 | - **Core idea**: keep logic functions and tests untouched; move argument consolidation into a session-aware interop layer and expose minimal public schemas.
  8 | - **Data flow**:
  9 |   - Client calls a tool with zero or few args → session middleware merges session defaults → validates with the internal schema → calls the existing logic function.
 10 | - **Components**:
 11 |   - `SessionStore` (singleton, in-memory): set/get/clear/show defaults.
 12 |   - Session-aware tool factory: merges defaults, performs preflight requirement checks (allOf/oneOf), then validates with the tool's internal zod schema.
 13 |   - Public vs internal schema: plugins register a minimal "public" input schema; handlers validate with the unchanged "internal" schema.
 14 | 
 15 | ## Core Types
 16 | 
 17 | ```typescript
 18 | // src/utils/session-store.ts
 19 | export type SessionDefaults = {
 20 |   projectPath?: string;
 21 |   workspacePath?: string;
 22 |   scheme?: string;
 23 |   configuration?: string;
 24 |   simulatorName?: string;
 25 |   simulatorId?: string;
 26 |   deviceId?: string;
 27 |   useLatestOS?: boolean;
 28 |   arch?: 'arm64' | 'x86_64';
 29 | };
 30 | ```
 31 | 
 32 | ## Session Store (singleton)
 33 | 
 34 | ```typescript
 35 | // src/utils/session-store.ts
 36 | import { log } from './logger.ts';
 37 | 
 38 | class SessionStore {
 39 |   private defaults: SessionDefaults = {};
 40 | 
 41 |   setDefaults(partial: Partial<SessionDefaults>): void {
 42 |     this.defaults = { ...this.defaults, ...partial };
 43 |     log('info', '[Session] Defaults set', { keys: Object.keys(partial) });
 44 |   }
 45 | 
 46 |   clear(keys?: (keyof SessionDefaults)[]): void {
 47 |     if (!keys || keys.length === 0) {
 48 |       this.defaults = {};
 49 |       log('info', '[Session] All defaults cleared');
 50 |       return;
 51 |     }
 52 |     for (const k of keys) delete this.defaults[k];
 53 |     log('info', '[Session] Defaults cleared', { keys });
 54 |   }
 55 | 
 56 |   get<K extends keyof SessionDefaults>(key: K): SessionDefaults[K] {
 57 |     return this.defaults[key];
 58 |   }
 59 | 
 60 |   getAll(): SessionDefaults {
 61 |     return { ...this.defaults };
 62 |   }
 63 | }
 64 | 
 65 | export const sessionStore = new SessionStore();
 66 | ```
 67 | 
 68 | ## Session-Aware Tool Factory
 69 | 
 70 | ```typescript
 71 | // src/utils/typed-tool-factory.ts (add new helper, keep createTypedTool as-is)
 72 | import { z } from 'zod';
 73 | import { sessionStore, type SessionDefaults } from './session-store.ts';
 74 | import type { CommandExecutor } from './execution/index.ts';
 75 | import { createErrorResponse } from './responses/index.ts';
 76 | import type { ToolResponse } from '../types/common.ts';
 77 | 
 78 | export type SessionRequirement =
 79 |   | { allOf: (keyof SessionDefaults)[]; message?: string }
 80 |   | { oneOf: (keyof SessionDefaults)[]; message?: string };
 81 | 
 82 | function missingFromArgsAndSession(
 83 |   keys: (keyof SessionDefaults)[],
 84 |   args: Record<string, unknown>,
 85 | ): string[] {
 86 |   return keys.filter((k) => args[k] == null && sessionStore.get(k) == null);
 87 | }
 88 | 
 89 | export function createSessionAwareTool<TParams>(opts: {
 90 |   internalSchema: z.ZodType<TParams>;
 91 |   logicFunction: (params: TParams, executor: CommandExecutor) => Promise<ToolResponse>;
 92 |   getExecutor: () => CommandExecutor;
 93 |   requirements?: SessionRequirement[]; // preflight, friendlier than raw zod errors
 94 | }) {
 95 |   const { internalSchema, logicFunction, getExecutor, requirements = [] } = opts;
 96 | 
 97 |   return async (rawArgs: Record<string, unknown>): Promise<ToolResponse> => {
 98 |     try {
 99 |       // Merge: explicit args take precedence over session defaults
100 |       const merged: Record<string, unknown> = { ...sessionStore.getAll(), ...rawArgs };
101 | 
102 |       // Preflight requirement checks (clear message how to fix)
103 |       for (const req of requirements) {
104 |         if ('allOf' in req) {
105 |           const missing = missingFromArgsAndSession(req.allOf, rawArgs);
106 |           if (missing.length > 0) {
107 |             return createErrorResponse(
108 |               'Missing required session defaults',
109 |               `${req.message ?? `Required: ${req.allOf.join(', ')}`}\n` +
110 |                 `Set with: session-set-defaults { ${missing.map((k) => `"${k}": "..."`).join(', ')} }`,
111 |             );
112 |           }
113 |         } else if ('oneOf' in req) {
114 |           const missing = missingFromArgsAndSession(req.oneOf, rawArgs);
115 |           // oneOf satisfied if at least one is present in merged
116 |           const satisfied = req.oneOf.some((k) => merged[k] != null);
117 |           if (!satisfied) {
118 |             return createErrorResponse(
119 |               'Missing required session defaults',
120 |               `${req.message ?? `Provide one of: ${req.oneOf.join(', ')}`}\n` +
121 |                 `Set with: session-set-defaults { "${req.oneOf[0]}": "..." }`,
122 |             );
123 |           }
124 |         }
125 |       }
126 | 
127 |       // Validate against unchanged internal schema (logic/api untouched)
128 |       const validated = internalSchema.parse(merged);
129 |       return await logicFunction(validated, getExecutor());
130 |     } catch (error) {
131 |       if (error instanceof z.ZodError) {
132 |         const msgs = error.errors.map((e) => `${e.path.join('.') || 'root'}: ${e.message}`);
133 |         return createErrorResponse(
134 |           'Parameter validation failed',
135 |           `Invalid parameters:\n${msgs.join('\n')}\n` +
136 |             `Tip: set session defaults via session-set-defaults`,
137 |         );
138 |       }
139 |       throw error;
140 |     }
141 |   };
142 | }
143 | ```
144 | 
145 | ## Plugin Migration Pattern (Example: build_sim)
146 | 
147 | Public schema hides session fields; handler uses session-aware factory with internal schema and requirements; logic function unchanged.
148 | 
149 | ```typescript
150 | // src/mcp/tools/simulator/build_sim.ts (key parts only)
151 | import { z } from 'zod';
152 | import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts';
153 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
154 | 
155 | // Existing internal schema (unchanged)…
156 | const baseOptions = { /* as-is (scheme, simulatorId, simulatorName, configuration, …) */ };
157 | const baseSchemaObject = z.object({
158 |   projectPath: z.string().optional(),
159 |   workspacePath: z.string().optional(),
160 |   ...baseOptions,
161 | });
162 | const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject);
163 | const buildSimulatorSchema = baseSchema
164 |   .refine(/* as-is: projectPath XOR workspacePath */)
165 |   .refine(/* as-is: simulatorId XOR simulatorName */);
166 | 
167 | export type BuildSimulatorParams = z.infer<typeof buildSimulatorSchema>;
168 | 
169 | // Public schema = internal minus session-managed fields
170 | const sessionManaged = [
171 |   'projectPath',
172 |   'workspacePath',
173 |   'scheme',
174 |   'configuration',
175 |   'simulatorId',
176 |   'simulatorName',
177 |   'useLatestOS',
178 | ] as const;
179 | 
180 | const publicSchemaObject = baseSchemaObject.omit(
181 |   Object.fromEntries(sessionManaged.map((k) => [k, true])) as Record<string, true>,
182 | );
183 | 
184 | export default {
185 |   name: 'build_sim',
186 |   description: 'Builds an app for an iOS simulator.',
187 |   schema: publicSchemaObject.shape, // what the MCP client sees
188 |   handler: createSessionAwareTool<BuildSimulatorParams>({
189 |     internalSchema: buildSimulatorSchema,
190 |     logicFunction: build_simLogic,
191 |     getExecutor: getDefaultCommandExecutor,
192 |     requirements: [
193 |       { allOf: ['scheme'], message: 'scheme is required' },
194 |       { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
195 |       { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' },
196 |     ],
197 |   }),
198 | };
199 | ```
200 | 
201 | This same pattern applies to `build_run_sim`, `test_sim`, device/macos tools, etc. Public schemas become minimal, while internal schemas and logic remain unchanged.
202 | 
203 | ## New Tool Group: session-management
204 | 
205 | ### session_set_defaults.ts
206 | 
207 | ```typescript
208 | // src/mcp/tools/session-management/session_set_defaults.ts
209 | import { z } from 'zod';
210 | import { sessionStore, type SessionDefaults } from '../../../utils/session-store.ts';
211 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
212 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
213 | 
214 | const schemaObj = z.object({
215 |   projectPath: z.string().optional(),
216 |   workspacePath: z.string().optional(),
217 |   scheme: z.string().optional(),
218 |   configuration: z.string().optional(),
219 |   simulatorName: z.string().optional(),
220 |   simulatorId: z.string().optional(),
221 |   deviceId: z.string().optional(),
222 |   useLatestOS: z.boolean().optional(),
223 |   arch: z.enum(['arm64', 'x86_64']).optional(),
224 | });
225 | type Params = z.infer<typeof schemaObj>;
226 | 
227 | async function logic(params: Params): Promise<import('../../../types/common.ts').ToolResponse> {
228 |   sessionStore.setDefaults(params as Partial<SessionDefaults>);
229 |   const current = sessionStore.getAll();
230 |   return { content: [{ type: 'text', text: `Defaults updated:\n${JSON.stringify(current, null, 2)}` }] };
231 | }
232 | 
233 | export default {
234 |   name: 'session-set-defaults',
235 |   description: 'Set session defaults used by other tools.',
236 |   schema: schemaObj.shape,
237 |   handler: createTypedTool(schemaObj, logic, getDefaultCommandExecutor),
238 | };
239 | ```
240 | 
241 | ### session_clear_defaults.ts
242 | 
243 | ```typescript
244 | // src/mcp/tools/session-management/session_clear_defaults.ts
245 | import { z } from 'zod';
246 | import { sessionStore } from '../../../utils/session-store.ts';
247 | import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
248 | import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
249 | 
250 | const keys = [
251 |   'projectPath','workspacePath','scheme','configuration',
252 |   'simulatorName','simulatorId','deviceId','useLatestOS','arch',
253 | ] as const;
254 | const schemaObj = z.object({
255 |   keys: z.array(z.enum(keys)).optional(),
256 |   all: z.boolean().optional(),
257 | });
258 | 
259 | async function logic(params: z.infer<typeof schemaObj>) {
260 |   if (params.all || !params.keys) sessionStore.clear();
261 |   else sessionStore.clear(params.keys);
262 |   return { content: [{ type: 'text', text: 'Session defaults cleared' }] };
263 | }
264 | 
265 | export default {
266 |   name: 'session-clear-defaults',
267 |   description: 'Clear selected or all session defaults.',
268 |   schema: schemaObj.shape,
269 |   handler: createTypedTool(schemaObj, logic, getDefaultCommandExecutor),
270 | };
271 | ```
272 | 
273 | ### session_show_defaults.ts
274 | 
275 | ```typescript
276 | // src/mcp/tools/session-management/session_show_defaults.ts
277 | import { sessionStore } from '../../../utils/session-store.ts';
278 | 
279 | export default {
280 |   name: 'session-show-defaults',
281 |   description: 'Show current session defaults.',
282 |   schema: {}, // no args
283 |   handler: async () => {
284 |     const current = sessionStore.getAll();
285 |     return { content: [{ type: 'text', text: JSON.stringify(current, null, 2) }] };
286 |   },
287 | };
288 | ```
289 | 
290 | ## Step-by-Step Implementation Plan (Incremental, buildable at each step)
291 | 
292 | 1. **Add SessionStore** ✅ **DONE**
293 |    - New file: `src/utils/session-store.ts`.
294 |    - No existing code changes; run: `npm run build`, `lint`, `test`.
295 |    - Commit checkpoint (after review): see Commit & Review Protocol below.
296 | 
297 | 2. **Add session-management tools** ✅ **DONE**
298 |    - New folder: `src/mcp/tools/session-management` with the three tools above.
299 |    - Register via existing plugin discovery (same pattern as others).
300 |    - Build and test.
301 |    - Commit checkpoint (after review).
302 | 
303 | 3. **Add session-aware tool factory** ✅ **DONE**
304 |    - Add `createSessionAwareTool` to `src/utils/typed-tool-factory.ts` (keep `createTypedTool` intact).
305 |    - Unit tests for requirement preflight and merge precedence.
306 |    - Commit checkpoint (after review).
307 | 
308 | 4. **Migrate 2-3 representative tools**
309 |    - Example: `simulator/build_sim`, `macos/build_macos`, `device/build_device`.
310 |    - Create `publicSchemaObject` (omit session fields), switch handler to `createSessionAwareTool` with requirements.
311 |    - Keep internal schema and logic unchanged. Build and test.
312 |    - Commit checkpoint (after review).
313 | 
314 | 5. **Migrate remaining tools in small batches**
315 |    - Apply the same pattern across simulator/device/macos/test utilities.
316 |    - After each batch: `npm run typecheck`, `lint`, `test`.
317 |    - Commit checkpoint (after review).
318 | 
319 | 6. **Final polish**
320 |    - Add tests for session tools and session-aware preflight error messages.
321 |    - Ensure public schemas no longer expose session parameters globally.
322 |    - Commit checkpoint (after review).
323 | 
324 | ## Standard Testing & DI Checklist (Mandatory)
325 | 
326 | - Handlers must use dependency injection; tests must never call real executors.
327 | - For validation-only tests, calling the handler is acceptable because Zod validation occurs before executor acquisition.
328 | - For logic tests that would otherwise trigger `getDefaultCommandExecutor`, export the logic function and test it directly (no executor needed if logic doesn’t use one):
329 | 
330 | ```ts
331 | // Example: src/mcp/tools/session-management/session_clear_defaults.ts
332 | export async function sessionClearDefaultsLogic(params: Params): Promise<ToolResponse> { /* ... */ }
333 | export default {
334 |   name: 'session-clear-defaults',
335 |   handler: createTypedTool(schemaObj, sessionClearDefaultsLogic, getDefaultCommandExecutor),
336 | };
337 | 
338 | // Test: import logic and call directly to avoid real executor
339 | import plugin, { sessionClearDefaultsLogic } from '../session_clear_defaults.ts';
340 | ```
341 | 
342 | - Add tests for the new group and tools:
343 |   - Group metadata test: `src/mcp/tools/session-management/__tests__/index.test.ts`
344 |   - Tool tests: `session_set_defaults.test.ts`, `session_clear_defaults.test.ts`, `session_show_defaults.test.ts`
345 |   - Utils tests: `src/utils/__tests__/session-store.test.ts`
346 |   - Factory tests: `src/utils/__tests__/session-aware-tool-factory.test.ts` covering:
347 |     - Preflight requirements (allOf/oneOf)
348 |     - Merge precedence (explicit args override session defaults)
349 |     - Zod error reporting with helpful tips
350 | 
351 | - Always run locally before requesting review:
352 |   - `npm run typecheck`
353 |   - `npm run lint`
354 |   - `npm run format:check`
355 |   - `npm run build`
356 |   - `npm run test`
357 |   - Perform a quick manual CLI check (mcpli or reloaderoo) per the Manual Testing section
358 | 
359 | ### Minimal Changes Policy for Tests (Enforced)
360 | 
361 | - Only make material, essential edits to tests required by the code change (e.g., new preflight error messages or added/removed fields).
362 | - Do not change sample input values or defaults in tests (e.g., flipping a boolean like `preferXcodebuild`) unless strictly necessary to validate behavior.
363 | - Preserve the original intent and coverage of logic-function tests; keep handler vs logic boundaries intact.
364 | - When session-awareness is added, prefer setting/clearing session defaults around tests rather than altering existing assertions or sample inputs.
365 | 
366 | ### Tool Description Policy (Enforced)
367 | 
368 | - Keep tool descriptions concise (maximum one short sentence).
369 | - Do not mention session defaults, setup steps, examples, or parameter relationships in descriptions.
370 | - Use clear, imperative phrasing (e.g., "Builds an app for an iOS simulator.").
371 | - Apply consistently across all migrated tools; update any tests that assert `description` to match the concise string only.
372 | 
373 | ## Commit & Review Protocol (Enforced)
374 | 
375 | At the end of each numbered step above:
376 | 
377 | 1. Ensure all checks pass: `typecheck`, `lint`, `format:check`, `build`, `test`; then perform a quick manual CLI test (mcpli or reloaderoo) per the Manual Testing section.
378 |    - Verify tool descriptions comply with the Tool Description Policy (concise, no session-defaults mention).
379 | 2. Stage only the files for that step.
380 | 3. Prepare a concise commit message focused on the “why”.
381 | 4. Request manual review and approval before committing. Do not push.
382 | 
383 | Example messages per step:
384 | 
385 | - Step 1 (SessionStore)
386 |   - `chore(utils): add in-memory SessionStore for session defaults`
387 |   - Body: “Introduces singleton SessionStore with set/get/clear/show for session defaults; no behavior changes.”
388 | 
389 | - Step 2 (session-management tools)
390 |   - `feat(session-management): add set/clear/show session defaults tools and workflow metadata`
391 |   - Body: “Adds tools to manage session defaults and exposes workflow metadata; minimal schemas via typed factory.”
392 | 
393 | - Step 3 (middleware)
394 |   - `feat(utils): add createSessionAwareTool with preflight requirements and args>session merge`
395 |   - Body: “Session-aware interop layer performing requirements checks and Zod validation against internal schema.”
396 | 
397 | - Step 6 (tests/final polish)
398 |   - `test(session-management): add tool, store, and middleware tests; export logic for DI`
399 |   - Body: “Covers group metadata, tools, SessionStore, and factory (requirements/merge/errors). No production behavior changes.”
400 | 
401 | Approval flow:
402 | - After preparing messages and confirming checks, request maintainer approval.
403 | - On approval: commit locally (no push).
404 | - On rejection: revise and re-run checks.
405 | 
406 | Note on commit hooks and selective commits:
407 | - The pre-commit hook runs format/lint/build and can auto-add or modify files, causing additional files to be included in the commit. If you must commit a minimal subset, skip hooks with: `git commit --no-verify` (use sparingly and run `npm run typecheck && npm run lint && npm run test` manually first).
408 | 
409 | ## Safety, Buildability, Testability
410 | 
411 | - Logic functions and their types remain unchanged; existing unit tests that import logic directly continue to pass.
412 | - Public schemas shrink; MCP clients see smaller input schemas without session fields.
413 | - Handlers validate with internal schemas after session-defaults merge, preserving runtime guarantees.
414 | - Preflight requirement checks return clear guidance, e.g., "Provide one of: projectPath or workspacePath" + "Set with: session-set-defaults { "projectPath": "..." }".
415 | 
416 | ## Developer Usage
417 | 
418 | - **Set defaults once**:
419 |   - `session-set-defaults { "workspacePath": "...", "scheme": "App", "simulatorName": "iPhone 16" }`
420 | - **Run tools without args**:
421 |   - `build_sim {}`
422 | - **Inspect/reset**:
423 |   - `session-show-defaults {}`
424 |   - `session-clear-defaults { "all": true }`
425 | 
426 | ## Manual Testing with mcpli (CLI)
427 | 
428 | The following commands exercise the session workflow end‑to‑end using the built server.
429 | 
430 | 1) Build the server (required after code changes):
431 | 
432 | ```bash
433 | npm run build
434 | ```
435 | 
436 | 2) Discover a scheme (optional helper):
437 | 
438 | ```bash
439 | mcpli --raw list-schemes --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" -- node build/index.js
440 | ```
441 | 
442 | 3) Set the session defaults (project/workspace, scheme, and simulator):
443 | 
444 | ```bash
445 | mcpli --raw session-set-defaults \
446 |   --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" \
447 |   --scheme MCPTest \
448 |   --simulatorName "iPhone 16" \
449 |   -- node build/index.js
450 | ```
451 | 
452 | 4) Verify defaults are stored:
453 | 
454 | ```bash
455 | mcpli --raw session-show-defaults -- node build/index.js
456 | ```
457 | 
458 | 5) Run a session‑aware tool with zero or minimal args (defaults are merged automatically):
459 | 
460 | ```bash
461 | # Optionally provide a scratch derived data path and a short timeout
462 | mcpli --tool-timeout=60 --raw build-sim --derivedDataPath "/tmp/XBMCP_DD" -- node build/index.js
463 | ```
464 | 
465 | Troubleshooting:
466 | 
467 | - If you see validation errors like “Missing required session defaults …”, (re)run step 3 with the missing keys.
468 | - If you see connect ECONNREFUSED or the daemon appears flaky:
469 |   - Check logs: `mcpli daemon log --since=10m -- node build/index.js`
470 |   - Restart daemon: `mcpli daemon restart -- node build/index.js`
471 |   - Clean daemon state: `mcpli daemon clean -- node build/index.js` then `mcpli daemon start -- node build/index.js`
472 |   - After code changes, always: `npm run build` then `mcpli daemon restart -- node build/index.js`
473 | 
474 | Notes:
475 | 
476 | - Public schemas for session‑aware tools intentionally omit session fields (e.g., `scheme`, `projectPath`, `simulatorName`). Provide them once via `session-set-defaults` and then call the tool with zero/minimal flags.
477 | - Use `--tool-timeout=<seconds>` to cap long‑running builds during manual testing.
478 | - mcpli CLI normalizes tool names: tools exported with underscores (e.g., `build_sim`) can be invoked with hyphens (e.g., `build-sim`). Copy/paste samples using hyphens are valid because mcpli converts underscores to dashes.
479 | 
480 | ## Next Steps
481 | 
482 | Would you like me to proceed with Phase 1–3 implementation (store + session tools + middleware), then migrate a first tool (build_sim) and run the test suite?
483 | 
```
Page 13/16FirstPrevNextLast