From 9b063eae7377bbb559fd980782364a9a497bb31f Mon Sep 17 00:00:00 2001 From: Harald Kirschner Date: Tue, 10 Jun 2025 21:39:42 -0700 Subject: [PATCH] Add progress notifications for build tools --- src/tools/build_ios_simulator.ts | 231 +++++++++++++++++++++++-------- src/tools/common.ts | 38 +++++ src/utils/build-utils.ts | 113 +++++++++++++++ 3 files changed, 325 insertions(+), 57 deletions(-) diff --git a/src/tools/build_ios_simulator.ts b/src/tools/build_ios_simulator.ts index 72df9c7e..d1938aad 100644 --- a/src/tools/build_ios_simulator.ts +++ b/src/tools/build_ios_simulator.ts @@ -31,6 +31,7 @@ import { simulatorIdSchema, useLatestOSSchema, preferXcodebuildSchema, + registerToolWithProgress, } from './common.js'; import { execSync } from 'child_process'; @@ -39,20 +40,42 @@ import { execSync } from 'child_process'; /** * Internal logic for building Simulator apps. */ -async function _handleSimulatorBuildLogic(params: { - workspacePath?: string; - projectPath?: string; - scheme: string; - configuration: string; - simulatorName?: string; - simulatorId?: string; - useLatestOS: boolean; - derivedDataPath?: string; - extraArgs?: string[]; - preferXcodebuild?: boolean; -}): Promise { +async function _handleSimulatorBuildLogic( + params: { + workspacePath?: string; + projectPath?: string; + scheme: string; + configuration: string; + simulatorName?: string; + simulatorId?: string; + useLatestOS: boolean; + derivedDataPath?: string; + extraArgs?: string[]; + preferXcodebuild?: boolean; + }, + context?: { + sendNotification?: (notification: { + method: string; + params: Record; + }) => Promise; + _meta?: Record; + }, +): Promise { log('info', `Starting iOS Simulator build for scheme ${params.scheme} (internal)`); + // Send initial progress notification + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 0, + total: 100, + message: 'Starting iOS Simulator build...', + }, + }); + } + return executeXcodeBuildCommand( { ...params, @@ -66,35 +89,69 @@ async function _handleSimulatorBuildLogic(params: { }, params.preferXcodebuild, 'build', + context, // Pass context for progress notifications ); } /** * Internal logic for building and running iOS Simulator apps. */ -async function _handleIOSSimulatorBuildAndRunLogic(params: { - workspacePath?: string; - projectPath?: string; - scheme: string; - configuration: string; - simulatorName?: string; - simulatorId?: string; - useLatestOS: boolean; - derivedDataPath?: string; - extraArgs?: string[]; - preferXcodebuild?: boolean; -}): Promise { +async function _handleIOSSimulatorBuildAndRunLogic( + params: { + workspacePath?: string; + projectPath?: string; + scheme: string; + configuration: string; + simulatorName?: string; + simulatorId?: string; + useLatestOS: boolean; + derivedDataPath?: string; + extraArgs?: string[]; + preferXcodebuild?: boolean; + }, + context?: { + sendNotification?: (notification: { + method: string; + params: Record; + }) => Promise; + _meta?: Record; + }, +): Promise { log('info', `Starting iOS Simulator build and run for scheme ${params.scheme} (internal)`); + // Send initial progress notification + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 0, + total: 100, + message: 'Starting iOS Simulator build and run...', + }, + }); + } + try { // --- Build Step --- - const buildResult = await _handleSimulatorBuildLogic(params); + const buildResult = await _handleSimulatorBuildLogic(params, context); if (buildResult.isError) { return buildResult; // Return the build error } // --- Get App Path Step --- + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 50, + total: 100, + message: 'Getting app path...', + }, + }); + } // Create the command array for xcodebuild with -showBuildSettings option const command = ['xcodebuild', '-showBuildSettings']; @@ -161,6 +218,18 @@ async function _handleIOSSimulatorBuildAndRunLogic(params: { log('info', `App bundle path for run: ${appBundlePath}`); // --- Find/Boot Simulator Step --- + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 60, + total: 100, + message: 'Setting up simulator...', + }, + }); + } + let simulatorUuid = params.simulatorId; if (!simulatorUuid && params.simulatorName) { try { @@ -253,6 +322,18 @@ async function _handleIOSSimulatorBuildAndRunLogic(params: { } // --- Install App Step --- + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 80, + total: 100, + message: 'Installing app on simulator...', + }, + }); + } + try { log('info', `Installing app at path: ${appBundlePath} to simulator: ${simulatorUuid}`); execSync(`xcrun simctl install "${simulatorUuid}" "${appBundlePath}"`); @@ -303,6 +384,18 @@ async function _handleIOSSimulatorBuildAndRunLogic(params: { } // --- Launch App Step --- + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 95, + total: 100, + message: 'Launching app...', + }, + }); + } + try { log('info', `Launching app with bundle ID: ${bundleId} on simulator: ${simulatorUuid}`); execSync(`xcrun simctl launch "${simulatorUuid}" "${bundleId}"`); @@ -316,6 +409,18 @@ async function _handleIOSSimulatorBuildAndRunLogic(params: { } // --- Success --- + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 100, + total: 100, + message: 'App launched successfully!', + }, + }); + } + log('info', '✅ iOS simulator build & run succeeded.'); const target = params.simulatorId @@ -571,7 +676,7 @@ export function registerSimulatorBuildAndRunByNameWorkspaceTool(server: McpServe preferXcodebuild?: boolean; }; - registerTool( + registerToolWithProgress( server, 'build_run_sim_name_ws', "Builds and runs an app from a workspace on a simulator specified by name. IMPORTANT: Requires workspacePath, scheme, and simulatorName. Example: build_run_sim_name_ws({ workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16' })", @@ -585,7 +690,7 @@ export function registerSimulatorBuildAndRunByNameWorkspaceTool(server: McpServe useLatestOS: useLatestOSSchema, preferXcodebuild: preferXcodebuildSchema, }, - async (params: Params) => { + async (params: Params, context) => { // Validate required parameters const workspaceValidation = validateRequiredParam('workspacePath', params.workspacePath); if (!workspaceValidation.isValid) return workspaceValidation.errorResponse!; @@ -597,12 +702,15 @@ export function registerSimulatorBuildAndRunByNameWorkspaceTool(server: McpServe if (!simulatorNameValidation.isValid) return simulatorNameValidation.errorResponse!; // Provide defaults - return _handleIOSSimulatorBuildAndRunLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, - preferXcodebuild: params.preferXcodebuild ?? false, - }); + return _handleIOSSimulatorBuildAndRunLogic( + { + ...params, + configuration: params.configuration ?? 'Debug', + useLatestOS: params.useLatestOS ?? true, + preferXcodebuild: params.preferXcodebuild ?? false, + }, + context, + ); }, ); } @@ -622,7 +730,7 @@ export function registerSimulatorBuildAndRunByNameProjectTool(server: McpServer) preferXcodebuild?: boolean; }; - registerTool( + registerToolWithProgress( server, 'build_run_sim_name_proj', "Builds and runs an app from a project file on a simulator specified by name. IMPORTANT: Requires projectPath, scheme, and simulatorName. Example: build_run_sim_name_proj({ projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', simulatorName: 'iPhone 16' })", @@ -636,7 +744,7 @@ export function registerSimulatorBuildAndRunByNameProjectTool(server: McpServer) useLatestOS: useLatestOSSchema, preferXcodebuild: preferXcodebuildSchema, }, - async (params: Params) => { + async (params: Params, context) => { // Validate required parameters const projectValidation = validateRequiredParam('projectPath', params.projectPath); if (!projectValidation.isValid) return projectValidation.errorResponse!; @@ -648,12 +756,15 @@ export function registerSimulatorBuildAndRunByNameProjectTool(server: McpServer) if (!simulatorNameValidation.isValid) return simulatorNameValidation.errorResponse!; // Provide defaults - return _handleIOSSimulatorBuildAndRunLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, - preferXcodebuild: params.preferXcodebuild ?? false, - }); + return _handleIOSSimulatorBuildAndRunLogic( + { + ...params, + configuration: params.configuration ?? 'Debug', + useLatestOS: params.useLatestOS ?? true, + preferXcodebuild: params.preferXcodebuild ?? false, + }, + context, + ); }, ); } @@ -673,7 +784,7 @@ export function registerSimulatorBuildAndRunByIdWorkspaceTool(server: McpServer) preferXcodebuild?: boolean; }; - registerTool( + registerToolWithProgress( server, 'build_run_sim_id_ws', "Builds and runs an app from a workspace on a simulator specified by UUID. IMPORTANT: Requires workspacePath, scheme, and simulatorId. Example: build_run_sim_id_ws({ workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorId: 'SIMULATOR_UUID' })", @@ -687,7 +798,7 @@ export function registerSimulatorBuildAndRunByIdWorkspaceTool(server: McpServer) useLatestOS: useLatestOSSchema, preferXcodebuild: preferXcodebuildSchema, }, - async (params: Params) => { + async (params: Params, context) => { // Validate required parameters const workspaceValidation = validateRequiredParam('workspacePath', params.workspacePath); if (!workspaceValidation.isValid) return workspaceValidation.errorResponse!; @@ -699,12 +810,15 @@ export function registerSimulatorBuildAndRunByIdWorkspaceTool(server: McpServer) if (!simulatorIdValidation.isValid) return simulatorIdValidation.errorResponse!; // Provide defaults - return _handleIOSSimulatorBuildAndRunLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, // May be ignored - preferXcodebuild: params.preferXcodebuild ?? false, - }); + return _handleIOSSimulatorBuildAndRunLogic( + { + ...params, + configuration: params.configuration ?? 'Debug', + useLatestOS: params.useLatestOS ?? true, // May be ignored + preferXcodebuild: params.preferXcodebuild ?? false, + }, + context, + ); }, ); } @@ -724,7 +838,7 @@ export function registerSimulatorBuildAndRunByIdProjectTool(server: McpServer): preferXcodebuild?: boolean; }; - registerTool( + registerToolWithProgress( server, 'build_run_sim_id_proj', "Builds and runs an app from a project file on a simulator specified by UUID. IMPORTANT: Requires projectPath, scheme, and simulatorId. Example: build_run_sim_id_proj({ projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', simulatorId: 'SIMULATOR_UUID' })", @@ -738,7 +852,7 @@ export function registerSimulatorBuildAndRunByIdProjectTool(server: McpServer): useLatestOS: useLatestOSSchema, preferXcodebuild: preferXcodebuildSchema, }, - async (params: Params) => { + async (params: Params, context) => { // Validate required parameters const projectValidation = validateRequiredParam('projectPath', params.projectPath); if (!projectValidation.isValid) return projectValidation.errorResponse!; @@ -750,12 +864,15 @@ export function registerSimulatorBuildAndRunByIdProjectTool(server: McpServer): if (!simulatorIdValidation.isValid) return simulatorIdValidation.errorResponse!; // Provide defaults - return _handleIOSSimulatorBuildAndRunLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, // May be ignored - preferXcodebuild: params.preferXcodebuild ?? false, - }); + return _handleIOSSimulatorBuildAndRunLogic( + { + ...params, + configuration: params.configuration ?? 'Debug', + useLatestOS: params.useLatestOS ?? true, // May be ignored + preferXcodebuild: params.preferXcodebuild ?? false, + }, + context, + ); }, ); } diff --git a/src/tools/common.ts b/src/tools/common.ts index cc30a6a2..3602bd44 100644 --- a/src/tools/common.ts +++ b/src/tools/common.ts @@ -154,6 +154,20 @@ export type BaseAppPathSimulatorIdParams = BaseSimulatorIdParams & { platform: (typeof platformSimulatorSchema._def.values)[number]; }; +/** + * Extended tool handler with progress notification support + */ +export type ToolHandlerWithProgress = ( + params: T, + context: { + sendNotification?: (notification: { + method: string; + params: Record; + }) => Promise; + _meta?: Record; + }, +) => Promise; + /** * Helper function to register a tool with the MCP server */ @@ -178,6 +192,30 @@ export function registerTool( server.tool(name, description, schema, wrappedHandler); } +/** + * Helper function to register a tool with progress notification support + */ +export function registerToolWithProgress( + server: McpServer, + name: string, + description: string, + schema: Record, + handler: ToolHandlerWithProgress, +): void { + // Create a wrapper handler that matches the signature expected by server.tool + const wrappedHandler = ( + args: Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extra: any, // Using any to work around the type mismatch - the actual runtime has sendNotification + ): Promise => { + // Assert the type *before* calling the original handler + const typedParams = args as T; + return handler(typedParams, extra); + }; + + server.tool(name, description, schema, wrappedHandler); +} + /** * Helper to create a standard text response content. */ diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index 1882190d..b7f49797 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -38,6 +38,7 @@ import path from 'path'; * @param platformOptions Platform-specific options * @param preferXcodebuild Whether to prefer xcodebuild over xcodemake, useful for if xcodemake is failing * @param buildAction The xcodebuild action to perform (e.g., 'build', 'clean', 'test') + * @param context Optional context for progress notifications * @returns Promise resolving to tool response */ export async function executeXcodeBuildCommand( @@ -45,6 +46,13 @@ export async function executeXcodeBuildCommand( platformOptions: PlatformBuildOptions, preferXcodebuild: boolean = false, buildAction: string = 'build', + context?: { + sendNotification?: (notification: { + method: string; + params: Record; + }) => Promise; + _meta?: Record; + }, ): Promise { // Collect warnings, errors, and stderr messages from the build output const buildMessages: { type: 'text'; text: string }[] = []; @@ -61,6 +69,19 @@ export async function executeXcodeBuildCommand( log('info', `Starting ${platformOptions.logPrefix} ${buildAction} for scheme ${params.scheme}`); + // Send initial setup progress + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 10, + total: 100, + message: 'Setting up build environment...', + }, + }); + } + // Check if xcodemake is enabled and available const isXcodemakeEnabledFlag = isXcodemakeEnabled(); let xcodemakeAvailableFlag = false; @@ -92,6 +113,19 @@ export async function executeXcodeBuildCommand( } } + // Send command preparation progress + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 20, + total: 100, + message: 'Preparing build command...', + }, + }); + } + try { const command = ['xcodebuild']; @@ -185,6 +219,19 @@ export async function executeXcodeBuildCommand( command.push(buildAction); + // Send build execution progress + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 30, + total: 100, + message: 'Executing build command...', + }, + }); + } + // Execute the command using xcodemake or xcodebuild let result; if ( @@ -207,6 +254,20 @@ export async function executeXcodeBuildCommand( type: 'text', text: 'ℹ️ Using make for incremental build', }); + + // Send incremental build progress + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 50, + total: 100, + message: 'Running incremental build with make...', + }, + }); + } + result = await executeMakeCommand(projectDir, platformOptions.logPrefix); } else { // Generate Makefile using xcodemake @@ -214,6 +275,20 @@ export async function executeXcodeBuildCommand( type: 'text', text: 'ℹ️ Generating Makefile with xcodemake (first build may take longer)', }); + + // Send Makefile generation progress + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 50, + total: 100, + message: 'Generating Makefile with xcodemake...', + }, + }); + } + // Remove 'xcodebuild' from the command array before passing to executeXcodemakeCommand result = await executeXcodemakeCommand( projectDir, @@ -223,9 +298,34 @@ export async function executeXcodeBuildCommand( } } else { // Use standard xcodebuild + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 50, + total: 100, + message: 'Running xcodebuild...', + }, + }); + } + result = await executeCommand(command, platformOptions.logPrefix); } + // Send post-build processing progress + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 80, + total: 100, + message: 'Processing build results...', + }, + }); + } + // Grep warnings and errors from stdout (build output) const warningOrErrorLines = grepWarningsAndErrors(result.output); warningOrErrorLines.forEach(({ type, content }) => { @@ -276,6 +376,19 @@ export async function executeXcodeBuildCommand( log('info', `✅ ${platformOptions.logPrefix} ${buildAction} succeeded.`); + // Send completion progress + if (context?.sendNotification && context._meta?.progressToken) { + await context.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: context._meta.progressToken, + progress: 100, + total: 100, + message: `${platformOptions.logPrefix} ${buildAction} completed successfully`, + }, + }); + } + // Create additional info based on platform and action let additionalInfo = '';