From 83073cf28708ae0cab097085c068c0e039cdb6f5 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 11 Feb 2026 21:19:54 +0000 Subject: [PATCH 1/2] refactor: Migrate next-steps to manifest-driven templates Move next-step definitions from inline tool logic into YAML manifests using a new nextStepTemplates system. Templates support static params, ${arg} substitution, and runtime merging with dynamic response params. Removes hardcoded next-step construction from individual tool handlers. --- NEXT_STEPS_MIGRATION_TODO.md | 27 ++ docs/TOOLS-CLI.md | 2 +- docs/TOOLS.md | 2 +- manifests/tools/boot_sim.yaml | 16 + manifests/tools/build_run_sim.yaml | 10 + manifests/tools/debug_attach_sim.yaml | 10 + manifests/tools/get_app_bundle_id.yaml | 13 + manifests/tools/get_device_app_path.yaml | 10 + manifests/tools/get_mac_app_path.yaml | 7 + manifests/tools/get_mac_bundle_id.yaml | 7 + manifests/tools/get_sim_app_path.yaml | 13 + manifests/tools/install_app_sim.yaml | 7 + manifests/tools/launch_app_device.yaml | 4 + manifests/tools/launch_app_logs_sim.yaml | 4 + manifests/tools/launch_app_sim.yaml | 17 + manifests/tools/list_devices.yaml | 10 + manifests/tools/list_schemes.yaml | 10 + manifests/tools/list_sims.yaml | 22 ++ manifests/tools/open_sim.yaml | 25 ++ manifests/tools/record_sim_video.yaml | 4 + manifests/tools/scaffold_ios_project.yaml | 8 + manifests/tools/scaffold_macos_project.yaml | 8 + manifests/tools/show_build_settings.yaml | 10 + manifests/tools/snapshot_ui.yaml | 15 + manifests/tools/start_device_log_cap.yaml | 4 + manifests/tools/start_sim_log_cap.yaml | 4 + src/core/manifest/__tests__/schema.test.ts | 3 + src/core/manifest/load-manifest.ts | 17 + src/core/manifest/schema.ts | 17 + .../__tests__/debugging-tools.test.ts | 26 +- src/mcp/tools/debugging/debug_attach_sim.ts | 25 +- .../__tests__/get_device_app_path.test.ts | 26 +- .../__tests__/launch_app_device.test.ts | 14 +- .../device/__tests__/list_devices.test.ts | 25 +- src/mcp/tools/device/get_device_app_path.ts | 47 +-- src/mcp/tools/device/launch_app_device.ts | 27 +- src/mcp/tools/device/list_devices.ts | 78 ++-- .../__tests__/start_device_log_cap.test.ts | 12 +- .../__tests__/start_sim_log_cap.test.ts | 12 +- src/mcp/tools/logging/start_device_log_cap.ts | 33 +- src/mcp/tools/logging/start_sim_log_cap.ts | 11 +- .../macos/__tests__/get_mac_app_path.test.ts | 52 +-- src/mcp/tools/macos/get_mac_app_path.ts | 36 +- .../__tests__/get_app_bundle_id.test.ts | 64 +--- .../__tests__/get_mac_bundle_id.test.ts | 36 +- .../__tests__/list_schemes.test.ts | 63 +--- .../__tests__/show_build_settings.test.ts | 62 +--- .../project-discovery/get_app_bundle_id.ts | 32 +- .../project-discovery/get_mac_bundle_id.ts | 18 +- .../tools/project-discovery/list_schemes.ts | 42 +-- .../project-discovery/show_build_settings.ts | 36 +- .../__tests__/scaffold_ios_project.test.ts | 15 - .../__tests__/scaffold_macos_project.test.ts | 10 - .../scaffold_ios_project.ts | 5 - .../scaffold_macos_project.ts | 5 - .../simulator/__tests__/boot_sim.test.ts | 25 +- .../__tests__/install_app_sim.test.ts | 36 +- .../__tests__/launch_app_logs_sim.test.ts | 11 +- .../__tests__/launch_app_sim.test.ts | 54 +-- .../simulator/__tests__/list_sims.test.ts | 152 ++------ .../simulator/__tests__/open_sim.test.ts | 34 +- .../__tests__/record_sim_video.test.ts | 11 +- src/mcp/tools/simulator/boot_sim.ts | 25 +- src/mcp/tools/simulator/build_run_sim.ts | 34 +- src/mcp/tools/simulator/get_sim_app_path.ts | 109 +----- src/mcp/tools/simulator/install_app_sim.ts | 22 +- .../tools/simulator/launch_app_logs_sim.ts | 17 +- src/mcp/tools/simulator/launch_app_sim.ts | 27 +- src/mcp/tools/simulator/list_sims.ts | 38 +- src/mcp/tools/simulator/open_sim.ts | 34 +- src/mcp/tools/simulator/record_sim_video.ts | 21 +- .../__tests__/snapshot_ui.test.ts | 20 -- src/mcp/tools/ui-automation/snapshot_ui.ts | 35 +- src/runtime/__tests__/tool-invoker.test.ts | 243 ++++++++++++- src/runtime/tool-catalog.ts | 42 ++- src/runtime/tool-invoker.ts | 336 +++++++++++++----- src/runtime/types.ts | 18 +- src/test-utils/next-step-assertions.ts | 66 ++++ src/types/common.ts | 19 +- .../__tests__/next-steps-renderer.test.ts | 44 +-- src/utils/responses/next-steps-renderer.ts | 31 +- src/visibility/__tests__/exposure.test.ts | 1 + 82 files changed, 1334 insertions(+), 1289 deletions(-) create mode 100644 NEXT_STEPS_MIGRATION_TODO.md create mode 100644 src/test-utils/next-step-assertions.ts diff --git a/NEXT_STEPS_MIGRATION_TODO.md b/NEXT_STEPS_MIGRATION_TODO.md new file mode 100644 index 00000000..abd76a08 --- /dev/null +++ b/NEXT_STEPS_MIGRATION_TODO.md @@ -0,0 +1,27 @@ +# Next Steps Migration TODO + +Generated: 2026-02-11 19:12:27 UTC + +## Remaining tool files with inline nextSteps +- [x] src/mcp/tools/debugging/debug_attach_sim.ts:148 +- [x] src/mcp/tools/device/get_device_app_path.ts:140 +- [x] src/mcp/tools/device/launch_app_device.ts:135 +- [x] src/mcp/tools/device/list_devices.ts:391 +- [x] src/mcp/tools/logging/start_device_log_cap.ts:665 +- [x] src/mcp/tools/logging/start_sim_log_cap.ts:81 +- [x] src/mcp/tools/macos/get_mac_app_path.ts:167 +- [x] src/mcp/tools/project-discovery/get_app_bundle_id.ts:91 +- [x] src/mcp/tools/project-discovery/get_mac_bundle_id.ts:88 +- [x] src/mcp/tools/project-discovery/list_schemes.ts:83 +- [x] src/mcp/tools/project-discovery/show_build_settings.ts:88 +- [x] src/mcp/tools/project-scaffolding/scaffold_ios_project.ts:366 +- [x] src/mcp/tools/project-scaffolding/scaffold_macos_project.ts:340 +- [x] src/mcp/tools/simulator/boot_sim.ts:69 +- [x] src/mcp/tools/simulator/build_run_sim.ts:486 +- [x] src/mcp/tools/simulator/get_sim_app_path.ts:244 +- [x] src/mcp/tools/simulator/install_app_sim.ts:93 +- [x] src/mcp/tools/simulator/launch_app_logs_sim.ts:100 +- [x] src/mcp/tools/simulator/launch_app_sim.ts:127 +- [x] src/mcp/tools/simulator/list_sims.ts:196 +- [x] src/mcp/tools/simulator/open_sim.ts:42 +- [x] src/mcp/tools/simulator/record_sim_video.ts:130 diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index a1eb08c8..3731d5b5 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -187,4 +187,4 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-08T12:09:33.648Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-11T19:41:33.286Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 71950ad7..c1e6bebf 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -202,4 +202,4 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-08T12:09:33.648Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-11T19:41:33.286Z UTC* diff --git a/manifests/tools/boot_sim.yaml b/manifests/tools/boot_sim.yaml index dbb03c8a..4b633e10 100644 --- a/manifests/tools/boot_sim.yaml +++ b/manifests/tools/boot_sim.yaml @@ -7,3 +7,19 @@ description: Boot iOS simulator. annotations: title: Boot Simulator destructiveHint: true +nextSteps: + - label: Open the Simulator app (makes it visible) + toolId: open_sim + priority: 1 + - label: Install an app + toolId: install_app_sim + params: + simulatorId: ${simulatorId} + appPath: PATH_TO_YOUR_APP + priority: 2 + - label: Launch an app + toolId: launch_app_sim + params: + simulatorId: ${simulatorId} + bundleId: YOUR_APP_BUNDLE_ID + priority: 3 diff --git a/manifests/tools/build_run_sim.yaml b/manifests/tools/build_run_sim.yaml index c23b640f..ee39e815 100644 --- a/manifests/tools/build_run_sim.yaml +++ b/manifests/tools/build_run_sim.yaml @@ -9,3 +9,13 @@ predicates: annotations: title: Build Run Simulator destructiveHint: true +nextSteps: + - label: Capture structured logs (app continues running) + toolId: start_sim_log_cap + priority: 1 + - label: Capture console + structured logs (app restarts) + toolId: start_sim_log_cap + priority: 2 + - label: Launch app with logs in one step + toolId: launch_app_logs_sim + priority: 3 diff --git a/manifests/tools/debug_attach_sim.yaml b/manifests/tools/debug_attach_sim.yaml index 01750bba..bfb3bfe0 100644 --- a/manifests/tools/debug_attach_sim.yaml +++ b/manifests/tools/debug_attach_sim.yaml @@ -6,3 +6,13 @@ names: description: Attach LLDB to sim app. routing: stateful: true +nextSteps: + - label: Add a breakpoint + toolId: debug_breakpoint_add + priority: 1 + - label: Continue execution + toolId: debug_continue + priority: 2 + - label: Show call stack + toolId: debug_stack + priority: 3 diff --git a/manifests/tools/get_app_bundle_id.yaml b/manifests/tools/get_app_bundle_id.yaml index 3e8fa28d..0ffa51a9 100644 --- a/manifests/tools/get_app_bundle_id.yaml +++ b/manifests/tools/get_app_bundle_id.yaml @@ -7,3 +7,16 @@ description: Extract bundle id from .app. annotations: title: Get App Bundle ID readOnlyHint: true +nextSteps: + - label: Install on simulator + toolId: install_app_sim + priority: 1 + - label: Launch on simulator + toolId: launch_app_sim + priority: 2 + - label: Install on device + toolId: install_app_device + priority: 3 + - label: Launch on device + toolId: launch_app_device + priority: 4 diff --git a/manifests/tools/get_device_app_path.yaml b/manifests/tools/get_device_app_path.yaml index be830454..d60989fd 100644 --- a/manifests/tools/get_device_app_path.yaml +++ b/manifests/tools/get_device_app_path.yaml @@ -7,3 +7,13 @@ description: Get device built app path. annotations: title: Get Device App Path readOnlyHint: true +nextSteps: + - label: Get bundle ID + toolId: get_app_bundle_id + priority: 1 + - label: Install app on device + toolId: install_app_device + priority: 2 + - label: Launch app on device + toolId: launch_app_device + priority: 3 diff --git a/manifests/tools/get_mac_app_path.yaml b/manifests/tools/get_mac_app_path.yaml index fdfabc47..70d4f532 100644 --- a/manifests/tools/get_mac_app_path.yaml +++ b/manifests/tools/get_mac_app_path.yaml @@ -7,3 +7,10 @@ description: Get macOS built app path. annotations: title: Get macOS App Path readOnlyHint: true +nextSteps: + - label: Get bundle ID + toolId: get_mac_bundle_id + priority: 1 + - label: Launch app + toolId: launch_mac_app + priority: 2 diff --git a/manifests/tools/get_mac_bundle_id.yaml b/manifests/tools/get_mac_bundle_id.yaml index c397834a..476dd82e 100644 --- a/manifests/tools/get_mac_bundle_id.yaml +++ b/manifests/tools/get_mac_bundle_id.yaml @@ -7,3 +7,10 @@ description: Extract bundle id from macOS .app. annotations: title: Get Mac Bundle ID readOnlyHint: true +nextSteps: + - label: Launch the app + toolId: launch_mac_app + priority: 1 + - label: Build again + toolId: build_macos + priority: 2 diff --git a/manifests/tools/get_sim_app_path.yaml b/manifests/tools/get_sim_app_path.yaml index 923df057..71970d4a 100644 --- a/manifests/tools/get_sim_app_path.yaml +++ b/manifests/tools/get_sim_app_path.yaml @@ -7,3 +7,16 @@ description: Get sim built app path. annotations: title: Get Simulator App Path readOnlyHint: true +nextSteps: + - label: Get bundle ID + toolId: get_app_bundle_id + priority: 1 + - label: Boot simulator + toolId: boot_sim + priority: 2 + - label: Install app + toolId: install_app_sim + priority: 3 + - label: Launch app + toolId: launch_app_sim + priority: 4 diff --git a/manifests/tools/install_app_sim.yaml b/manifests/tools/install_app_sim.yaml index 2cc6fc9a..ced0894f 100644 --- a/manifests/tools/install_app_sim.yaml +++ b/manifests/tools/install_app_sim.yaml @@ -7,3 +7,10 @@ description: Install app on sim. annotations: title: Install App Simulator destructiveHint: true +nextSteps: + - label: Open the Simulator app + toolId: open_sim + priority: 1 + - label: Launch the app + toolId: launch_app_sim + priority: 2 diff --git a/manifests/tools/launch_app_device.yaml b/manifests/tools/launch_app_device.yaml index 7df14a6c..4bed9250 100644 --- a/manifests/tools/launch_app_device.yaml +++ b/manifests/tools/launch_app_device.yaml @@ -7,3 +7,7 @@ description: Launch app on device. annotations: title: Launch App Device destructiveHint: true +nextSteps: + - label: Stop the app + toolId: stop_app_device + priority: 1 diff --git a/manifests/tools/launch_app_logs_sim.yaml b/manifests/tools/launch_app_logs_sim.yaml index 14d69f01..ee4b250a 100644 --- a/manifests/tools/launch_app_logs_sim.yaml +++ b/manifests/tools/launch_app_logs_sim.yaml @@ -9,3 +9,7 @@ routing: annotations: title: Launch App Logs Simulator destructiveHint: true +nextSteps: + - label: Stop capture and retrieve logs + toolId: stop_sim_log_cap + priority: 1 diff --git a/manifests/tools/launch_app_sim.yaml b/manifests/tools/launch_app_sim.yaml index b78adc16..ae303d0a 100644 --- a/manifests/tools/launch_app_sim.yaml +++ b/manifests/tools/launch_app_sim.yaml @@ -7,3 +7,20 @@ description: Launch app on simulator. annotations: title: Launch App Simulator destructiveHint: true +nextSteps: + - label: Open Simulator app to see it + toolId: open_sim + priority: 1 + - label: Capture structured logs (app continues running) + toolId: start_sim_log_cap + params: + simulatorId: ${simulatorId} + bundleId: ${bundleId} + priority: 2 + - label: Capture console + structured logs (app restarts) + toolId: start_sim_log_cap + params: + simulatorId: ${simulatorId} + bundleId: ${bundleId} + captureConsole: true + priority: 3 diff --git a/manifests/tools/list_devices.yaml b/manifests/tools/list_devices.yaml index 47f48d0e..37a90974 100644 --- a/manifests/tools/list_devices.yaml +++ b/manifests/tools/list_devices.yaml @@ -7,3 +7,13 @@ description: List connected devices. annotations: title: List Devices readOnlyHint: true +nextSteps: + - label: Build for device + toolId: build_device + priority: 1 + - label: Run tests on device + toolId: test_device + priority: 2 + - label: Get app path + toolId: get_device_app_path + priority: 3 diff --git a/manifests/tools/list_schemes.yaml b/manifests/tools/list_schemes.yaml index 0eff0718..19a4dcb8 100644 --- a/manifests/tools/list_schemes.yaml +++ b/manifests/tools/list_schemes.yaml @@ -7,3 +7,13 @@ description: List Xcode schemes. annotations: title: List Schemes readOnlyHint: true +nextSteps: + - label: Build for macOS + toolId: build_macos + priority: 1 + - label: Build for iOS Simulator + toolId: build_sim + priority: 2 + - label: Show build settings + toolId: show_build_settings + priority: 3 diff --git a/manifests/tools/list_sims.yaml b/manifests/tools/list_sims.yaml index 4ec0ccfb..32d171c8 100644 --- a/manifests/tools/list_sims.yaml +++ b/manifests/tools/list_sims.yaml @@ -7,3 +7,25 @@ description: List iOS simulators. annotations: title: List Simulators readOnlyHint: true +nextSteps: + - label: Boot a simulator + toolId: boot_sim + params: + simulatorId: UUID_FROM_ABOVE + priority: 1 + - label: Open the simulator UI + toolId: open_sim + priority: 2 + - label: Build for simulator + toolId: build_sim + params: + scheme: YOUR_SCHEME + simulatorId: UUID_FROM_ABOVE + priority: 3 + - label: Get app path + toolId: get_sim_app_path + params: + scheme: YOUR_SCHEME + platform: iOS Simulator + simulatorId: UUID_FROM_ABOVE + priority: 4 diff --git a/manifests/tools/open_sim.yaml b/manifests/tools/open_sim.yaml index 0f88dd7b..ab388239 100644 --- a/manifests/tools/open_sim.yaml +++ b/manifests/tools/open_sim.yaml @@ -7,3 +7,28 @@ description: Open Simulator app. annotations: title: Open Simulator destructiveHint: true +nextSteps: + - label: Boot a simulator if needed + toolId: boot_sim + params: + simulatorId: UUID_FROM_LIST_SIMS + priority: 1 + - label: Capture structured logs (app continues running) + toolId: start_sim_log_cap + params: + simulatorId: UUID + bundleId: YOUR_APP_BUNDLE_ID + priority: 2 + - label: Capture console + structured logs (app restarts) + toolId: start_sim_log_cap + params: + simulatorId: UUID + bundleId: YOUR_APP_BUNDLE_ID + captureConsole: true + priority: 3 + - label: Launch app with logs in one step + toolId: launch_app_logs_sim + params: + simulatorId: UUID + bundleId: YOUR_APP_BUNDLE_ID + priority: 4 diff --git a/manifests/tools/record_sim_video.yaml b/manifests/tools/record_sim_video.yaml index 78cd2647..2a2536eb 100644 --- a/manifests/tools/record_sim_video.yaml +++ b/manifests/tools/record_sim_video.yaml @@ -9,3 +9,7 @@ routing: annotations: title: Record Simulator Video destructiveHint: true +nextSteps: + - label: Stop and save the recording + toolId: record_sim_video + priority: 1 diff --git a/manifests/tools/scaffold_ios_project.yaml b/manifests/tools/scaffold_ios_project.yaml index af49dc28..ce4a64ee 100644 --- a/manifests/tools/scaffold_ios_project.yaml +++ b/manifests/tools/scaffold_ios_project.yaml @@ -9,3 +9,11 @@ predicates: annotations: title: Scaffold iOS Project destructiveHint: true +nextSteps: + - label: Read the generated README in the workspace root before working on the project + - label: Build for simulator + toolId: build_sim + priority: 1 + - label: Build and run on simulator + toolId: build_run_sim + priority: 2 diff --git a/manifests/tools/scaffold_macos_project.yaml b/manifests/tools/scaffold_macos_project.yaml index 3c90b339..ed6dd4d9 100644 --- a/manifests/tools/scaffold_macos_project.yaml +++ b/manifests/tools/scaffold_macos_project.yaml @@ -9,3 +9,11 @@ predicates: annotations: title: Scaffold macOS Project destructiveHint: true +nextSteps: + - label: Read the generated README in the workspace root before working on the project + - label: Build for macOS + toolId: build_macos + priority: 1 + - label: Build and run on macOS + toolId: build_run_macos + priority: 2 diff --git a/manifests/tools/show_build_settings.yaml b/manifests/tools/show_build_settings.yaml index d2ec7862..f439c579 100644 --- a/manifests/tools/show_build_settings.yaml +++ b/manifests/tools/show_build_settings.yaml @@ -9,3 +9,13 @@ predicates: annotations: title: Show Build Settings readOnlyHint: true +nextSteps: + - label: Build for macOS + toolId: build_macos + priority: 1 + - label: Build for iOS Simulator + toolId: build_sim + priority: 2 + - label: List schemes + toolId: list_schemes + priority: 3 diff --git a/manifests/tools/snapshot_ui.yaml b/manifests/tools/snapshot_ui.yaml index 2808e101..758ceaee 100644 --- a/manifests/tools/snapshot_ui.yaml +++ b/manifests/tools/snapshot_ui.yaml @@ -4,6 +4,21 @@ names: mcp: snapshot_ui cli: snapshot-ui description: Print view hierarchy with precise view coordinates (x, y, width, height) for visible elements. +nextSteps: + - label: Refresh after layout changes + toolId: snapshot_ui + params: + simulatorId: ${simulatorId} + - label: Tap on element + toolId: tap + params: + simulatorId: ${simulatorId} + x: 0 + y: 0 + - label: Take screenshot for verification + toolId: screenshot + params: + simulatorId: ${simulatorId} annotations: title: Snapshot UI readOnlyHint: true diff --git a/manifests/tools/start_device_log_cap.yaml b/manifests/tools/start_device_log_cap.yaml index 35b81796..9880a4a0 100644 --- a/manifests/tools/start_device_log_cap.yaml +++ b/manifests/tools/start_device_log_cap.yaml @@ -9,3 +9,7 @@ routing: annotations: title: Start Device Log Capture destructiveHint: true +nextSteps: + - label: Stop capture and retrieve logs + toolId: stop_device_log_cap + priority: 1 diff --git a/manifests/tools/start_sim_log_cap.yaml b/manifests/tools/start_sim_log_cap.yaml index 36056e15..cdfe5b03 100644 --- a/manifests/tools/start_sim_log_cap.yaml +++ b/manifests/tools/start_sim_log_cap.yaml @@ -9,3 +9,7 @@ routing: annotations: title: Start Simulator Log Capture destructiveHint: true +nextSteps: + - label: Stop capture and retrieve logs + toolId: stop_sim_log_cap + priority: 1 diff --git a/src/core/manifest/__tests__/schema.test.ts b/src/core/manifest/__tests__/schema.test.ts index 253a2d98..a50b9255 100644 --- a/src/core/manifest/__tests__/schema.test.ts +++ b/src/core/manifest/__tests__/schema.test.ts @@ -40,6 +40,7 @@ describe('schema', () => { if (result.success) { expect(result.data.availability).toEqual({ mcp: true, cli: true }); expect(result.data.predicates).toEqual([]); + expect(result.data.nextSteps).toEqual([]); } }); @@ -201,6 +202,7 @@ describe('schema', () => { names: { mcp: 'build_sim', cli: 'build-simulator' }, availability: { mcp: true, cli: true }, predicates: [], + nextSteps: [], }; expect(getEffectiveCliName(tool)).toBe('build-simulator'); @@ -213,6 +215,7 @@ describe('schema', () => { names: { mcp: 'build_sim' }, availability: { mcp: true, cli: true }, predicates: [], + nextSteps: [], }; expect(getEffectiveCliName(tool)).toBe('build-sim'); diff --git a/src/core/manifest/load-manifest.ts b/src/core/manifest/load-manifest.ts index ad75ecfa..f9b2f57e 100644 --- a/src/core/manifest/load-manifest.ts +++ b/src/core/manifest/load-manifest.ts @@ -158,6 +158,23 @@ export function loadManifest(): ResolvedManifest { mcpNames.set(tool.names.mcp, toolId); } + // Validate next step template references + for (const [toolId, tool] of tools.entries()) { + const sourceFile = toolFiles.find((raw) => { + const candidate = raw as { id?: string; _sourceFile?: string }; + return candidate.id === toolId; + }) as { _sourceFile?: string } | undefined; + + for (const nextStep of tool.nextSteps) { + if (nextStep.toolId && !tools.has(nextStep.toolId)) { + throw new ManifestValidationError( + `Tool '${toolId}' next step references unknown tool '${nextStep.toolId}'`, + sourceFile?._sourceFile, + ); + } + } + } + return { tools, workflows }; } diff --git a/src/core/manifest/schema.ts b/src/core/manifest/schema.ts index 97b055cb..11e4e7e0 100644 --- a/src/core/manifest/schema.ts +++ b/src/core/manifest/schema.ts @@ -54,6 +54,20 @@ export const toolNamesSchema = z.object({ export type ToolNames = z.infer; +/** + * Static next-step template declared on a tool manifest. + */ +export const manifestNextStepTemplateSchema = z + .object({ + label: z.string(), + toolId: z.string().optional(), + params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}), + priority: z.number().optional(), + }) + .strict(); + +export type ManifestNextStepTemplate = z.infer; + /** * Tool manifest entry schema. * Describes a single tool's metadata and configuration. @@ -85,6 +99,9 @@ export const toolManifestEntrySchema = z.object({ /** MCP annotations (hints for clients) */ annotations: annotationsSchema.optional(), + + /** Static next-step templates for this tool */ + nextSteps: z.array(manifestNextStepTemplateSchema).default([]), }); export type ToolManifestEntry = z.infer; diff --git a/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts b/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts index e4e34eeb..4559e787 100644 --- a/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts +++ b/src/mcp/tools/debugging/__tests__/debugging-tools.test.ts @@ -257,7 +257,7 @@ describe('debug_attach_sim', () => { expect(text).toContain('Failed to resolve simulator PID'); }); - it('should include nextSteps on success', async () => { + it('should include nextStepParams on success', async () => { const ctx = createTestContext(); const result = await debug_attach_simLogic( @@ -270,12 +270,24 @@ describe('debug_attach_sim', () => { ctx, ); - expect(result.nextSteps).toBeDefined(); - expect(result.nextSteps!.length).toBeGreaterThan(0); - const tools = result.nextSteps!.map((s) => s.tool); - expect(tools).toContain('debug_breakpoint_add'); - expect(tools).toContain('debug_continue'); - expect(tools).toContain('debug_stack'); + expect(result.nextStepParams).toBeDefined(); + const breakpointStep = result.nextStepParams?.debug_breakpoint_add; + const continueStep = result.nextStepParams?.debug_continue; + const stackStep = result.nextStepParams?.debug_stack; + + expect(Array.isArray(breakpointStep)).toBe(false); + expect(Array.isArray(continueStep)).toBe(false); + expect(Array.isArray(stackStep)).toBe(false); + + const breakpointParams = Array.isArray(breakpointStep) ? undefined : breakpointStep; + const continueParams = Array.isArray(continueStep) ? undefined : continueStep; + const stackParams = Array.isArray(stackStep) ? undefined : stackStep; + + const debugSessionId = breakpointParams?.debugSessionId; + expect(typeof debugSessionId).toBe('string'); + expect(breakpointParams).toMatchObject({ file: '...', line: 123 }); + expect(continueParams?.debugSessionId).toBe(debugSessionId); + expect(stackParams?.debugSessionId).toBe(debugSessionId); }); }); }); diff --git a/src/mcp/tools/debugging/debug_attach_sim.ts b/src/mcp/tools/debugging/debug_attach_sim.ts index 143058ea..b0adeb16 100644 --- a/src/mcp/tools/debugging/debug_attach_sim.ts +++ b/src/mcp/tools/debugging/debug_attach_sim.ts @@ -145,26 +145,11 @@ export async function debug_attach_simLogic( `${resumeText}`, }, ], - nextSteps: [ - { - tool: 'debug_breakpoint_add', - label: 'Add a breakpoint', - params: { debugSessionId: session.id, file: '...', line: 123 }, - priority: 1, - }, - { - tool: 'debug_continue', - label: 'Continue execution', - params: { debugSessionId: session.id }, - priority: 2, - }, - { - tool: 'debug_stack', - label: 'Show call stack', - params: { debugSessionId: session.id }, - priority: 3, - }, - ], + nextStepParams: { + debug_breakpoint_add: { debugSessionId: session.id, file: '...', line: 123 }, + debug_continue: { debugSessionId: session.id }, + debug_stack: { debugSessionId: session.id }, + }, isError: false, }; } catch (error) { diff --git a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts index f9534b18..231f07c7 100644 --- a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts +++ b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts @@ -266,26 +266,14 @@ describe('get_device_app_path plugin', () => { text: '✅ App path retrieved successfully: /path/to/build/Debug-iphoneos/MyApp.app', }, ], - nextSteps: [ - { - tool: 'get_app_bundle_id', - label: 'Get bundle ID', - params: { appPath: '/path/to/build/Debug-iphoneos/MyApp.app' }, - priority: 1, - }, - { - tool: 'install_app_device', - label: 'Install app on device', - params: { deviceId: 'DEVICE_UDID', appPath: '/path/to/build/Debug-iphoneos/MyApp.app' }, - priority: 2, + nextStepParams: { + get_app_bundle_id: { appPath: '/path/to/build/Debug-iphoneos/MyApp.app' }, + install_app_device: { + deviceId: 'DEVICE_UDID', + appPath: '/path/to/build/Debug-iphoneos/MyApp.app', }, - { - tool: 'launch_app_device', - label: 'Launch app on device', - params: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, - priority: 3, - }, - ], + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, + }, }); }); diff --git a/src/mcp/tools/device/__tests__/launch_app_device.test.ts b/src/mcp/tools/device/__tests__/launch_app_device.test.ts index d67a3c19..5798fe76 100644 --- a/src/mcp/tools/device/__tests__/launch_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/launch_app_device.test.ts @@ -220,7 +220,6 @@ describe('launch_app_device plugin (device-shared)', () => { text: '✅ App launched successfully\n\nApp launched successfully', }, ], - nextSteps: [], }); }); @@ -246,7 +245,6 @@ describe('launch_app_device plugin (device-shared)', () => { text: '✅ App launched successfully\n\nLaunch succeeded with detailed output', }, ], - nextSteps: [], }); }); @@ -284,14 +282,9 @@ describe('launch_app_device plugin (device-shared)', () => { text: '✅ App launched successfully\n\nApp launched successfully\n\nProcess ID: 12345\n\nInteract with your app on the device.', }, ], - nextSteps: [ - { - tool: 'stop_app_device', - label: 'Stop the app', - params: { deviceId: 'test-device-123', processId: 12345 }, - priority: 1, - }, - ], + nextStepParams: { + stop_app_device: { deviceId: 'test-device-123', processId: 12345 }, + }, }); }); @@ -317,7 +310,6 @@ describe('launch_app_device plugin (device-shared)', () => { text: '✅ App launched successfully\n\nApp "io.sentry.app" launched on device "test-device-123"', }, ], - nextSteps: [], }); }); }); diff --git a/src/mcp/tools/device/__tests__/list_devices.test.ts b/src/mcp/tools/device/__tests__/list_devices.test.ts index d976f934..d3769a79 100644 --- a/src/mcp/tools/device/__tests__/list_devices.test.ts +++ b/src/mcp/tools/device/__tests__/list_devices.test.ts @@ -224,26 +224,11 @@ describe('list_devices plugin (device-shared)', () => { text: "Connected Devices:\n\n✅ Available Devices:\n\n📱 Test iPhone\n UDID: test-device-123\n Model: iPhone15,2\n Product Type: iPhone15,2\n Platform: iOS 17.0\n Connection: USB\n\nNote: Use the device ID/UDID from above when required by other tools.\nHint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n", }, ], - nextSteps: [ - { - tool: 'build_device', - label: 'Build for device', - params: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - priority: 1, - }, - { - tool: 'test_device', - label: 'Run tests on device', - params: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - priority: 2, - }, - { - tool: 'get_device_app_path', - label: 'Get app path', - params: { scheme: 'SCHEME' }, - priority: 3, - }, - ], + nextStepParams: { + build_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, + test_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, + get_device_app_path: { scheme: 'SCHEME' }, + }, }); }); diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index 99375e1d..2cad6f51 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -87,18 +87,16 @@ export async function get_device_app_pathLogic( command.push('-scheme', params.scheme); command.push('-configuration', configuration); - // Handle destination based on platform - let destinationString = ''; - - if (platform === XcodePlatform.iOS) { - destinationString = 'generic/platform=iOS'; - } else if (platform === XcodePlatform.watchOS) { - destinationString = 'generic/platform=watchOS'; - } else if (platform === XcodePlatform.tvOS) { - destinationString = 'generic/platform=tvOS'; - } else if (platform === XcodePlatform.visionOS) { - destinationString = 'generic/platform=visionOS'; - } else { + // Map platform to destination string + const destinationMap: Record = { + [XcodePlatform.iOS]: 'generic/platform=iOS', + [XcodePlatform.watchOS]: 'generic/platform=watchOS', + [XcodePlatform.tvOS]: 'generic/platform=tvOS', + [XcodePlatform.visionOS]: 'generic/platform=visionOS', + }; + + const destinationString = destinationMap[platform]; + if (!destinationString) { return createTextResponse(`Unsupported platform: ${platform}`, true); } @@ -137,26 +135,11 @@ export async function get_device_app_pathLogic( text: `✅ App path retrieved successfully: ${appPath}`, }, ], - nextSteps: [ - { - tool: 'get_app_bundle_id', - label: 'Get bundle ID', - params: { appPath }, - priority: 1, - }, - { - tool: 'install_app_device', - label: 'Install app on device', - params: { deviceId: 'DEVICE_UDID', appPath }, - priority: 2, - }, - { - tool: 'launch_app_device', - label: 'Launch app on device', - params: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, - priority: 3, - }, - ], + nextStepParams: { + get_app_bundle_id: { appPath }, + install_app_device: { deviceId: 'DEVICE_UDID', appPath }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, + }, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/device/launch_app_device.ts b/src/mcp/tools/device/launch_app_device.ts index 7d03d489..feb1e404 100644 --- a/src/mcp/tools/device/launch_app_device.ts +++ b/src/mcp/tools/device/launch_app_device.ts @@ -125,28 +125,9 @@ export async function launch_app_deviceLogic( await fileSystem.rm(tempJsonPath, { force: true }).catch(() => {}); } - let responseText = `✅ App launched successfully\n\n${result.output}`; - - if (processId) { - responseText += `\n\nProcess ID: ${processId}`; - responseText += `\n\nInteract with your app on the device.`; - } - - const nextSteps: Array<{ - tool: string; - label: string; - params: Record; - priority?: number; - }> = []; - - if (processId) { - nextSteps.push({ - tool: 'stop_app_device', - label: 'Stop the app', - params: { deviceId, processId }, - priority: 1, - }); - } + const responseText = processId + ? `✅ App launched successfully\n\n${result.output}\n\nProcess ID: ${processId}\n\nInteract with your app on the device.` + : `✅ App launched successfully\n\n${result.output}`; return { content: [ @@ -155,7 +136,7 @@ export async function launch_app_deviceLogic( text: responseText, }, ], - nextSteps, + ...(processId ? { nextStepParams: { stop_app_device: { deviceId, processId } } } : {}), }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/device/list_devices.ts b/src/mcp/tools/device/list_devices.ts index 4b2109ad..d685a6c8 100644 --- a/src/mcp/tools/device/list_devices.ts +++ b/src/mcp/tools/device/list_devices.ts @@ -219,18 +219,16 @@ export async function list_devicesLogic( // Determine platform from platformIdentifier let platform = 'Unknown'; const platformId = device.deviceProperties?.platformIdentifier?.toLowerCase() ?? ''; - if (typeof platformId === 'string') { - if (platformId.includes('ios') || platformId.includes('iphone')) { - platform = 'iOS'; - } else if (platformId.includes('ipad')) { - platform = 'iPadOS'; - } else if (platformId.includes('watch')) { - platform = 'watchOS'; - } else if (platformId.includes('tv') || platformId.includes('apple tv')) { - platform = 'tvOS'; - } else if (platformId.includes('vision')) { - platform = 'visionOS'; - } + if (platformId.includes('ios') || platformId.includes('iphone')) { + platform = 'iOS'; + } else if (platformId.includes('ipad')) { + platform = 'iPadOS'; + } else if (platformId.includes('watch')) { + platform = 'watchOS'; + } else if (platformId.includes('tv') || platformId.includes('apple tv')) { + platform = 'tvOS'; + } else if (platformId.includes('vision')) { + platform = 'visionOS'; } // Determine connection state @@ -238,29 +236,23 @@ export async function list_devicesLogic( const tunnelState = device.connectionProperties?.tunnelState ?? ''; const transportType = device.connectionProperties?.transportType ?? ''; - let state = 'Unknown'; - // Consider a device available if it's paired, regardless of tunnel state - // This allows WiFi-connected devices to be used even if tunnelState isn't "connected" - if (pairingState === 'paired') { - if (tunnelState === 'connected') { - state = 'Available'; - } else { - // Device is paired but tunnel state may be different for WiFi connections - // Still mark as available since devicectl commands can work with paired devices - state = 'Available (WiFi)'; - } - } else { + let state: string; + if (pairingState !== 'paired') { state = 'Unpaired'; + } else if (tunnelState === 'connected') { + state = 'Available'; + } else { + state = 'Available (WiFi)'; } devices.push({ name: device.deviceProperties?.name ?? 'Unknown Device', identifier: device.identifier ?? 'Unknown', - platform: platform, + platform, model: device.deviceProperties?.marketingName ?? device.hardwareProperties?.productType, osVersion: device.deviceProperties?.osVersionNumber, - state: state, + state, connectionType: transportType, trustState: pairingState, developerModeStatus: device.deviceProperties?.developerModeStatus, @@ -388,38 +380,18 @@ export async function list_devicesLogic( (d) => d.state === 'Available' || d.state === 'Available (WiFi)' || d.state === 'Connected', ); - const nextSteps: Array<{ - tool: string; - label: string; - params: Record; - priority?: number; - }> = []; + let nextStepParams: Record> | undefined; if (availableDevicesExist) { responseText += 'Note: Use the device ID/UDID from above when required by other tools.\n'; responseText += "Hint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n"; - nextSteps.push( - { - tool: 'build_device', - label: 'Build for device', - params: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - priority: 1, - }, - { - tool: 'test_device', - label: 'Run tests on device', - params: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, - priority: 2, - }, - { - tool: 'get_device_app_path', - label: 'Get app path', - params: { scheme: 'SCHEME' }, - priority: 3, - }, - ); + nextStepParams = { + build_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, + test_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' }, + get_device_app_path: { scheme: 'SCHEME' }, + }; } else if (uniqueDevices.length > 0) { responseText += 'Note: No devices are currently available for testing. Make sure devices are:\n'; @@ -435,7 +407,7 @@ export async function list_devicesLogic( text: responseText, }, ], - nextSteps, + ...(nextStepParams ? { nextStepParams } : {}), }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts index 836e7eb9..c1fa0f4b 100644 --- a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts @@ -142,8 +142,16 @@ describe('start_device_log_cap plugin', () => { ); expect(result.content[0].text).toContain('Interact with your app'); - expect(result.nextSteps).toBeDefined(); - expect(result.nextSteps![0].tool).toBe('stop_device_log_cap'); + const responseText = String(result.content[0].text); + const sessionIdMatch = responseText.match(/Session ID: ([a-f0-9-]{36})/); + expect(sessionIdMatch).not.toBeNull(); + const sessionId = sessionIdMatch?.[1]; + expect(typeof sessionId).toBe('string'); + + expect(result.nextStepParams?.stop_device_log_cap).toBeDefined(); + expect(result.nextStepParams?.stop_device_log_cap).toMatchObject({ + logSessionId: sessionId, + }); }); it('should surface early launch failures when process exits immediately', async () => { diff --git a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts index b1648446..2a3fcc92 100644 --- a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts @@ -108,14 +108,10 @@ describe('start_sim_log_cap plugin', () => { expect(result.content[0].text).toBe( 'Log capture started successfully. Session ID: test-uuid-123.\n\nOnly structured logs from the app subsystem are being captured.\n\nInteract with your simulator and app, then stop capture to retrieve logs.', ); - expect(result.nextSteps).toEqual([ - { - tool: 'stop_sim_log_cap', - label: 'Stop capture and retrieve logs', - params: { logSessionId: 'test-uuid-123' }, - priority: 1, - }, - ]); + expect(result.nextStepParams?.stop_sim_log_cap).toBeDefined(); + expect(result.nextStepParams?.stop_sim_log_cap).toMatchObject({ + logSessionId: 'test-uuid-123', + }); }); it('should indicate swiftui capture when subsystemFilter is swiftui', async () => { diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts index 843503ed..1f8c2ee2 100644 --- a/src/mcp/tools/logging/start_device_log_cap.ts +++ b/src/mcp/tools/logging/start_device_log_cap.ts @@ -465,13 +465,10 @@ function detectEarlyLaunchFailure( registerImmediateFailure?: (handler: (message: string) => void) => void, ): Promise { if (process.exitCode != null) { - if (process.exitCode === 0) { - const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); - return Promise.resolve( - failureFromOutput ? { exitCode: process.exitCode, errorMessage: failureFromOutput } : null, - ); - } const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); + if (process.exitCode === 0 && !failureFromOutput) { + return Promise.resolve(null); + } return Promise.resolve({ exitCode: process.exitCode, errorMessage: failureFromOutput }); } @@ -493,14 +490,10 @@ function detectEarlyLaunchFailure( const onClose = (code: number | null): void => { const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); - if (code === 0 && failureFromOutput) { - finalize({ exitCode: code ?? null, errorMessage: failureFromOutput }); - return; - } - if (code === 0) { + if (code === 0 && !failureFromOutput) { finalize(null); } else { - finalize({ exitCode: code ?? null, errorMessage: failureFromOutput }); + finalize({ exitCode: code, errorMessage: failureFromOutput }); } }; @@ -635,10 +628,7 @@ export async function start_device_log_capLogic( const resolvedFileSystemExecutor = fileSystemExecutor ?? getDefaultFileSystemExecutor(); const { sessionId, error } = await startDeviceLogCapture( - { - deviceUuid: deviceId, - bundleId: bundleId, - }, + { deviceUuid: deviceId, bundleId }, executor, resolvedFileSystemExecutor, ); @@ -662,14 +652,9 @@ export async function start_device_log_capLogic( text: `✅ Device log capture started successfully\n\nSession ID: ${sessionId}\n\nNote: The app has been launched on the device with console output capture enabled.\n\nInteract with your app on the device, then stop capture to retrieve logs.`, }, ], - nextSteps: [ - { - tool: 'stop_device_log_cap', - label: 'Stop capture and retrieve logs', - params: { logSessionId: sessionId }, - priority: 1, - }, - ], + nextStepParams: { + stop_device_log_cap: { logSessionId: sessionId }, + }, }; } diff --git a/src/mcp/tools/logging/start_sim_log_cap.ts b/src/mcp/tools/logging/start_sim_log_cap.ts index dbb06cda..3ea427bd 100644 --- a/src/mcp/tools/logging/start_sim_log_cap.ts +++ b/src/mcp/tools/logging/start_sim_log_cap.ts @@ -78,14 +78,9 @@ export async function start_sim_log_capLogic( `Log capture started successfully. Session ID: ${sessionId}.\n\n${captureConsole ? 'Note: Your app was relaunched to capture console output.\n' : ''}${filterDescription}\n\nInteract with your simulator and app, then stop capture to retrieve logs.`, ), ], - nextSteps: [ - { - tool: 'stop_sim_log_cap', - label: 'Stop capture and retrieve logs', - params: { logSessionId: sessionId }, - priority: 1, - }, - ], + nextStepParams: { + stop_sim_log_cap: { logSessionId: sessionId }, + }, }; } diff --git a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts index a26cd47f..e2c4da98 100644 --- a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts +++ b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts @@ -377,26 +377,16 @@ FULL_PRODUCT_NAME = MyApp.app text: '✅ App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', }, ], - nextSteps: [ - { - tool: 'get_mac_bundle_id', - label: 'Get bundle ID', - params: { - appPath: - '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - priority: 1, + nextStepParams: { + get_mac_bundle_id: { + appPath: + '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', }, - { - tool: 'launch_mac_app', - label: 'Launch app', - params: { - appPath: - '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - priority: 2, + launch_mac_app: { + appPath: + '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', }, - ], + }, }); }); @@ -424,26 +414,16 @@ FULL_PRODUCT_NAME = MyApp.app text: '✅ App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', }, ], - nextSteps: [ - { - tool: 'get_mac_bundle_id', - label: 'Get bundle ID', - params: { - appPath: - '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - priority: 1, + nextStepParams: { + get_mac_bundle_id: { + appPath: + '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', }, - { - tool: 'launch_mac_app', - label: 'Launch app', - params: { - appPath: - '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', - }, - priority: 2, + launch_mac_app: { + appPath: + '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', }, - ], + }, }); }); diff --git a/src/mcp/tools/macos/get_mac_app_path.ts b/src/mcp/tools/macos/get_mac_app_path.ts index 0541ee57..dfc32c7c 100644 --- a/src/mcp/tools/macos/get_mac_app_path.ts +++ b/src/mcp/tools/macos/get_mac_app_path.ts @@ -7,6 +7,7 @@ import * as z from 'zod'; import type { ToolResponse } from '../../../types/common.ts'; +import { XcodePlatform } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; @@ -16,7 +17,6 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -// Unified schema: XOR between projectPath and workspacePath, sharing common options const baseOptions = { scheme: z.string().describe('The scheme to use'), configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), @@ -53,21 +53,8 @@ const getMacosAppPathSchema = z.preprocess( }), ); -// Use z.infer for type safety type GetMacosAppPathParams = z.infer; -const XcodePlatform = { - iOS: 'iOS', - watchOS: 'watchOS', - tvOS: 'tvOS', - visionOS: 'visionOS', - iOSSimulator: 'iOS Simulator', - watchOSSimulator: 'watchOS Simulator', - tvOSSimulator: 'tvOS Simulator', - visionOSSimulator: 'visionOS Simulator', - macOS: 'macOS', -}; - export async function get_mac_app_pathLogic( params: GetMacosAppPathParams, executor: CommandExecutor, @@ -105,8 +92,7 @@ export async function get_mac_app_pathLogic( command.push('-destination', destinationString); } - // Add extra arguments if provided - if (params.extraArgs && Array.isArray(params.extraArgs)) { + if (params.extraArgs) { command.push(...params.extraArgs); } @@ -164,20 +150,10 @@ export async function get_mac_app_pathLogic( text: `✅ App path retrieved successfully: ${appPath}`, }, ], - nextSteps: [ - { - tool: 'get_mac_bundle_id', - label: 'Get bundle ID', - params: { appPath }, - priority: 1, - }, - { - tool: 'launch_mac_app', - label: 'Launch app', - params: { appPath }, - priority: 2, - }, - ], + nextStepParams: { + get_mac_bundle_id: { appPath }, + launch_mac_app: { appPath }, + }, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts index f80df8bf..f808f0dc 100644 --- a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts @@ -111,32 +111,12 @@ describe('get_app_bundle_id plugin', () => { text: '✅ Bundle ID: io.sentry.MyApp', }, ], - nextSteps: [ - { - tool: 'install_app_sim', - label: 'Install on simulator', - params: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, - priority: 1, - }, - { - tool: 'launch_app_sim', - label: 'Launch on simulator', - params: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, - priority: 2, - }, - { - tool: 'install_app_device', - label: 'Install on device', - params: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' }, - priority: 3, - }, - { - tool: 'launch_app_device', - label: 'Launch on device', - params: { deviceId: 'DEVICE_UDID', bundleId: 'io.sentry.MyApp' }, - priority: 4, - }, - ], + nextStepParams: { + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, + install_app_device: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'io.sentry.MyApp' }, + }, isError: false, }); }); @@ -166,32 +146,12 @@ describe('get_app_bundle_id plugin', () => { text: '✅ Bundle ID: io.sentry.MyApp', }, ], - nextSteps: [ - { - tool: 'install_app_sim', - label: 'Install on simulator', - params: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, - priority: 1, - }, - { - tool: 'launch_app_sim', - label: 'Launch on simulator', - params: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, - priority: 2, - }, - { - tool: 'install_app_device', - label: 'Install on device', - params: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' }, - priority: 3, - }, - { - tool: 'launch_app_device', - label: 'Launch on device', - params: { deviceId: 'DEVICE_UDID', bundleId: 'io.sentry.MyApp' }, - priority: 4, - }, - ], + nextStepParams: { + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'io.sentry.MyApp' }, + install_app_device: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: 'io.sentry.MyApp' }, + }, isError: false, }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts index 7c33b332..367dfd05 100644 --- a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts @@ -90,20 +90,10 @@ describe('get_mac_bundle_id plugin', () => { text: '✅ Bundle ID: io.sentry.MyMacApp', }, ], - nextSteps: [ - { - tool: 'launch_mac_app', - label: 'Launch the app', - params: { appPath: '/Applications/MyApp.app' }, - priority: 1, - }, - { - tool: 'build_macos', - label: 'Build again', - params: { scheme: 'SCHEME_NAME' }, - priority: 2, - }, - ], + nextStepParams: { + launch_mac_app: { appPath: '/Applications/MyApp.app' }, + build_macos: { scheme: 'SCHEME_NAME' }, + }, isError: false, }); }); @@ -133,20 +123,10 @@ describe('get_mac_bundle_id plugin', () => { text: '✅ Bundle ID: io.sentry.MyMacApp', }, ], - nextSteps: [ - { - tool: 'launch_mac_app', - label: 'Launch the app', - params: { appPath: '/Applications/MyApp.app' }, - priority: 1, - }, - { - tool: 'build_macos', - label: 'Build again', - params: { scheme: 'SCHEME_NAME' }, - priority: 2, - }, - ], + nextStepParams: { + launch_mac_app: { appPath: '/Applications/MyApp.app' }, + build_macos: { scheme: 'SCHEME_NAME' }, + }, isError: false, }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts index 417456c1..b676bf46 100644 --- a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts @@ -71,30 +71,15 @@ describe('list_schemes plugin', () => { text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyProject" } to avoid repeating it.', }, ], - nextSteps: [ - { - tool: 'build_macos', - label: 'Build for macOS', - params: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' }, - priority: 1, - }, - { - tool: 'build_sim', - label: 'Build for iOS Simulator', - params: { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyProject', - simulatorName: 'iPhone 16', - }, - priority: 2, + nextStepParams: { + build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' }, + build_sim: { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyProject', + simulatorName: 'iPhone 16', }, - { - tool: 'show_build_settings', - label: 'Show build settings', - params: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' }, - priority: 3, - }, - ], + show_build_settings: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' }, + }, isError: false, }); }); @@ -165,7 +150,6 @@ describe('list_schemes plugin', () => { text: '', }, ], - nextSteps: [], isError: false, }); }); @@ -309,30 +293,15 @@ describe('list_schemes plugin', () => { text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyApp" } to avoid repeating it.', }, ], - nextSteps: [ - { - tool: 'build_macos', - label: 'Build for macOS', - params: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' }, - priority: 1, - }, - { - tool: 'build_sim', - label: 'Build for iOS Simulator', - params: { - workspacePath: '/path/to/MyProject.xcworkspace', - scheme: 'MyApp', - simulatorName: 'iPhone 16', - }, - priority: 2, + nextStepParams: { + build_macos: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' }, + build_sim: { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyApp', + simulatorName: 'iPhone 16', }, - { - tool: 'show_build_settings', - label: 'Show build settings', - params: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' }, - priority: 3, - }, - ], + show_build_settings: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' }, + }, isError: false, }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts index 65e9bac4..eca48309 100644 --- a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts @@ -113,30 +113,15 @@ describe('show_build_settings plugin', () => { SUPPORTED_PLATFORMS = iphoneos iphonesimulator`, }, ], - nextSteps: [ - { - tool: 'build_macos', - label: 'Build for macOS', - params: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' }, - priority: 1, - }, - { - tool: 'build_sim', - label: 'Build for iOS Simulator', - params: { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - simulatorName: 'iPhone 16', - }, - priority: 2, + nextStepParams: { + build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' }, + build_sim: { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', }, - { - tool: 'list_schemes', - label: 'List schemes', - params: { projectPath: '/path/to/MyProject.xcodeproj' }, - priority: 3, - }, - ], + list_schemes: { projectPath: '/path/to/MyProject.xcodeproj' }, + }, isError: false, }); }); @@ -321,30 +306,15 @@ describe('show_build_settings plugin', () => { SUPPORTED_PLATFORMS = iphoneos iphonesimulator`, }, ], - nextSteps: [ - { - tool: 'build_macos', - label: 'Build for macOS', - params: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' }, - priority: 1, - }, - { - tool: 'build_sim', - label: 'Build for iOS Simulator', - params: { - projectPath: '/path/to/MyProject.xcodeproj', - scheme: 'MyScheme', - simulatorName: 'iPhone 16', - }, - priority: 2, + nextStepParams: { + build_macos: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' }, + build_sim: { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', }, - { - tool: 'list_schemes', - label: 'List schemes', - params: { projectPath: '/path/to/MyProject.xcodeproj' }, - priority: 3, - }, - ], + list_schemes: { projectPath: '/path/to/MyProject.xcodeproj' }, + }, isError: false, }); }); diff --git a/src/mcp/tools/project-discovery/get_app_bundle_id.ts b/src/mcp/tools/project-discovery/get_app_bundle_id.ts index a3778d4f..2da85cad 100644 --- a/src/mcp/tools/project-discovery/get_app_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_app_bundle_id.ts @@ -88,32 +88,12 @@ export async function get_app_bundle_idLogic( text: `✅ Bundle ID: ${bundleId}`, }, ], - nextSteps: [ - { - tool: 'install_app_sim', - label: 'Install on simulator', - params: { simulatorId: 'SIMULATOR_UUID', appPath }, - priority: 1, - }, - { - tool: 'launch_app_sim', - label: 'Launch on simulator', - params: { simulatorId: 'SIMULATOR_UUID', bundleId: bundleId.trim() }, - priority: 2, - }, - { - tool: 'install_app_device', - label: 'Install on device', - params: { deviceId: 'DEVICE_UDID', appPath }, - priority: 3, - }, - { - tool: 'launch_app_device', - label: 'Launch on device', - params: { deviceId: 'DEVICE_UDID', bundleId: bundleId.trim() }, - priority: 4, - }, - ], + nextStepParams: { + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: bundleId.trim() }, + install_app_device: { deviceId: 'DEVICE_UDID', appPath }, + launch_app_device: { deviceId: 'DEVICE_UDID', bundleId: bundleId.trim() }, + }, isError: false, }; } catch (error) { diff --git a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts index b54c59df..e396c986 100644 --- a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts @@ -85,20 +85,10 @@ export async function get_mac_bundle_idLogic( text: `✅ Bundle ID: ${bundleId}`, }, ], - nextSteps: [ - { - tool: 'launch_mac_app', - label: 'Launch the app', - params: { appPath }, - priority: 1, - }, - { - tool: 'build_macos', - label: 'Build again', - params: { scheme: 'SCHEME_NAME' }, - priority: 2, - }, - ], + nextStepParams: { + launch_mac_app: { appPath }, + build_macos: { scheme: 'SCHEME_NAME' }, + }, isError: false, }; } catch (error) { diff --git a/src/mcp/tools/project-discovery/list_schemes.ts b/src/mcp/tools/project-discovery/list_schemes.ts index 431fc549..f3cc2ee6 100644 --- a/src/mcp/tools/project-discovery/list_schemes.ts +++ b/src/mcp/tools/project-discovery/list_schemes.ts @@ -79,42 +79,22 @@ export async function listSchemesLogic( const schemeLines = schemesMatch[1].trim().split('\n'); const schemes = schemeLines.map((line) => line.trim()).filter((line) => line); - // Prepare next steps with the first scheme if available - const nextSteps: Array<{ - tool: string; - label: string; - params: Record; - priority?: number; - }> = []; + // Prepare next step params with the first scheme if available + let nextStepParams: Record> | undefined; let hintText = ''; if (schemes.length > 0) { const firstScheme = schemes[0]; - nextSteps.push( - { - tool: 'build_macos', - label: 'Build for macOS', - params: { [`${projectOrWorkspace}Path`]: path!, scheme: firstScheme }, - priority: 1, + nextStepParams = { + build_macos: { [`${projectOrWorkspace}Path`]: path!, scheme: firstScheme }, + build_sim: { + [`${projectOrWorkspace}Path`]: path!, + scheme: firstScheme, + simulatorName: 'iPhone 16', }, - { - tool: 'build_sim', - label: 'Build for iOS Simulator', - params: { - [`${projectOrWorkspace}Path`]: path!, - scheme: firstScheme, - simulatorName: 'iPhone 16', - }, - priority: 2, - }, - { - tool: 'show_build_settings', - label: 'Show build settings', - params: { [`${projectOrWorkspace}Path`]: path!, scheme: firstScheme }, - priority: 3, - }, - ); + show_build_settings: { [`${projectOrWorkspace}Path`]: path!, scheme: firstScheme }, + }; hintText = `Hint: Consider saving a default scheme with session-set-defaults ` + @@ -128,7 +108,7 @@ export async function listSchemesLogic( return { content, - nextSteps, + ...(nextStepParams ? { nextStepParams } : {}), isError: false, }; } catch (error) { diff --git a/src/mcp/tools/project-discovery/show_build_settings.ts b/src/mcp/tools/project-discovery/show_build_settings.ts index b7b812ea..676d7321 100644 --- a/src/mcp/tools/project-discovery/show_build_settings.ts +++ b/src/mcp/tools/project-discovery/show_build_settings.ts @@ -84,41 +84,21 @@ export async function showBuildSettingsLogic( }, ]; - // Build next steps - const nextSteps: Array<{ - tool: string; - label: string; - params: Record; - priority?: number; - }> = []; + // Build next step params + let nextStepParams: Record> | undefined; if (path) { const pathKey = hasProjectPath ? 'projectPath' : 'workspacePath'; - nextSteps.push( - { - tool: 'build_macos', - label: 'Build for macOS', - params: { [pathKey]: path, scheme: params.scheme }, - priority: 1, - }, - { - tool: 'build_sim', - label: 'Build for iOS Simulator', - params: { [pathKey]: path, scheme: params.scheme, simulatorName: 'iPhone 16' }, - priority: 2, - }, - { - tool: 'list_schemes', - label: 'List schemes', - params: { [pathKey]: path }, - priority: 3, - }, - ); + nextStepParams = { + build_macos: { [pathKey]: path, scheme: params.scheme }, + build_sim: { [pathKey]: path, scheme: params.scheme, simulatorName: 'iPhone 16' }, + list_schemes: { [pathKey]: path }, + }; } return { content, - nextSteps, + ...(nextStepParams ? { nextStepParams } : {}), isError: false, }; } catch (error) { diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts index d889e11e..ea5aba73 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts @@ -367,11 +367,6 @@ describe('scaffold_ios_project plugin', () => { projectPath: '/tmp/test-projects', platform: 'iOS', message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', - nextSteps: [ - 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', - 'Build for simulator: build_sim({ workspacePath: "/tmp/test-projects/TestIOSApp.xcworkspace", scheme: "TestIOSApp", simulatorName: "iPhone 16" })', - 'Build and run on simulator: build_run_sim({ workspacePath: "/tmp/test-projects/TestIOSApp.xcworkspace", scheme: "TestIOSApp", simulatorName: "iPhone 16" })', - ], }, null, 2, @@ -410,11 +405,6 @@ describe('scaffold_ios_project plugin', () => { projectPath: '/tmp/test-projects', platform: 'iOS', message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', - nextSteps: [ - 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', - 'Build for simulator: build_sim({ workspacePath: "/tmp/test-projects/TestIOSApp.xcworkspace", scheme: "TestIOSApp", simulatorName: "iPhone 16" })', - 'Build and run on simulator: build_run_sim({ workspacePath: "/tmp/test-projects/TestIOSApp.xcworkspace", scheme: "TestIOSApp", simulatorName: "iPhone 16" })', - ], }, null, 2, @@ -445,11 +435,6 @@ describe('scaffold_ios_project plugin', () => { projectPath: '/tmp/test-projects', platform: 'iOS', message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', - nextSteps: [ - 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', - 'Build for simulator: build_sim({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject", simulatorName: "iPhone 16" })', - 'Build and run on simulator: build_run_sim({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject", simulatorName: "iPhone 16" })', - ], }, null, 2, diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts index d3a7ef2e..04b28889 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts @@ -243,11 +243,6 @@ describe('scaffold_macos_project plugin', () => { projectPath: '/tmp/test-projects', platform: 'macOS', message: 'Successfully scaffolded macOS project "TestMacApp" in /tmp/test-projects', - nextSteps: [ - 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', - 'Build for macOS: build_macos({ workspacePath: "/tmp/test-projects/TestMacApp.xcworkspace", scheme: "TestMacApp" })', - 'Build & Run on macOS: build_run_macos({ workspacePath: "/tmp/test-projects/TestMacApp.xcworkspace", scheme: "TestMacApp" })', - ], }, null, 2, @@ -283,11 +278,6 @@ describe('scaffold_macos_project plugin', () => { projectPath: '/tmp/test-projects', platform: 'macOS', message: 'Successfully scaffolded macOS project "TestMacApp" in /tmp/test-projects', - nextSteps: [ - 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', - 'Build for macOS: build_macos({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject" })', - 'Build & Run on macOS: build_run_macos({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject" })', - ], }, null, 2, diff --git a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts index 9e4d5ff1..f501c4d6 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts @@ -363,11 +363,6 @@ export async function scaffold_ios_projectLogic( projectPath, platform: 'iOS', message: `Successfully scaffolded iOS project "${params.projectName}" in ${projectPath}`, - nextSteps: [ - `Important: Before working on the project make sure to read the README.md file in the workspace root directory.`, - `Build for simulator: build_sim({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}", simulatorName: "iPhone 16" })`, - `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" })`, - ], }; return { diff --git a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts index f10911b1..b92005ba 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts @@ -337,11 +337,6 @@ export async function scaffold_macos_projectLogic( projectPath, platform: 'macOS', message: `Successfully scaffolded macOS project "${params.projectName}" in ${projectPath}`, - nextSteps: [ - `Important: Before working on the project make sure to read the README.md file in the workspace root directory.`, - `Build for macOS: build_macos({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}" })`, - `Build & Run on macOS: build_run_macos({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}" })`, - ], }; return { diff --git a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts index 5005793f..1ad047db 100644 --- a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts @@ -57,26 +57,11 @@ describe('boot_sim tool', () => { text: 'Simulator booted successfully.', }, ], - nextSteps: [ - { - tool: 'open_sim', - label: 'Open the Simulator app (makes it visible)', - params: {}, - priority: 1, - }, - { - tool: 'install_app_sim', - label: 'Install an app', - params: { simulatorId: 'test-uuid-123', appPath: 'PATH_TO_YOUR_APP' }, - priority: 2, - }, - { - tool: 'launch_app_sim', - label: 'Launch an app', - params: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' }, - priority: 3, - }, - ], + nextStepParams: { + open_sim: {}, + install_app_sim: { simulatorId: 'test-uuid-123', appPath: 'PATH_TO_YOUR_APP' }, + launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' }, + }, }); }); diff --git a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts index 9be4dc06..b983d856 100644 --- a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts @@ -213,20 +213,10 @@ describe('install_app_sim tool', () => { text: 'App installed successfully in simulator test-uuid-123.', }, ], - nextSteps: [ - { - tool: 'open_sim', - label: 'Open the Simulator app', - params: {}, - priority: 1, - }, - { - tool: 'launch_app_sim', - label: 'Launch the app', - params: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' }, - priority: 2, - }, - ], + nextStepParams: { + open_sim: {}, + launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' }, + }, }); expect(bundleIdCalls).toHaveLength(2); }); @@ -277,20 +267,10 @@ describe('install_app_sim tool', () => { text: 'App installed successfully in simulator test-uuid-123.', }, ], - nextSteps: [ - { - tool: 'open_sim', - label: 'Open the Simulator app', - params: {}, - priority: 1, - }, - { - tool: 'launch_app_sim', - label: 'Launch the app', - params: { simulatorId: 'test-uuid-123', bundleId: 'io.sentry.myapp' }, - priority: 2, - }, - ], + nextStepParams: { + open_sim: {}, + launch_app_sim: { simulatorId: 'test-uuid-123', bundleId: 'io.sentry.myapp' }, + }, }); expect(bundleIdCalls).toHaveLength(2); }); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts index 11e0800d..32c9177a 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts @@ -89,14 +89,9 @@ describe('launch_app_logs_sim tool', () => { text: 'App launched successfully in simulator test-uuid-123 with log capture enabled.\n\nLog capture session ID: test-session-123\n\nInteract with your app in the simulator, then stop capture to retrieve logs.', }, ], - nextSteps: [ - { - tool: 'stop_sim_log_cap', - label: 'Stop capture and retrieve logs', - params: { logSessionId: 'test-session-123' }, - priority: 1, - }, - ], + nextStepParams: { + stop_sim_log_cap: { logSessionId: 'test-session-123' }, + }, isError: false, }); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts index abb102dc..6c732c79 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts @@ -104,30 +104,17 @@ describe('launch_app_sim tool', () => { text: 'App launched successfully in simulator test-uuid-123.', }, ], - nextSteps: [ - { - tool: 'open_sim', - label: 'Open Simulator app to see it', - params: {}, - priority: 1, - }, - { - tool: 'start_sim_log_cap', - label: 'Capture structured logs (app continues running)', - params: { simulatorId: 'test-uuid-123', bundleId: 'io.sentry.testapp' }, - priority: 2, - }, - { - tool: 'start_sim_log_cap', - label: 'Capture console + structured logs (app restarts)', - params: { + nextStepParams: { + open_sim: {}, + start_sim_log_cap: [ + { simulatorId: 'test-uuid-123', bundleId: 'io.sentry.testapp' }, + { simulatorId: 'test-uuid-123', bundleId: 'io.sentry.testapp', captureConsole: true, }, - priority: 3, - }, - ], + ], + }, }); }); @@ -205,30 +192,17 @@ describe('launch_app_sim tool', () => { text: 'App launched successfully in simulator "iPhone 16" (resolved-uuid).', }, ], - nextSteps: [ - { - tool: 'open_sim', - label: 'Open Simulator app to see it', - params: {}, - priority: 1, - }, - { - tool: 'start_sim_log_cap', - label: 'Capture structured logs (app continues running)', - params: { simulatorId: 'resolved-uuid', bundleId: 'io.sentry.testapp' }, - priority: 2, - }, - { - tool: 'start_sim_log_cap', - label: 'Capture console + structured logs (app restarts)', - params: { + nextStepParams: { + open_sim: {}, + start_sim_log_cap: [ + { simulatorId: 'resolved-uuid', bundleId: 'io.sentry.testapp' }, + { simulatorId: 'resolved-uuid', bundleId: 'io.sentry.testapp', captureConsole: true, }, - priority: 3, - }, - ], + ], + }, }); }); diff --git a/src/mcp/tools/simulator/__tests__/list_sims.test.ts b/src/mcp/tools/simulator/__tests__/list_sims.test.ts index ab0e998c..9acbd0fb 100644 --- a/src/mcp/tools/simulator/__tests__/list_sims.test.ts +++ b/src/mcp/tools/simulator/__tests__/list_sims.test.ts @@ -115,36 +115,16 @@ iOS 17.0: Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`, }, ], - nextSteps: [ - { - tool: 'boot_sim', - label: 'Boot a simulator', - params: { simulatorId: 'UUID_FROM_ABOVE' }, - priority: 1, - }, - { - tool: 'open_sim', - label: 'Open the simulator UI', - params: {}, - priority: 2, - }, - { - tool: 'build_sim', - label: 'Build for simulator', - params: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - priority: 3, + nextStepParams: { + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, - { - tool: 'get_sim_app_path', - label: 'Get app path', - params: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, - priority: 4, - }, - ], + }, }); }); @@ -195,36 +175,16 @@ iOS 17.0: Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`, }, ], - nextSteps: [ - { - tool: 'boot_sim', - label: 'Boot a simulator', - params: { simulatorId: 'UUID_FROM_ABOVE' }, - priority: 1, - }, - { - tool: 'open_sim', - label: 'Open the simulator UI', - params: {}, - priority: 2, - }, - { - tool: 'build_sim', - label: 'Build for simulator', - params: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - priority: 3, + nextStepParams: { + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, - { - tool: 'get_sim_app_path', - label: 'Get app path', - params: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, - priority: 4, - }, - ], + }, }); }); @@ -281,36 +241,16 @@ iOS 26.0: Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`, }, ], - nextSteps: [ - { - tool: 'boot_sim', - label: 'Boot a simulator', - params: { simulatorId: 'UUID_FROM_ABOVE' }, - priority: 1, - }, - { - tool: 'open_sim', - label: 'Open the simulator UI', - params: {}, - priority: 2, - }, - { - tool: 'build_sim', - label: 'Build for simulator', - params: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - priority: 3, + nextStepParams: { + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, - { - tool: 'get_sim_app_path', - label: 'Get app path', - params: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, - priority: 4, - }, - ], + }, }); }); @@ -372,36 +312,16 @@ iOS 17.0: Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`, }, ], - nextSteps: [ - { - tool: 'boot_sim', - label: 'Boot a simulator', - params: { simulatorId: 'UUID_FROM_ABOVE' }, - priority: 1, - }, - { - tool: 'open_sim', - label: 'Open the simulator UI', - params: {}, - priority: 2, - }, - { - tool: 'build_sim', - label: 'Build for simulator', - params: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - priority: 3, + nextStepParams: { + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, - { - tool: 'get_sim_app_path', - label: 'Get app path', - params: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, - priority: 4, - }, - ], + }, }); }); diff --git a/src/mcp/tools/simulator/__tests__/open_sim.test.ts b/src/mcp/tools/simulator/__tests__/open_sim.test.ts index 6954a871..d7dda558 100644 --- a/src/mcp/tools/simulator/__tests__/open_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/open_sim.test.ts @@ -56,32 +56,14 @@ describe('open_sim tool', () => { text: 'Simulator app opened successfully.', }, ], - nextSteps: [ - { - tool: 'boot_sim', - label: 'Boot a simulator if needed', - params: { simulatorId: 'UUID_FROM_LIST_SIMS' }, - priority: 1, - }, - { - tool: 'start_sim_log_cap', - label: 'Capture structured logs (app continues running)', - params: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, - priority: 2, - }, - { - tool: 'start_sim_log_cap', - label: 'Capture console + structured logs (app restarts)', - params: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true }, - priority: 3, - }, - { - tool: 'launch_app_logs_sim', - label: 'Launch app with logs in one step', - params: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, - priority: 4, - }, - ], + nextStepParams: { + boot_sim: { simulatorId: 'UUID_FROM_LIST_SIMS' }, + start_sim_log_cap: [ + { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, + { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true }, + ], + launch_app_logs_sim: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, + }, }); }); diff --git a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts index 56fc0676..b02e3345 100644 --- a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts +++ b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts @@ -79,12 +79,11 @@ describe('record_sim_video logic - start behavior', () => { expect(texts).toMatch(/30\s*fps/i); expect(texts.toLowerCase()).toContain('outputfile is ignored'); - // Check nextSteps array instead of embedded text - expect(res.nextSteps).toBeDefined(); - expect(res.nextSteps!.length).toBeGreaterThan(0); - expect(res.nextSteps![0].tool).toBe('record_sim_video'); - expect(res.nextSteps![0].params).toHaveProperty('stop', true); - expect(res.nextSteps![0].params).toHaveProperty('outputFile'); + // Check nextStepParams instead of embedded text + expect(res.nextStepParams).toBeDefined(); + expect(res.nextStepParams?.record_sim_video).toBeDefined(); + expect(res.nextStepParams?.record_sim_video).toHaveProperty('stop', true); + expect(res.nextStepParams?.record_sim_video).toHaveProperty('outputFile'); }); }); diff --git a/src/mcp/tools/simulator/boot_sim.ts b/src/mcp/tools/simulator/boot_sim.ts index 8d1c332a..997050ca 100644 --- a/src/mcp/tools/simulator/boot_sim.ts +++ b/src/mcp/tools/simulator/boot_sim.ts @@ -66,26 +66,11 @@ export async function boot_simLogic( text: `Simulator booted successfully.`, }, ], - nextSteps: [ - { - tool: 'open_sim', - label: 'Open the Simulator app (makes it visible)', - params: {}, - priority: 1, - }, - { - tool: 'install_app_sim', - label: 'Install an app', - params: { simulatorId: params.simulatorId, appPath: 'PATH_TO_YOUR_APP' }, - priority: 2, - }, - { - tool: 'launch_app_sim', - label: 'Launch an app', - params: { simulatorId: params.simulatorId, bundleId: 'YOUR_APP_BUNDLE_ID' }, - priority: 3, - }, - ], + nextStepParams: { + open_sim: {}, + install_app_sim: { simulatorId: params.simulatorId, appPath: 'PATH_TO_YOUR_APP' }, + launch_app_sim: { simulatorId: params.simulatorId, bundleId: 'YOUR_APP_BUNDLE_ID' }, + }, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index ed4126b9..d9283d1a 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -165,7 +165,6 @@ export async function build_run_simLogic( return buildResult; // Return the build error } - const platformDestination = detectedPlatform; const platformName = detectedPlatform.replace(' Simulator', ''); // --- Get App Path Step --- @@ -186,12 +185,12 @@ export async function build_run_simLogic( // Handle destination for simulator let destinationString: string; if (params.simulatorId) { - destinationString = `platform=${platformDestination},id=${params.simulatorId}`; + destinationString = `platform=${detectedPlatform},id=${params.simulatorId}`; } else if (params.simulatorName) { - destinationString = `platform=${platformDestination},name=${params.simulatorName}${(params.useLatestOS ?? true) ? ',OS=latest' : ''}`; + destinationString = `platform=${detectedPlatform},name=${params.simulatorName}${(params.useLatestOS ?? true) ? ',OS=latest' : ''}`; } else { // This shouldn't happen due to validation, but handle it - destinationString = `platform=${platformDestination}`; + destinationString = `platform=${detectedPlatform}`; } command.push('-destination', destinationString); @@ -483,26 +482,13 @@ export async function build_run_simLogic( text: `${platformName} simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}.\n\nThe app (${bundleId}) is now running in the ${platformName} Simulator.\nIf you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open.`, }, ], - nextSteps: [ - { - tool: 'start_sim_log_cap', - label: 'Capture structured logs (app continues running)', - params: { simulatorId, bundleId }, - priority: 1, - }, - { - tool: 'start_sim_log_cap', - label: 'Capture console + structured logs (app restarts)', - params: { simulatorId, bundleId, captureConsole: true }, - priority: 2, - }, - { - tool: 'launch_app_logs_sim', - label: 'Launch app with logs in one step', - params: { simulatorId, bundleId }, - priority: 3, - }, - ], + nextStepParams: { + start_sim_log_cap: [ + { simulatorId, bundleId }, + { simulatorId, bundleId, captureConsole: true }, + ], + launch_app_logs_sim: { simulatorId, bundleId }, + }, isError: false, }; } catch (error) { diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts index d4b8781f..ec7268a0 100644 --- a/src/mcp/tools/simulator/get_sim_app_path.ts +++ b/src/mcp/tools/simulator/get_sim_app_path.ts @@ -12,24 +12,13 @@ import { createTextResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; +import { XcodePlatform } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; -const XcodePlatform = { - macOS: 'macOS', - iOS: 'iOS', - iOSSimulator: 'iOS Simulator', - watchOS: 'watchOS', - watchOSSimulator: 'watchOS Simulator', - tvOS: 'tvOS', - tvOSSimulator: 'tvOS Simulator', - visionOS: 'visionOS', - visionOSSimulator: 'visionOS Simulator', -}; - function constructDestinationString( platform: string, simulatorName: string, @@ -54,12 +43,7 @@ function constructDestinationString( return `platform=${platform},name=${simulatorName}${useLatest ? ',OS=latest' : ''}`; } - // If it's a simulator platform but neither ID nor name is provided (should be prevented by callers now) - if (isSimulatorPlatform && !simulatorId && !simulatorName) { - log( - 'warning', - `Constructing generic destination for ${platform} without name or ID. This might not be specific enough.`, - ); + if (isSimulatorPlatform) { throw new Error(`Simulator name or ID is required for specific ${platform} operations`); } @@ -191,7 +175,7 @@ export async function get_sim_app_pathLogic( if (simulatorId) { destinationString = `platform=${platform},id=${simulatorId}`; } else if (simulatorName) { - destinationString = `platform=${platform},name=${simulatorName}${(simulatorId ? false : useLatestOS) ? ',OS=latest' : ''}`; + destinationString = `platform=${platform},name=${simulatorName}${useLatestOS ? ',OS=latest' : ''}`; } else { return createTextResponse( `For ${platform} platform, either simulatorId or simulatorName must be provided`, @@ -240,85 +224,12 @@ export async function get_sim_app_pathLogic( const fullProductName = fullProductNameMatch[1].trim(); const appPath = `${builtProductsDir}/${fullProductName}`; - // Build nextSteps based on platform - let nextSteps: Array<{ - tool: string; - label: string; - params: Record; - priority?: number; - }> = []; - - if (platform === XcodePlatform.macOS) { - nextSteps = [ - { - tool: 'get_mac_bundle_id', - label: 'Get bundle ID', - params: { appPath }, - priority: 1, - }, - { - tool: 'launch_mac_app', - label: 'Launch the app', - params: { appPath }, - priority: 2, - }, - ]; - } else if (isSimulatorPlatform) { - nextSteps = [ - { - tool: 'get_app_bundle_id', - label: 'Get bundle ID', - params: { appPath }, - priority: 1, - }, - { - tool: 'boot_sim', - label: 'Boot simulator', - params: { simulatorId: 'SIMULATOR_UUID' }, - priority: 2, - }, - { - tool: 'install_app_sim', - label: 'Install app', - params: { simulatorId: 'SIMULATOR_UUID', appPath }, - priority: 3, - }, - { - tool: 'launch_app_sim', - label: 'Launch app', - params: { simulatorId: 'SIMULATOR_UUID', bundleId: 'BUNDLE_ID' }, - priority: 4, - }, - ]; - } else if ( - [ - XcodePlatform.iOS, - XcodePlatform.watchOS, - XcodePlatform.tvOS, - XcodePlatform.visionOS, - ].includes(platform) - ) { - nextSteps = [ - { - tool: 'get_app_bundle_id', - label: 'Get bundle ID', - params: { appPath }, - priority: 1, - }, - { - tool: 'install_app_device', - label: 'Install app on device', - params: { deviceId: 'DEVICE_UDID', appPath }, - priority: 2, - }, - { - tool: 'launch_app_device', - label: 'Launch app on device', - params: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' }, - priority: 3, - }, - ]; - } + const nextStepParams: Record> = { + get_app_bundle_id: { appPath }, + boot_sim: { simulatorId: 'SIMULATOR_UUID' }, + install_app_sim: { simulatorId: 'SIMULATOR_UUID', appPath }, + launch_app_sim: { simulatorId: 'SIMULATOR_UUID', bundleId: 'BUNDLE_ID' }, + }; return { content: [ @@ -327,7 +238,7 @@ export async function get_sim_app_pathLogic( text: `✅ App path retrieved successfully: ${appPath}`, }, ], - nextSteps, + nextStepParams, isError: false, }; } catch (error) { diff --git a/src/mcp/tools/simulator/install_app_sim.ts b/src/mcp/tools/simulator/install_app_sim.ts index fbe1e290..ba0b297d 100644 --- a/src/mcp/tools/simulator/install_app_sim.ts +++ b/src/mcp/tools/simulator/install_app_sim.ts @@ -90,23 +90,13 @@ export async function install_app_simLogic( text: `App installed successfully in simulator ${params.simulatorId}.`, }, ], - nextSteps: [ - { - tool: 'open_sim', - label: 'Open the Simulator app', - params: {}, - priority: 1, + nextStepParams: { + open_sim: {}, + launch_app_sim: { + simulatorId: params.simulatorId, + bundleId: bundleId || 'YOUR_APP_BUNDLE_ID', }, - { - tool: 'launch_app_sim', - label: 'Launch the app', - params: { - simulatorId: params.simulatorId, - bundleId: bundleId || 'YOUR_APP_BUNDLE_ID', - }, - priority: 2, - }, - ], + }, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/simulator/launch_app_logs_sim.ts b/src/mcp/tools/simulator/launch_app_logs_sim.ts index ec6dea53..fb5a5c7a 100644 --- a/src/mcp/tools/simulator/launch_app_logs_sim.ts +++ b/src/mcp/tools/simulator/launch_app_logs_sim.ts @@ -79,9 +79,9 @@ export async function launch_app_logs_simLogic( simulatorUuid: params.simulatorId, bundleId: params.bundleId, captureConsole: true, - ...(params.args && params.args.length > 0 ? { args: params.args } : {}), - ...(params.env ? { env: params.env } : {}), - } as const; + args: params.args?.length ? params.args : undefined, + env: params.env, + }; const { sessionId, error } = await logCaptureFunction(captureParams, executor); if (error) { @@ -97,14 +97,9 @@ export async function launch_app_logs_simLogic( `App launched successfully in simulator ${params.simulatorId} with log capture enabled.\n\nLog capture session ID: ${sessionId}\n\nInteract with your app in the simulator, then stop capture to retrieve logs.`, ), ], - nextSteps: [ - { - tool: 'stop_sim_log_cap', - label: 'Stop capture and retrieve logs', - params: { logSessionId: sessionId }, - priority: 1, - }, - ], + nextStepParams: { + stop_sim_log_cap: { logSessionId: sessionId }, + }, isError: false, }; } diff --git a/src/mcp/tools/simulator/launch_app_sim.ts b/src/mcp/tools/simulator/launch_app_sim.ts index 0207e2c4..3f4a1c0f 100644 --- a/src/mcp/tools/simulator/launch_app_sim.ts +++ b/src/mcp/tools/simulator/launch_app_sim.ts @@ -124,26 +124,13 @@ export async function launch_app_simLogic( text: `App launched successfully in simulator ${simulatorDisplayName}.`, }, ], - nextSteps: [ - { - tool: 'open_sim', - label: 'Open Simulator app to see it', - params: {}, - priority: 1, - }, - { - tool: 'start_sim_log_cap', - label: 'Capture structured logs (app continues running)', - params: { simulatorId, bundleId: params.bundleId }, - priority: 2, - }, - { - tool: 'start_sim_log_cap', - label: 'Capture console + structured logs (app restarts)', - params: { simulatorId, bundleId: params.bundleId, captureConsole: true }, - priority: 3, - }, - ], + nextStepParams: { + open_sim: {}, + start_sim_log_cap: [ + { simulatorId, bundleId: params.bundleId }, + { simulatorId, bundleId: params.bundleId, captureConsole: true }, + ], + }, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/simulator/list_sims.ts b/src/mcp/tools/simulator/list_sims.ts index c1e3a69e..c4f10985 100644 --- a/src/mcp/tools/simulator/list_sims.ts +++ b/src/mcp/tools/simulator/list_sims.ts @@ -193,36 +193,16 @@ export async function list_simsLogic( text: responseText, }, ], - nextSteps: [ - { - tool: 'boot_sim', - label: 'Boot a simulator', - params: { simulatorId: 'UUID_FROM_ABOVE' }, - priority: 1, - }, - { - tool: 'open_sim', - label: 'Open the simulator UI', - params: {}, - priority: 2, - }, - { - tool: 'build_sim', - label: 'Build for simulator', - params: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, - priority: 3, + nextStepParams: { + boot_sim: { simulatorId: 'UUID_FROM_ABOVE' }, + open_sim: {}, + build_sim: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }, + get_sim_app_path: { + scheme: 'YOUR_SCHEME', + platform: 'iOS Simulator', + simulatorId: 'UUID_FROM_ABOVE', }, - { - tool: 'get_sim_app_path', - label: 'Get app path', - params: { - scheme: 'YOUR_SCHEME', - platform: 'iOS Simulator', - simulatorId: 'UUID_FROM_ABOVE', - }, - priority: 4, - }, - ], + }, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/simulator/open_sim.ts b/src/mcp/tools/simulator/open_sim.ts index 68f4c281..cfde7a7b 100644 --- a/src/mcp/tools/simulator/open_sim.ts +++ b/src/mcp/tools/simulator/open_sim.ts @@ -39,32 +39,14 @@ export async function open_simLogic( text: `Simulator app opened successfully.`, }, ], - nextSteps: [ - { - tool: 'boot_sim', - label: 'Boot a simulator if needed', - params: { simulatorId: 'UUID_FROM_LIST_SIMS' }, - priority: 1, - }, - { - tool: 'start_sim_log_cap', - label: 'Capture structured logs (app continues running)', - params: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, - priority: 2, - }, - { - tool: 'start_sim_log_cap', - label: 'Capture console + structured logs (app restarts)', - params: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true }, - priority: 3, - }, - { - tool: 'launch_app_logs_sim', - label: 'Launch app with logs in one step', - params: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, - priority: 4, - }, - ], + nextStepParams: { + boot_sim: { simulatorId: 'UUID_FROM_LIST_SIMS' }, + start_sim_log_cap: [ + { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, + { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true }, + ], + launch_app_logs_sim: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }, + }, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/mcp/tools/simulator/record_sim_video.ts b/src/mcp/tools/simulator/record_sim_video.ts index 90e85d23..9ec863a3 100644 --- a/src/mcp/tools/simulator/record_sim_video.ts +++ b/src/mcp/tools/simulator/record_sim_video.ts @@ -86,10 +86,8 @@ export async function record_sim_videoLogic( ); } - // using injected fs executor - if (params.start) { - const fpsUsed = Number.isFinite(params.fps as number) ? Number(params.fps) : 30; + const fpsUsed = params.fps ?? 30; const startRes = await video.startSimulatorVideoCapture( { simulatorUuid: params.simulatorId, fps: fpsUsed }, executor, @@ -127,18 +125,13 @@ export async function record_sim_videoLogic( ] : []), ], - nextSteps: [ - { - tool: 'record_sim_video', - label: 'Stop and save the recording', - params: { - simulatorId: params.simulatorId, - stop: true, - outputFile: '/path/to/output.mp4', - }, - priority: 1, + nextStepParams: { + record_sim_video: { + simulatorId: params.simulatorId, + stop: true, + outputFile: '/path/to/output.mp4', }, - ], + }, isError: false, }; } diff --git a/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts b/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts index bb73d1c5..d5431f44 100644 --- a/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts @@ -102,26 +102,6 @@ describe('Snapshot UI Plugin', () => { text: 'Tips:\n- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)\n- If a debugger is attached, ensure the app is running (not stopped on breakpoints)\n- Screenshots are for visual verification only', }, ], - nextSteps: [ - { - tool: 'snapshot_ui', - label: 'Refresh after layout changes', - params: { simulatorId: '12345678-1234-4234-8234-123456789012' }, - priority: 1, - }, - { - tool: 'tap_coordinate', - label: 'Tap on element', - params: { simulatorId: '12345678-1234-4234-8234-123456789012', x: 0, y: 0 }, - priority: 2, - }, - { - tool: 'take_screenshot', - label: 'Take screenshot for verification', - params: { simulatorId: '12345678-1234-4234-8234-123456789012' }, - priority: 3, - }, - ], }); }); diff --git a/src/mcp/tools/ui-automation/snapshot_ui.ts b/src/mcp/tools/ui-automation/snapshot_ui.ts index 00ab4518..80ea45fe 100644 --- a/src/mcp/tools/ui-automation/snapshot_ui.ts +++ b/src/mcp/tools/ui-automation/snapshot_ui.ts @@ -86,26 +86,6 @@ export async function snapshot_uiLogic( text: `Tips:\n- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)\n- If a debugger is attached, ensure the app is running (not stopped on breakpoints)\n- Screenshots are for visual verification only`, }, ], - nextSteps: [ - { - tool: 'snapshot_ui', - label: 'Refresh after layout changes', - params: { simulatorId }, - priority: 1, - }, - { - tool: 'tap_coordinate', - label: 'Tap on element', - params: { simulatorId, x: 0, y: 0 }, - priority: 2, - }, - { - tool: 'take_screenshot', - label: 'Take screenshot for verification', - params: { simulatorId }, - priority: 3, - }, - ], }; if (guard.warningText) { response.content.push({ type: 'text', text: guard.warningText }); @@ -203,16 +183,11 @@ async function executeAxeCommand( return result.output.trim(); } catch (error) { - if (error instanceof Error) { - if (error instanceof AxeError) { - throw error; - } - - // Otherwise wrap it in a SystemError - throw new SystemError(`Failed to execute axe command: ${error.message}`, error); + if (error instanceof AxeError) { + throw error; } - - // For any other type of error - throw new SystemError(`Failed to execute axe command: ${String(error)}`); + const message = error instanceof Error ? error.message : String(error); + const cause = error instanceof Error ? error : undefined; + throw new SystemError(`Failed to execute axe command: ${message}`, cause); } } diff --git a/src/runtime/__tests__/tool-invoker.test.ts b/src/runtime/__tests__/tool-invoker.test.ts index 21918e9d..6e1f6c1f 100644 --- a/src/runtime/__tests__/tool-invoker.test.ts +++ b/src/runtime/__tests__/tool-invoker.test.ts @@ -31,14 +31,19 @@ function textResponse(text: string): ToolResponse { function makeTool(opts: { cliName: string; + mcpName?: string; + id?: string; + nextStepTemplates?: ToolDefinition['nextStepTemplates']; workflow: string; stateful: boolean; handler: ToolDefinition['handler']; xcodeIdeRemoteToolName?: string; }): ToolDefinition { return { + id: opts.id, cliName: opts.cliName, - mcpName: opts.cliName.replace(/-/g, '_'), + mcpName: opts.mcpName ?? opts.cliName.replace(/-/g, '_'), + nextStepTemplates: opts.nextStepTemplates, workflow: opts.workflow, description: `${opts.cliName} tool`, mcpSchema: { value: z.string().optional() }, @@ -196,3 +201,239 @@ describe('DefaultToolInvoker xcode-ide dynamic routing', () => { expect(daemonClientMock.invokeXcodeIdeTool).not.toHaveBeenCalled(); }); }); + +describe('DefaultToolInvoker next steps post-processing', () => { + beforeEach(() => { + vi.clearAllMocks(); + daemonClientMock.isRunning.mockResolvedValue(true); + }); + + it('enriches canonical next-step tool names in CLI runtime', async () => { + const directHandler = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'ok' }], + nextSteps: [ + { + tool: 'screenshot', + label: 'Take screenshot', + params: { simulatorId: '123' }, + }, + ], + } satisfies ToolResponse); + + const catalog = createToolCatalog([ + makeTool({ + cliName: 'snapshot-ui', + mcpName: 'snapshot_ui', + workflow: 'ui-automation', + stateful: false, + handler: directHandler, + }), + makeTool({ + id: 'screenshot', + cliName: 'screenshot', + mcpName: 'screenshot', + workflow: 'ui-automation', + stateful: false, + handler: vi.fn().mockResolvedValue(textResponse('screenshot')), + }), + ]); + + const invoker = new DefaultToolInvoker(catalog); + const response = await invoker.invoke('snapshot-ui', {}, { runtime: 'cli' }); + + expect(response.nextSteps).toEqual([ + { + tool: 'screenshot', + label: 'Take screenshot', + params: { simulatorId: '123' }, + workflow: 'ui-automation', + cliTool: 'screenshot', + }, + ]); + }); + + it('injects manifest template next steps when a response omits nextSteps', async () => { + const directHandler = vi.fn().mockResolvedValue(textResponse('ok')); + const catalog = createToolCatalog([ + makeTool({ + id: 'snapshot_ui', + cliName: 'snapshot-ui', + mcpName: 'snapshot_ui', + workflow: 'ui-automation', + stateful: false, + nextStepTemplates: [ + { + label: 'Refresh', + toolId: 'snapshot_ui', + params: { simulatorId: '${simulatorId}' }, + }, + { + label: 'Visually verify hierarchy output', + }, + { + label: 'Tap on element', + toolId: 'tap', + params: { simulatorId: '${simulatorId}', x: 0, y: 0 }, + }, + ], + handler: directHandler, + }), + makeTool({ + id: 'tap', + cliName: 'tap', + mcpName: 'tap', + workflow: 'ui-automation', + stateful: false, + handler: vi.fn().mockResolvedValue(textResponse('tap')), + }), + ]); + + const invoker = new DefaultToolInvoker(catalog); + const response = await invoker.invoke( + 'snapshot-ui', + { simulatorId: '12345678-1234-4234-8234-123456789012' }, + { runtime: 'cli' }, + ); + + expect(response.nextSteps).toEqual([ + { + tool: 'snapshot_ui', + label: 'Refresh', + params: { simulatorId: '12345678-1234-4234-8234-123456789012' }, + workflow: 'ui-automation', + cliTool: 'snapshot-ui', + }, + { + label: 'Visually verify hierarchy output', + }, + { + tool: 'tap', + label: 'Tap on element', + params: { simulatorId: '12345678-1234-4234-8234-123456789012', x: 0, y: 0 }, + workflow: 'ui-automation', + cliTool: 'tap', + }, + ]); + }); + + it('prefers manifest templates over tool-provided next-step labels and tools', async () => { + const directHandler = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'ok' }], + nextSteps: [ + { + tool: 'legacy_stop_sim_log_cap', + label: 'Old label', + params: { logSessionId: 'session-123' }, + priority: 99, + }, + ], + } satisfies ToolResponse); + + const catalog = createToolCatalog([ + makeTool({ + id: 'start_sim_log_cap', + cliName: 'start-simulator-log-capture', + mcpName: 'start_sim_log_cap', + workflow: 'logging', + stateful: false, + nextStepTemplates: [ + { + label: 'Stop capture and retrieve logs', + toolId: 'stop_sim_log_cap', + priority: 1, + }, + ], + handler: directHandler, + }), + makeTool({ + id: 'stop_sim_log_cap', + cliName: 'stop-simulator-log-capture', + mcpName: 'stop_sim_log_cap', + workflow: 'logging', + stateful: true, + handler: vi.fn().mockResolvedValue(textResponse('stop')), + }), + ]); + + const invoker = new DefaultToolInvoker(catalog); + const response = await invoker.invoke('start-simulator-log-capture', {}, { runtime: 'cli' }); + + expect(response.nextSteps).toEqual([ + { + tool: 'stop_sim_log_cap', + label: 'Stop capture and retrieve logs', + params: { logSessionId: 'session-123' }, + priority: 1, + workflow: 'logging', + cliTool: 'stop-simulator-log-capture', + }, + ]); + }); + + it('keeps tool-provided next steps when template count does not match', async () => { + const directHandler = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'ok' }], + nextSteps: [ + { + tool: 'launch_app_sim', + label: 'Launch app (platform-specific)', + params: { simulatorId: '123', bundleId: 'com.example.app' }, + priority: 1, + }, + ], + } satisfies ToolResponse); + + const catalog = createToolCatalog([ + makeTool({ + id: 'get_sim_app_path', + cliName: 'get-app-path', + mcpName: 'get_sim_app_path', + workflow: 'simulator', + stateful: false, + nextStepTemplates: [ + { label: 'Get bundle ID', toolId: 'get_app_bundle_id', priority: 1 }, + { label: 'Boot simulator', toolId: 'boot_sim', priority: 2 }, + ], + handler: directHandler, + }), + makeTool({ + id: 'launch_app_sim', + cliName: 'launch-app', + mcpName: 'launch_app_sim', + workflow: 'simulator', + stateful: false, + handler: vi.fn().mockResolvedValue(textResponse('launch')), + }), + makeTool({ + id: 'get_app_bundle_id', + cliName: 'get-app-bundle-id', + mcpName: 'get_app_bundle_id', + workflow: 'project-discovery', + stateful: false, + handler: vi.fn().mockResolvedValue(textResponse('bundle')), + }), + makeTool({ + id: 'boot_sim', + cliName: 'boot', + mcpName: 'boot_sim', + workflow: 'simulator', + stateful: false, + handler: vi.fn().mockResolvedValue(textResponse('boot')), + }), + ]); + + const invoker = new DefaultToolInvoker(catalog); + const response = await invoker.invoke('get-app-path', {}, { runtime: 'cli' }); + + expect(response.nextSteps).toEqual([ + { + tool: 'launch_app_sim', + label: 'Launch app (platform-specific)', + params: { simulatorId: '123', bundleId: 'com.example.app' }, + priority: 1, + workflow: 'simulator', + cliTool: 'launch-app', + }, + ]); + }); +}); diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts index 87247a53..37687f6f 100644 --- a/src/runtime/tool-catalog.ts +++ b/src/runtime/tool-catalog.ts @@ -18,6 +18,7 @@ export function createToolCatalog(tools: ToolDefinition[]): ToolCatalog { // tools shared across multiple workflows don't cause ambiguous resolution. const byCliName = new Map(); const byMcpName = new Map(); + const byToolId = new Map(); const byMcpKebab = new Map(); const seenMcpNames = new Set(); @@ -28,10 +29,17 @@ export function createToolCatalog(tools: ToolDefinition[]): ToolCatalog { byCliName.set(tool.cliName, tool); byMcpName.set(mcpKey, tool); + if (tool.id) { + byToolId.set(tool.id, tool); + } const mcpKebab = toKebabCase(tool.mcpName); - const existing = byMcpKebab.get(mcpKebab) ?? []; - byMcpKebab.set(mcpKebab, [...existing, tool]); + let kebabGroup = byMcpKebab.get(mcpKebab); + if (!kebabGroup) { + kebabGroup = []; + byMcpKebab.set(mcpKebab, kebabGroup); + } + kebabGroup.push(tool); } return { @@ -45,6 +53,10 @@ export function createToolCatalog(tools: ToolDefinition[]): ToolCatalog { return byMcpName.get(name.toLowerCase().trim()) ?? null; }, + getByToolId(toolId: string): ToolDefinition | null { + return byToolId.get(toolId) ?? null; + }, + resolve(input: string): ToolResolution { const normalized = input.toLowerCase().trim(); @@ -89,8 +101,12 @@ export function groupToolsByWorkflow(catalog: ToolCatalog): Map(); for (const tool of catalog.tools) { - const existing = groups.get(tool.workflow) ?? []; - groups.set(tool.workflow, [...existing, tool]); + let group = groups.get(tool.workflow); + if (!group) { + group = []; + groups.set(tool.workflow, group); + } + group.push(tool); } return groups; @@ -120,16 +136,12 @@ export async function buildToolCatalogFromManifest(opts: { workflowsToInclude = Array.from(manifest.workflows.values()); } - // Filter workflows - const filteredWorkflows = workflowsToInclude.filter((wf) => { - // Check exclusion list - if (excludeSet.has(wf.id.toLowerCase())) return false; - // Check runtime availability - if (!isWorkflowAvailableForRuntime(wf, opts.runtime)) return false; - // Check predicates - if (!isWorkflowEnabledForRuntime(wf, opts.ctx)) return false; - return true; - }); + const filteredWorkflows = workflowsToInclude.filter( + (wf) => + !excludeSet.has(wf.id.toLowerCase()) && + isWorkflowAvailableForRuntime(wf, opts.runtime) && + isWorkflowEnabledForRuntime(wf, opts.ctx), + ); // Cache imported modules to avoid re-importing the same tool const moduleCache = new Map>>(); @@ -160,11 +172,13 @@ export async function buildToolCatalogFromManifest(opts: { const cliName = getEffectiveCliName(toolManifest); tools.push({ + id: toolManifest.id, cliName, mcpName: toolManifest.names.mcp, workflow: workflow.id, description: toolManifest.description, annotations: toolManifest.annotations, + nextStepTemplates: toolManifest.nextSteps, mcpSchema: toolModule.schema, cliSchema: toolModule.schema, stateful: toolManifest.routing?.stateful ?? false, diff --git a/src/runtime/tool-invoker.ts b/src/runtime/tool-invoker.ts index 739cbe6a..72060808 100644 --- a/src/runtime/tool-invoker.ts +++ b/src/runtime/tool-invoker.ts @@ -1,14 +1,138 @@ import type { ToolCatalog, ToolDefinition, ToolInvoker, InvokeOptions } from './types.ts'; -import type { ToolResponse } from '../types/common.ts'; +import type { NextStep, NextStepParams, NextStepParamsMap, ToolResponse } from '../types/common.ts'; import { createErrorResponse } from '../utils/responses/index.ts'; import { DaemonClient } from '../cli/daemon-client.ts'; import { ensureDaemonRunning, DEFAULT_DAEMON_STARTUP_TIMEOUT_MS } from '../cli/daemon-control.ts'; /** - * Enrich nextSteps for CLI rendering. - * Resolves MCP tool names to their workflow and CLI command name. + * Resolve template params using input args. + * Supports primitive passthrough and ${argName} substitution. */ -function enrichNextStepsForCli(response: ToolResponse, catalog: ToolCatalog): ToolResponse { +function resolveTemplateParams( + params: Record, + args: Record, +): Record { + const resolved: Record = {}; + + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'string') { + const match = value.match(/^\$\{([^}]+)\}$/); + if (match) { + const argValue = args[match[1]]; + if ( + typeof argValue === 'string' || + typeof argValue === 'number' || + typeof argValue === 'boolean' + ) { + resolved[key] = argValue; + continue; + } + } + } + resolved[key] = value; + } + + return resolved; +} + +function buildTemplateNextSteps( + tool: ToolDefinition, + args: Record, + catalog: ToolCatalog, +): NextStep[] { + if (!tool.nextStepTemplates || tool.nextStepTemplates.length === 0) { + return []; + } + + const built: NextStep[] = []; + for (const template of tool.nextStepTemplates) { + if (!template.toolId) { + built.push({ + label: template.label, + priority: template.priority, + }); + continue; + } + + const target = catalog.getByToolId(template.toolId); + if (!target) { + continue; + } + + built.push({ + tool: target.mcpName, + label: template.label, + params: resolveTemplateParams(template.params ?? {}, args), + priority: template.priority, + }); + } + + return built; +} + +function hasTemplateParams(step: NextStep): boolean { + return !!step.params && Object.keys(step.params).length > 0; +} + +function consumeDynamicParams( + nextStepParams: NextStepParamsMap | undefined, + toolId: string, + consumedCounts: Map, +): NextStepParams | undefined { + const candidate = nextStepParams?.[toolId]; + if (!candidate) { + return undefined; + } + + if (Array.isArray(candidate)) { + const current = consumedCounts.get(toolId) ?? 0; + consumedCounts.set(toolId, current + 1); + return candidate[current]; + } + + return candidate; +} + +function mergeTemplateAndResponseNextSteps( + tool: ToolDefinition, + templateSteps: NextStep[], + responseParamsMap: NextStepParamsMap | undefined, + responseSteps: NextStep[] | undefined, +): NextStep[] { + const consumedCounts = new Map(); + const templates = tool.nextStepTemplates ?? []; + + return templateSteps.map((templateStep, index) => { + const template = templates[index]; + if (!template?.toolId || !templateStep.tool || hasTemplateParams(templateStep)) { + return templateStep; + } + + const paramsFromMap = consumeDynamicParams(responseParamsMap, template.toolId, consumedCounts); + if (paramsFromMap) { + return { + ...templateStep, + params: paramsFromMap, + }; + } + + const fallbackStep = responseSteps?.[index]; + if (!fallbackStep?.params) { + return templateStep; + } + + return { + ...templateStep, + params: fallbackStep.params, + }; + }); +} + +function normalizeNextSteps( + response: ToolResponse, + catalog: ToolCatalog, + runtime: InvokeOptions['runtime'], +): ToolResponse { if (!response.nextSteps || response.nextSteps.length === 0) { return response; } @@ -16,20 +140,63 @@ function enrichNextStepsForCli(response: ToolResponse, catalog: ToolCatalog): To return { ...response, nextSteps: response.nextSteps.map((step) => { + if (!step.tool) { + return step; + } + const target = catalog.getByMcpName(step.tool); if (!target) { return step; } - return { - ...step, - workflow: target.workflow, - cliTool: target.cliName, // Canonical CLI name from manifest - }; + return runtime === 'cli' + ? { + ...step, + tool: target.mcpName, + workflow: target.workflow, + cliTool: target.cliName, + } + : { + ...step, + tool: target.mcpName, + }; }), }; } +function postProcessToolResponse(params: { + tool: ToolDefinition; + response: ToolResponse; + args: Record; + catalog: ToolCatalog; + runtime: InvokeOptions['runtime']; +}): ToolResponse { + const { tool, response, args, catalog, runtime } = params; + + const templateSteps = buildTemplateNextSteps(tool, args, catalog); + const canApplyTemplates = + templateSteps.length > 0 && + (!response.nextSteps || + response.nextSteps.length === 0 || + response.nextSteps.length === templateSteps.length); + + const withTemplates = canApplyTemplates + ? { + ...response, + nextSteps: mergeTemplateAndResponseNextSteps( + tool, + templateSteps, + response.nextStepParams, + response.nextSteps, + ), + } + : response; + + const result = normalizeNextSteps(withTemplates, catalog, runtime); + delete result.nextStepParams; + return result; +} + function buildDaemonEnvOverrides(opts: InvokeOptions): Record | undefined { const envOverrides: Record = {}; @@ -80,49 +247,77 @@ export class DefaultToolInvoker implements ToolInvoker { return this.executeTool(tool, args, opts); } - private async executeTool( + private buildPostProcessParams( tool: ToolDefinition, args: Record, - opts: InvokeOptions, - ): Promise { - const xcodeIdeRemoteToolName = tool.xcodeIdeRemoteToolName; - const isDynamicXcodeIdeTool = - tool.workflow === 'xcode-ide' && typeof xcodeIdeRemoteToolName === 'string'; + runtime: InvokeOptions['runtime'], + ): { + tool: ToolDefinition; + args: Record; + catalog: ToolCatalog; + runtime: InvokeOptions['runtime']; + } { + return { tool, args, catalog: this.catalog, runtime }; + } - if (opts.runtime === 'cli' && isDynamicXcodeIdeTool) { - const socketPath = opts.socketPath; - if (!socketPath) { - return createErrorResponse( + private async ensureDaemonClient( + opts: InvokeOptions, + ): Promise<{ client: DaemonClient } | { error: ToolResponse }> { + const socketPath = opts.socketPath; + if (!socketPath) { + return { + error: createErrorResponse( 'Socket path required', `No socket path configured for daemon communication.`, - ); - } + ), + }; + } - const envOverrideValue = buildDaemonEnvOverrides(opts); - const client = new DaemonClient({ socketPath }); - - const isRunning = await client.isRunning(); - if (!isRunning) { - try { - await ensureDaemonRunning({ - socketPath, - workspaceRoot: opts.workspaceRoot, - startupTimeoutMs: opts.daemonStartupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS, - env: envOverrideValue, - }); - } catch (error) { - return createErrorResponse( + const client = new DaemonClient({ socketPath }); + const isRunning = await client.isRunning(); + + if (!isRunning) { + try { + await ensureDaemonRunning({ + socketPath, + workspaceRoot: opts.workspaceRoot, + startupTimeoutMs: opts.daemonStartupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS, + env: buildDaemonEnvOverrides(opts), + }); + } catch (error) { + return { + error: createErrorResponse( 'Daemon auto-start failed', (error instanceof Error ? error.message : String(error)) + `\n\nYou can try starting the daemon manually:\n` + ` xcodebuildmcp daemon start`, - ); - } + ), + }; } + } + + return { client }; + } + + private async executeTool( + tool: ToolDefinition, + args: Record, + opts: InvokeOptions, + ): Promise { + const xcodeIdeRemoteToolName = tool.xcodeIdeRemoteToolName; + const isDynamicXcodeIdeTool = + tool.workflow === 'xcode-ide' && typeof xcodeIdeRemoteToolName === 'string'; + + if (opts.runtime === 'cli' && isDynamicXcodeIdeTool) { + const result = await this.ensureDaemonClient(opts); + if ('error' in result) return result.error; try { - const response = await client.invokeXcodeIdeTool(xcodeIdeRemoteToolName, args); - return enrichNextStepsForCli(response, this.catalog); + const response = await result.client.invokeXcodeIdeTool(xcodeIdeRemoteToolName, args); + return postProcessToolResponse({ + ...this.buildPostProcessParams(tool, args, opts.runtime), + response, + }); } catch (error) { return createErrorResponse( 'Xcode IDE invocation failed', @@ -131,58 +326,31 @@ export class DefaultToolInvoker implements ToolInvoker { } } - const mustUseDaemon = tool.stateful; - - if (opts.runtime === 'cli') { - if (mustUseDaemon) { - // Route through daemon with auto-start - const socketPath = opts.socketPath; - if (!socketPath) { - return createErrorResponse( - 'Socket path required', - `No socket path configured for daemon communication.`, - ); - } - - const client = new DaemonClient({ socketPath }); - const envOverrideValue = buildDaemonEnvOverrides(opts); - - // Check if daemon is running; auto-start if not - const isRunning = await client.isRunning(); - if (!isRunning) { - try { - await ensureDaemonRunning({ - socketPath, - workspaceRoot: opts.workspaceRoot, - startupTimeoutMs: opts.daemonStartupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS, - env: envOverrideValue, - }); - } catch (error) { - return createErrorResponse( - 'Daemon auto-start failed', - (error instanceof Error ? error.message : String(error)) + - `\n\nYou can try starting the daemon manually:\n` + - ` xcodebuildmcp daemon start`, - ); - } - } + if (opts.runtime === 'cli' && tool.stateful) { + const result = await this.ensureDaemonClient(opts); + if ('error' in result) return result.error; - try { - const response = await client.invokeTool(tool.mcpName, args); - return opts.runtime === 'cli' ? enrichNextStepsForCli(response, this.catalog) : response; - } catch (error) { - return createErrorResponse( - 'Daemon invocation failed', - error instanceof Error ? error.message : String(error), - ); - } + try { + const response = await result.client.invokeTool(tool.mcpName, args); + return postProcessToolResponse({ + ...this.buildPostProcessParams(tool, args, opts.runtime), + response, + }); + } catch (error) { + return createErrorResponse( + 'Daemon invocation failed', + error instanceof Error ? error.message : String(error), + ); } } // Direct invocation (CLI stateless or daemon internal) try { const response = await tool.handler(args); - return opts.runtime === 'cli' ? enrichNextStepsForCli(response, this.catalog) : response; + return postProcessToolResponse({ + ...this.buildPostProcessParams(tool, args, opts.runtime), + response, + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return createErrorResponse('Tool execution failed', message); diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 4e0e347f..ef9a505d 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -2,9 +2,19 @@ import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; import type { ToolResponse } from '../types/common.ts'; import type { ToolSchemaShape, PluginMeta } from '../core/plugin-types.ts'; +export interface NextStepTemplate { + label: string; + toolId?: string; + params?: Record; + priority?: number; +} + export type RuntimeKind = 'cli' | 'daemon' | 'mcp'; export interface ToolDefinition { + /** Stable manifest tool id for static tools loaded from YAML */ + id?: string; + /** Stable CLI command name (kebab-case, disambiguated) */ cliName: string; @@ -17,6 +27,9 @@ export interface ToolDefinition { description?: string; annotations?: ToolAnnotations; + /** Static next-step templates declared in the manifest */ + nextStepTemplates?: NextStepTemplate[]; + /** * Schema shape used to generate yargs flags for CLI. * Must include ALL parameters (not the session-default-hidden version). @@ -59,7 +72,10 @@ export interface ToolCatalog { /** Exact match on MCP name */ getByMcpName(name: string): ToolDefinition | null; - /** Resolve user input, supporting aliases + ambiguity reporting */ + /** Exact match on stable manifest tool id */ + getByToolId(toolId: string): ToolDefinition | null; + + /** Resolve user input with ambiguity reporting */ resolve(input: string): ToolResolution; } diff --git a/src/test-utils/next-step-assertions.ts b/src/test-utils/next-step-assertions.ts new file mode 100644 index 00000000..465c445a --- /dev/null +++ b/src/test-utils/next-step-assertions.ts @@ -0,0 +1,66 @@ +import { expect } from 'vitest'; +import { loadManifest } from '../core/manifest/load-manifest.ts'; +import type { NextStep } from '../types/common.ts'; + +function normalizeToolName(name: string): string { + return name.trim().toLowerCase(); +} + +type MappedManifestTool = { + id: string; + names: { mcp: string }; + aliases?: { mcp?: string[] }; +}; + +function createMcpToolToIdResolver(): (toolName: string | undefined) => string | undefined { + const nameToId = new Map(); + const manifest = loadManifest(); + + for (const rawTool of manifest.tools.values()) { + const tool = rawTool as MappedManifestTool; + nameToId.set(normalizeToolName(tool.names.mcp), tool.id); + + for (const alias of tool.aliases?.mcp ?? []) { + nameToId.set(normalizeToolName(alias), tool.id); + } + } + + return function resolveMcpToolToId(toolName: string | undefined): string | undefined { + if (!toolName) { + return undefined; + } + + return nameToId.get(normalizeToolName(toolName)); + }; +} + +const resolveMcpToolToId = createMcpToolToIdResolver(); + +export function expectNextStepByToolId( + steps: NextStep[] | undefined, + expected: { toolId: string; label: string; priority?: number }, +): NextStep { + expect(steps).toBeDefined(); + expect(steps!.length).toBeGreaterThan(0); + + const matchesByToolId = + steps?.filter((step) => resolveMcpToolToId(step.tool) === expected.toolId) ?? []; + const matches = + matchesByToolId.length > 0 + ? matchesByToolId + : (steps?.filter( + (step) => + step.label === expected.label && + (expected.priority === undefined || step.priority === expected.priority), + ) ?? []); + expect(matches).toHaveLength(1); + + const step = matches[0]; + expect(step.label).toBe(expected.label); + + if (expected.priority !== undefined) { + expect(step.priority).toBe(expected.priority); + } + + return step; +} diff --git a/src/types/common.ts b/src/types/common.ts index 93e88179..8d49bccf 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -16,20 +16,23 @@ * Represents a suggested next step that can be rendered for CLI or MCP. */ export interface NextStep { - /** MCP tool name (e.g., "boot_sim") */ - tool: string; + /** Optional MCP tool name (e.g., "boot_sim") */ + tool?: string; /** CLI tool name (kebab-case, disambiguated) */ cliTool?: string; /** Workflow name for CLI grouping (e.g., "simulator") */ workflow?: string; - /** Human-readable description of the action */ - label: string; - /** Parameters to pass to the tool */ - params: Record; - /** Lower priority values appear first (default: 0) */ + /** Human-readable description of the action (optional when manifest template provides it) */ + label?: string; + /** Optional parameters to pass to the tool */ + params?: Record; + /** Optional ordering hint for merged steps */ priority?: number; } +export type NextStepParams = Record; +export type NextStepParamsMap = Record; + /** * Output style controls verbosity of tool responses. * - 'normal': Full output including next steps @@ -62,6 +65,8 @@ export interface ToolResponse { _meta?: Record; /** Structured next steps that get rendered differently for CLI vs MCP */ nextSteps?: NextStep[]; + /** Dynamic params for manifest nextSteps keyed by toolId */ + nextStepParams?: NextStepParamsMap; [key: string]: unknown; // Index signature to match CallToolResult } diff --git a/src/utils/responses/__tests__/next-steps-renderer.test.ts b/src/utils/responses/__tests__/next-steps-renderer.test.ts index 418b8a59..c0ff6080 100644 --- a/src/utils/responses/__tests__/next-steps-renderer.test.ts +++ b/src/utils/responses/__tests__/next-steps-renderer.test.ts @@ -51,16 +51,13 @@ describe('next-steps-renderer', () => { ); }); - it('should throw error for CLI without cliTool', () => { + it('should fallback to kebab-case tool name for CLI without cliTool', () => { const step: NextStep = { tool: 'open_sim', label: 'Open the Simulator app', - params: {}, }; - expect(() => renderNextStep(step, 'cli')).toThrow( - "Next step for tool 'open_sim' is missing cliTool - ensure enrichNextStepsForCli was called", - ); + expect(renderNextStep(step, 'cli')).toBe('Open the Simulator app: xcodebuildmcp open-sim'); }); it('should format step for CLI without workflow', () => { @@ -103,7 +100,6 @@ describe('next-steps-renderer', () => { const step: NextStep = { tool: 'open_sim', label: 'Open the Simulator app', - params: {}, }; const result = renderNextStep(step, 'mcp'); @@ -149,12 +145,20 @@ describe('next-steps-renderer', () => { const step: NextStep = { tool: 'open_sim', label: 'Open the Simulator app', - params: {}, }; const result = renderNextStep(step, 'daemon'); expect(result).toBe('Open the Simulator app: open_sim()'); }); + + it('should render label-only step as plain text', () => { + const step: NextStep = { + label: 'Verify layout visually before continuing', + }; + + expect(renderNextStep(step, 'cli')).toBe('Verify layout visually before continuing'); + expect(renderNextStep(step, 'mcp')).toBe('Verify layout visually before continuing'); + }); }); describe('renderNextStepsSection', () => { @@ -196,28 +200,28 @@ describe('next-steps-renderer', () => { ); }); - it('should sort by priority', () => { + it('should keep declared order', () => { const steps: NextStep[] = [ - { tool: 'third', label: 'Third', params: {}, priority: 3 }, - { tool: 'first', label: 'First', params: {}, priority: 1 }, - { tool: 'second', label: 'Second', params: {}, priority: 2 }, + { tool: 'third', label: 'Third', params: {} }, + { tool: 'first', label: 'First', params: {} }, + { tool: 'second', label: 'Second', params: {} }, ]; const result = renderNextStepsSection(steps, 'mcp'); - expect(result).toContain('1. First: first()'); - expect(result).toContain('2. Second: second()'); - expect(result).toContain('3. Third: third()'); + expect(result).toContain('1. Third: third()'); + expect(result).toContain('2. First: first()'); + expect(result).toContain('3. Second: second()'); }); - it('should handle missing priority (defaults to 0)', () => { + it('should render label-only next step without command', () => { const steps: NextStep[] = [ - { tool: 'later', label: 'Later', params: {}, priority: 1 }, - { tool: 'first', label: 'First', params: {} }, + { label: 'Take a look at the screenshot' }, + { tool: 'open_sim', label: 'Open simulator', params: {} }, ]; - const result = renderNextStepsSection(steps, 'mcp'); - expect(result).toContain('1. First: first()'); - expect(result).toContain('2. Later: later()'); + const result = renderNextStepsSection(steps, 'cli'); + expect(result).toContain('1. Take a look at the screenshot'); + expect(result).toContain('2. Open simulator: xcodebuildmcp open-sim'); }); }); diff --git a/src/utils/responses/next-steps-renderer.ts b/src/utils/responses/next-steps-renderer.ts index 4aa77871..4ad71e46 100644 --- a/src/utils/responses/next-steps-renderer.ts +++ b/src/utils/responses/next-steps-renderer.ts @@ -11,27 +11,34 @@ function toKebabCase(name: string): string { .toLowerCase(); } +function resolveLabel(step: NextStep): string { + if (step.label?.trim()) return step.label; + if (step.tool) return step.tool; + if (step.cliTool) return step.cliTool; + return 'Next action'; +} + /** * Format a single next step for CLI output. * Example: xcodebuildmcp simulator open-sim * Example: xcodebuildmcp simulator install-app-sim --simulator-id "ABC123" --app-path "PATH" */ function formatNextStepForCli(step: NextStep): string { - if (!step.cliTool) { - throw new Error( - `Next step for tool '${step.tool}' is missing cliTool - ensure enrichNextStepsForCli was called`, - ); + if (!step.tool) { + return resolveLabel(step); } const parts = ['xcodebuildmcp']; + const cliTool = step.cliTool ?? toKebabCase(step.tool); + const params = step.params ?? {}; // Include workflow as subcommand if provided if (step.workflow) { parts.push(step.workflow); } - parts.push(step.cliTool); + parts.push(cliTool); - for (const [key, value] of Object.entries(step.params)) { + for (const [key, value] of Object.entries(params)) { const flagName = toKebabCase(key); if (typeof value === 'boolean') { if (value) { @@ -51,7 +58,11 @@ function formatNextStepForCli(step: NextStep): string { * Example: install_app_sim({ simulatorId: "ABC123", appPath: "PATH" }) */ function formatNextStepForMcp(step: NextStep): string { - const paramEntries = Object.entries(step.params); + if (!step.tool) { + return resolveLabel(step); + } + + const paramEntries = Object.entries(step.params ?? {}); if (paramEntries.length === 0) { return `${step.tool}()`; } @@ -72,7 +83,13 @@ function formatNextStepForMcp(step: NextStep): string { * Render a single next step based on runtime. */ export function renderNextStep(step: NextStep, runtime: RuntimeKind): string { + if (!step.tool) { + return resolveLabel(step); + } const formatted = runtime === 'cli' ? formatNextStepForCli(step) : formatNextStepForMcp(step); + if (!step.label) { + return formatted; + } return `${step.label}: ${formatted}`; } diff --git a/src/visibility/__tests__/exposure.test.ts b/src/visibility/__tests__/exposure.test.ts index ab43b0ad..e87de4c7 100644 --- a/src/visibility/__tests__/exposure.test.ts +++ b/src/visibility/__tests__/exposure.test.ts @@ -52,6 +52,7 @@ function createTool(overrides: Partial = {}): ToolManifestEnt names: { mcp: 'test_tool' }, availability: { mcp: true, cli: true }, predicates: [], + nextSteps: [], ...overrides, }; } From 7c062ae20fbb3b604638a64ac918e5ca149e75bc Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 11 Feb 2026 22:52:19 +0000 Subject: [PATCH 2/2] fix: align simulator app path platform typing --- .../__tests__/get_sim_app_path.test.ts | 17 ++++----- src/mcp/tools/simulator/get_sim_app_path.ts | 36 +++++++++++-------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts index c94cfc63..0d650d2c 100644 --- a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts +++ b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts @@ -10,6 +10,7 @@ import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { schema, handler, get_sim_app_pathLogic } from '../get_sim_app_path.ts'; import type { CommandExecutor } from '../../../../utils/CommandExecutor.ts'; +import { XcodePlatform } from '../../../../types/common.ts'; describe('get_sim_app_path tool', () => { beforeEach(() => { @@ -24,7 +25,7 @@ describe('get_sim_app_path tool', () => { it('should expose only platform in public schema', () => { const schemaObj = z.object(schema); - expect(schemaObj.safeParse({ platform: 'iOS Simulator' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: XcodePlatform.iOSSimulator }).success).toBe(true); expect(schemaObj.safeParse({}).success).toBe(false); expect(schemaObj.safeParse({ platform: 'iOS' }).success).toBe(false); @@ -36,7 +37,7 @@ describe('get_sim_app_path tool', () => { describe('Handler Requirements', () => { it('should require scheme when not provided', async () => { const result = await handler({ - platform: 'iOS Simulator', + platform: XcodePlatform.iOSSimulator, }); expect(result.isError).toBe(true); @@ -47,7 +48,7 @@ describe('get_sim_app_path tool', () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); const result = await handler({ - platform: 'iOS Simulator', + platform: XcodePlatform.iOSSimulator, }); expect(result.isError).toBe(true); @@ -61,7 +62,7 @@ describe('get_sim_app_path tool', () => { }); const result = await handler({ - platform: 'iOS Simulator', + platform: XcodePlatform.iOSSimulator, }); expect(result.isError).toBe(true); @@ -72,7 +73,7 @@ describe('get_sim_app_path tool', () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); const result = await handler({ - platform: 'iOS Simulator', + platform: XcodePlatform.iOSSimulator, projectPath: '/path/project.xcodeproj', workspacePath: '/path/workspace.xcworkspace', }); @@ -90,7 +91,7 @@ describe('get_sim_app_path tool', () => { }); const result = await handler({ - platform: 'iOS Simulator', + platform: XcodePlatform.iOSSimulator, simulatorId: 'SIM-UUID', simulatorName: 'iPhone 16', }); @@ -134,7 +135,7 @@ describe('get_sim_app_path tool', () => { { workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', - platform: 'iOS Simulator', + platform: XcodePlatform.iOSSimulator, simulatorName: 'iPhone 16', useLatestOS: true, }, @@ -173,7 +174,7 @@ describe('get_sim_app_path tool', () => { { projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', - platform: 'iOS Simulator', + platform: XcodePlatform.iOSSimulator, simulatorId: 'SIM-UUID', }, mockExecutor, diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts index ec7268a0..9cbe89da 100644 --- a/src/mcp/tools/simulator/get_sim_app_path.ts +++ b/src/mcp/tools/simulator/get_sim_app_path.ts @@ -19,19 +19,23 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +const SIMULATOR_PLATFORMS = [ + XcodePlatform.iOSSimulator, + XcodePlatform.watchOSSimulator, + XcodePlatform.tvOSSimulator, + XcodePlatform.visionOSSimulator, +] as const; + function constructDestinationString( - platform: string, + platform: XcodePlatform, simulatorName: string, simulatorId: string, useLatest: boolean = true, arch?: string, ): string { - const isSimulatorPlatform = [ - XcodePlatform.iOSSimulator, - XcodePlatform.watchOSSimulator, - XcodePlatform.tvOSSimulator, - XcodePlatform.visionOSSimulator, - ].includes(platform); + const isSimulatorPlatform = SIMULATOR_PLATFORMS.includes( + platform as (typeof SIMULATOR_PLATFORMS)[number], + ); // If ID is provided for a simulator, it takes precedence and uniquely identifies it. if (isSimulatorPlatform && simulatorId) { @@ -76,7 +80,14 @@ const baseGetSimulatorAppPathSchema = z.object({ .optional() .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'), scheme: z.string().describe('The scheme to use (Required)'), - platform: z.enum(['iOS Simulator', 'watchOS Simulator', 'tvOS Simulator', 'visionOS Simulator']), + platform: z + .nativeEnum(XcodePlatform) + .refine( + (platform) => SIMULATOR_PLATFORMS.includes(platform as (typeof SIMULATOR_PLATFORMS)[number]), + { + message: 'platform must be a simulator platform', + }, + ), simulatorId: z .string() .optional() @@ -162,12 +173,9 @@ export async function get_sim_app_pathLogic( command.push('-configuration', configuration); // Handle destination based on platform - const isSimulatorPlatform = [ - XcodePlatform.iOSSimulator, - XcodePlatform.watchOSSimulator, - XcodePlatform.tvOSSimulator, - XcodePlatform.visionOSSimulator, - ].includes(platform); + const isSimulatorPlatform = SIMULATOR_PLATFORMS.includes( + platform as (typeof SIMULATOR_PLATFORMS)[number], + ); let destinationString = '';