Skip to main content
Glama

MCP Xcode

by Stefan-Nitu
XcodeBuild.ts24.2 kB
import { execAsync } from '../../utils.js'; import { execSync } from 'child_process'; import { createModuleLogger } from '../../logger.js'; import { Platform } from '../../types.js'; import { PlatformInfo } from '../../features/build/domain/PlatformInfo.js'; import { existsSync, mkdirSync, rmSync } from 'fs'; import path from 'path'; import { config } from '../../config.js'; import { LogManager } from '../LogManager.js'; import { parseXcbeautifyOutput, formatParsedOutput } from '../errors/xcbeautify-parser.js'; const logger = createModuleLogger('XcodeBuild'); const logManager = new LogManager(); export interface BuildOptions { scheme?: string; configuration?: string; platform?: Platform; deviceId?: string; derivedDataPath?: string; } export interface TestOptions { scheme?: string; configuration?: string; platform?: Platform; deviceId?: string; testFilter?: string; testTarget?: string; } // Using unified xcbeautify parser for all error handling /** * Handles xcodebuild commands for Xcode projects */ export class XcodeBuild { /** * Validates that a scheme supports the requested platform * @throws Error if the platform is not supported */ private async validatePlatformSupport( projectPath: string, isWorkspace: boolean, scheme: string | undefined, platform: Platform ): Promise<void> { const projectFlag = isWorkspace ? '-workspace' : '-project'; let command = `xcodebuild -showBuildSettings ${projectFlag} "${projectPath}"`; if (scheme) { command += ` -scheme "${scheme}"`; } // Use a generic destination to check platform support const platformInfo = PlatformInfo.fromPlatform(platform); const destination = platformInfo.generateGenericDestination(); command += ` -destination '${destination}'`; try { logger.debug({ command }, 'Validating platform support'); // Just check if the command succeeds - we don't need the output await execAsync(command, { maxBuffer: 1024 * 1024, // 1MB is enough for validation timeout: 10000 // 10 second timeout for validation }); logger.debug({ platform, scheme }, 'Platform validation succeeded'); } catch (error: any) { const stderr = error.stderr || ''; const stdout = error.stdout || ''; // Check if error indicates platform mismatch if (stderr.includes('Available destinations for') || stdout.includes('Available destinations for')) { // Extract available platforms from the error message const availablePlatforms = this.extractAvailablePlatforms(stderr + stdout); throw new Error( `Platform '${platform}' is not supported by scheme '${scheme || 'default'}'. ` + `Available platforms: ${availablePlatforms.join(', ')}` ); } // Some other error - let it pass through for now logger.warn({ error: error.message }, 'Platform validation check failed, continuing anyway'); } } /** * Extracts available platforms from xcodebuild error output */ private extractAvailablePlatforms(output: string): string[] { const platforms = new Set<string>(); const lines = output.split('\n'); for (const line of lines) { // Look for lines like "{ platform:watchOS" or "{ platform:iOS Simulator" const match = line.match(/\{ platform:([^,}]+)/); if (match) { let platform = match[1].trim(); // Normalize platform names if (platform.includes('Simulator')) { platform = platform.replace(' Simulator', ''); } platforms.add(platform); } } return Array.from(platforms); } /** * Build an Xcode project or workspace */ async build( projectPath: string, isWorkspace: boolean, options: BuildOptions = {} ): Promise<{ success: boolean; output: string; appPath?: string; logPath?: string; errors?: any[] }> { const { scheme, configuration = 'Debug', platform = Platform.iOS, deviceId, derivedDataPath = './DerivedData' } = options; // Validate platform support first await this.validatePlatformSupport(projectPath, isWorkspace, scheme, platform); const projectFlag = isWorkspace ? '-workspace' : '-project'; let command = `xcodebuild ${projectFlag} "${projectPath}"`; if (scheme) { command += ` -scheme "${scheme}"`; } command += ` -configuration "${configuration}"`; // Determine destination const platformInfo = PlatformInfo.fromPlatform(platform); let destination: string; if (deviceId) { destination = platformInfo.generateDestination(deviceId); } else { destination = platformInfo.generateGenericDestination(); } command += ` -destination '${destination}'`; command += ` -derivedDataPath "${derivedDataPath}" build`; // Pipe through xcbeautify for clean output command = `set -o pipefail && ${command} 2>&1 | xcbeautify`; logger.debug({ command }, 'Build command'); let output = ''; let exitCode = 0; const projectName = path.basename(projectPath, path.extname(projectPath)); try { const { stdout, stderr } = await execAsync(command, { maxBuffer: 50 * 1024 * 1024, shell: '/bin/bash' }); output = stdout + (stderr ? `\n${stderr}` : ''); // Try to find the built app using find command (more reliable than parsing output) let appPath: string | undefined; try { const { stdout: findOutput } = await execAsync( `find "${derivedDataPath}" -name "*.app" -type d | head -1` ); appPath = findOutput.trim() || undefined; if (appPath) { logger.info({ appPath }, 'Found app at path'); // Verify the app actually exists if (!existsSync(appPath)) { logger.error({ appPath }, 'App path does not exist!'); appPath = undefined; } } else { logger.warn({ derivedDataPath }, 'No app found in DerivedData'); } } catch (error: any) { logger.error({ error: error.message, derivedDataPath }, 'Error finding app path'); } logger.info({ projectPath, scheme, configuration, platform }, 'Build succeeded'); // Save the build output to logs const logPath = logManager.saveLog('build', output, projectName, { scheme, configuration, platform, exitCode, command }); return { success: true, output, appPath, logPath }; } catch (error: any) { logger.error({ error: error.message, projectPath }, 'Build failed'); output = (error.stdout || '') + (error.stderr ? `\n${error.stderr}` : ''); exitCode = error.code || 1; // Parse errors using the unified xcbeautify parser const parsed = parseXcbeautifyOutput(output); // Log for debugging if (parsed.errors.length === 0 && output.toLowerCase().includes('error:')) { logger.warn({ outputSample: output.substring(0, 500) }, 'Output contains "error:" but no errors were parsed'); } // Save the build output to logs const logPath = logManager.saveLog('build', output, projectName, { scheme, configuration, platform, exitCode, command, errors: parsed.errors, warnings: parsed.warnings }); // Save debug data with parsed errors if (parsed.errors.length > 0) { logManager.saveDebugData('build-errors', parsed.errors, projectName); logger.info({ errorCount: parsed.errors.length, warningCount: parsed.warnings.length }, 'Parsed errors'); } // Create error with parsed details const errorWithDetails = new Error(formatParsedOutput(parsed)) as any; errorWithDetails.output = output; errorWithDetails.parsed = parsed; errorWithDetails.logPath = logPath; throw errorWithDetails; } } /** * Run tests for an Xcode project */ async test( projectPath: string, isWorkspace: boolean, options: TestOptions = {} ): Promise<{ success: boolean; output: string; passed: number; failed: number; failingTests?: Array<{ identifier: string; reason: string }>; errors?: any[]; warnings?: any[]; logPath: string; }> { const { scheme, configuration = 'Debug', platform = Platform.iOS, deviceId, testFilter, testTarget } = options; // Create a unique result bundle path in DerivedData const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const derivedDataPath = config.getDerivedDataPath(projectPath); let resultBundlePath = path.join( derivedDataPath, 'Logs', 'Test', `Test-${scheme || 'tests'}-${timestamp}.xcresult` ); // Ensure result directory exists const resultDir = path.dirname(resultBundlePath); if (!existsSync(resultDir)) { mkdirSync(resultDir, { recursive: true }); } const projectFlag = isWorkspace ? '-workspace' : '-project'; let command = `xcodebuild ${projectFlag} "${projectPath}"`; if (scheme) { command += ` -scheme "${scheme}"`; } command += ` -configuration "${configuration}"`; // Determine destination const platformInfo = PlatformInfo.fromPlatform(platform); let destination: string; if (deviceId) { destination = platformInfo.generateDestination(deviceId); } else { destination = platformInfo.generateGenericDestination(); } command += ` -destination '${destination}'`; // Add test target/filter if provided if (testTarget) { command += ` -only-testing:${testTarget}`; } if (testFilter) { command += ` -only-testing:${testFilter}`; } // Disable parallel testing to avoid timeouts and multiple simulator instances command += ' -parallel-testing-enabled NO'; // Add result bundle path command += ` -resultBundlePath "${resultBundlePath}"`; command += ' test'; // Pipe through xcbeautify for clean output command = `set -o pipefail && ${command} 2>&1 | xcbeautify`; logger.debug({ command }, 'Test command'); // Use execAsync instead of spawn to ensure the xcresult is fully written when we get the result let output = ''; let code = 0; try { logger.info('Running tests...'); const { stdout, stderr } = await execAsync(command, { maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large test outputs timeout: 1800000, // 10 minute timeout for tests shell: '/bin/bash' }); output = stdout + (stderr ? '\n' + stderr : ''); } catch (error: any) { // Test failure is expected, capture the output output = (error.stdout || '') + (error.stderr ? '\n' + error.stderr : ''); code = error.code || 1; logger.debug({ code }, 'Tests completed with failures'); } // Parse compile errors and warnings using the central parser const parsed = parseXcbeautifyOutput(output); // Save the full test output to logs const projectName = path.basename(projectPath, path.extname(projectPath)); const logPath = logManager.saveLog('test', output, projectName, { scheme, configuration, platform, exitCode: code, command, errors: parsed.errors.length > 0 ? parsed.errors : undefined, warnings: parsed.warnings.length > 0 ? parsed.warnings : undefined }); logger.debug({ logPath }, 'Test output saved to log file'); // Parse the xcresult bundle for accurate test results let testResult = { passed: 0, failed: 0, success: false, failingTests: undefined as Array<{ identifier: string; reason: string }> | undefined, logPath }; // Try to extract the actual xcresult path from the output const resultMatch = output.match(/Test session results.*?\n\s*(.+\.xcresult)/); if (resultMatch) { resultBundlePath = resultMatch[1].trim(); logger.debug({ resultBundlePath }, 'Found xcresult path in output'); } // Also check for the "Writing result bundle at path" message const writingMatch = output.match(/Writing result bundle at path:\s*(.+\.xcresult)/); if (!resultMatch && writingMatch) { resultBundlePath = writingMatch[1].trim(); logger.debug({ resultBundlePath }, 'Found xcresult path from Writing message'); } try { // Check if xcresult exists and wait for it to be fully written // Wait for the xcresult bundle to be created and fully written (up to 10 seconds) let waitTime = 0; const maxWaitTime = 10000; const checkInterval = 200; // Check both that the directory exists and has the Info.plist file const isXcresultReady = () => { if (!existsSync(resultBundlePath)) { return false; } // Check if Info.plist exists inside the bundle, which indicates it's fully written const infoPlistPath = path.join(resultBundlePath, 'Info.plist'); return existsSync(infoPlistPath); }; while (!isXcresultReady() && waitTime < maxWaitTime) { await new Promise(resolve => setTimeout(resolve, checkInterval)); waitTime += checkInterval; } if (!isXcresultReady()) { logger.warn({ resultBundlePath, waitTime }, 'xcresult bundle not ready after waiting, using fallback parsing'); throw new Error('xcresult bundle not ready'); } // Give xcresulttool a moment to prepare for reading await new Promise(resolve => setTimeout(resolve, 300)); logger.debug({ resultBundlePath, waitTime }, 'xcresult bundle is ready'); let testReportJson; let totalPassed = 0; let totalFailed = 0; const failingTests: Array<{ identifier: string; reason: string }> = []; try { // Try the new format first (Xcode 16+) logger.debug({ resultBundlePath }, 'Attempting to parse xcresult with new format'); testReportJson = execSync( `xcrun xcresulttool get test-results summary --path "${resultBundlePath}"`, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 } ); const summary = JSON.parse(testReportJson); logger.debug({ summary: { passedTests: summary.passedTests, failedTests: summary.failedTests } }, 'Got summary from xcresulttool'); // The summary counts are not reliable for mixed XCTest/Swift Testing // We'll count from the detailed test nodes instead // Always get the detailed tests to count accurately try { const testsJson = execSync( `xcrun xcresulttool get test-results tests --path "${resultBundlePath}"`, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 } ); const testsData = JSON.parse(testsJson); // Helper function to count tests and extract failing tests with reasons const processTestNodes = (node: any, parentName: string = ''): void => { if (!node) return; // Count test cases (including argument variations) if (node.nodeType === 'Test Case') { // Check if this test has argument variations let hasArguments = false; if (node.children && Array.isArray(node.children)) { for (const child of node.children) { if (child.nodeType === 'Arguments') { hasArguments = true; // Each argument variation is a separate test if (child.result === 'Passed') { totalPassed++; } else if (child.result === 'Failed') { totalFailed++; } } } } // If no arguments, count the test case itself if (!hasArguments) { if (node.result === 'Passed') { totalPassed++; } else if (node.result === 'Failed') { totalFailed++; // Extract failure information let testName = node.nodeIdentifier || node.name || parentName; let failureReason = ''; // Look for failure message in children if (node.children && Array.isArray(node.children)) { for (const child of node.children) { if (child.nodeType === 'Failure Message') { failureReason = child.details || child.name || 'Test failed'; break; } } } // Add test as an object with identifier and reason failingTests.push({ identifier: testName, reason: failureReason || 'Test failed (no details available)' }); } } } // Recurse through children if (node.children && Array.isArray(node.children)) { for (const child of node.children) { processTestNodes(child, node.name || parentName); } } }; // Parse the test nodes to count tests and extract failing test names with reasons if (testsData.testNodes && Array.isArray(testsData.testNodes)) { for (const testNode of testsData.testNodes) { processTestNodes(testNode); } } } catch (detailsError: any) { logger.debug({ error: detailsError.message }, 'Could not extract failing test details'); } } catch (newFormatError: any) { // Fall back to legacy format logger.debug('Falling back to legacy xcresulttool format'); testReportJson = execSync( `xcrun xcresulttool get test-report --legacy --format json --path "${resultBundlePath}"`, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 } ); const testReport = JSON.parse(testReportJson); // Parse the legacy test report structure if (testReport.tests) { const countTests = (tests: any[]): void => { for (const test of tests) { if (test.subtests) { // This is a test suite, recurse into it countTests(test.subtests); } else if (test.testStatus) { // This is an actual test if (test.testStatus === 'Success') { totalPassed++; } else if (test.testStatus === 'Failure' || test.testStatus === 'Expected Failure') { totalFailed++; // Extract test name and failure details if (test.identifier) { const failureReason = test.failureMessage || test.message || 'Test failed (no details available)'; failingTests.push({ identifier: test.identifier, reason: failureReason }); } } } } }; countTests(testReport.tests); } } testResult = { passed: totalPassed, failed: totalFailed, success: totalFailed === 0 && code === 0, failingTests: failingTests.length > 0 ? failingTests : undefined, logPath }; // Save debug data for successful parsing logManager.saveDebugData('test-xcresult-parsed', { passed: totalPassed, failed: totalFailed, failingTests, resultBundlePath }, projectName); logger.info({ projectPath, ...testResult, exitCode: code, resultBundlePath }, 'Tests completed (parsed from xcresult)'); } catch (parseError: any) { logger.error({ error: parseError.message, resultBundlePath, xcresultExists: existsSync(resultBundlePath) }, 'Failed to parse xcresult bundle'); // Save debug info about the failure logManager.saveDebugData('test-xcresult-parse-error', { error: parseError.message, resultBundlePath, exists: existsSync(resultBundlePath) }, projectName); // If xcresulttool fails, try to parse counts from the text output const passedMatch = output.match(/Executed (\d+) tests?, with (\d+) failures?/); if (passedMatch) { const totalTests = parseInt(passedMatch[1], 10); const failures = parseInt(passedMatch[2], 10); testResult = { passed: totalTests - failures, failed: failures, success: failures === 0, failingTests: undefined, logPath }; } else { // Last resort fallback testResult = { passed: 0, failed: code === 0 ? 0 : 1, success: code === 0, failingTests: undefined, logPath }; } } // Parse build errors from output // Errors are already parsed by xcbeautify parser const result = { ...testResult, success: code === 0 && testResult.failed === 0, output, errors: parsed.errors.length > 0 ? parsed.errors : undefined, warnings: parsed.warnings.length > 0 ? parsed.warnings : undefined }; // Clean up the result bundle if tests passed (keep failed results for debugging) if (result.success) { try { rmSync(resultBundlePath, { recursive: true, force: true }); } catch { // Ignore cleanup errors } } return result; } /** * Clean build artifacts */ async clean( projectPath: string, isWorkspace: boolean, options: { scheme?: string; configuration?: string } = {} ): Promise<void> { const { scheme, configuration = 'Debug' } = options; const projectFlag = isWorkspace ? '-workspace' : '-project'; let command = `xcodebuild ${projectFlag} "${projectPath}"`; if (scheme) { command += ` -scheme "${scheme}"`; } command += ` -configuration "${configuration}" clean`; logger.debug({ command }, 'Clean command'); try { await execAsync(command); logger.info({ projectPath }, 'Clean succeeded'); } catch (error: any) { logger.error({ error: error.message, projectPath }, 'Clean failed'); throw new Error(`Clean failed: ${error.message}`); } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Stefan-Nitu/mcp-xcode'

If you have feedback or need assistance with the MCP directory API, please join our Discord server