Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 32 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,16 +135,18 @@ simctl-get-details({

**Example: Build Operations**
```typescript
// 1. Build returns summary + buildId
// 1. Build returns summary + buildId, errors/warnings at top level
xcodebuild-build({ projectPath: "./MyApp.xcworkspace", scheme: "MyApp" })
// Returns:
{
buildId: "build-xyz789",
success: true,
summary: { duration: 7075, errorCount: 0, warningCount: 1 }
errors: ["...error: undefined symbol..."], // top-level (first 10)
warnings: ["...warning: unused variable..."], // top-level (first 10)
summary: { duration: 7075, errorCount: 1, warningCount: 1 }
}

// 2. Access full logs only when debugging
// 2. Access full logs only when debugging (>10 errors/warnings)
xcodebuild-get-details({ buildId: "build-xyz789", detailType: "full-log" })
```

Expand Down Expand Up @@ -507,7 +509,7 @@ xcodebuild-build({
### Example 3: Progressive Disclosure Build Workflow

```typescript
// 1. Build returns summary + buildId
// 1. Build returns errors/warnings at top level for immediate visibility
xcodebuild-build({
projectPath: "./MyApp.xcworkspace",
scheme: "MyApp"
Expand All @@ -516,26 +518,26 @@ xcodebuild-build({
{
buildId: "build-abc123",
success: true,
warnings: ["...warning: unused variable 'foo'..."], // first 10 warnings
summary: {
duration: 7075,
errorCount: 0,
warningCount: 1,
configuration: "Debug",
sdk: "iphonesimulator"
configuration: "Debug"
},
nextSteps: [
guidance: [
"Build completed successfully",
"⚠️ 1 warning(s) detected",
"Use 'xcodebuild-get-details' with buildId for full logs"
]
}

// 2. Access full logs only when debugging
// 2. Access full logs only when >10 errors/warnings or debugging
xcodebuild-get-details({
buildId: "build-abc123",
detailType: "full-log",
maxLines: 100
detailType: "warnings-only" // or "errors-only", "full-log"
})
// Returns: Full compiler output, warnings, errors
// Returns: All warnings from build output
```

---
Expand Down Expand Up @@ -574,9 +576,10 @@ This project uses XC-MCP for iOS development automation. Follow these patterns f

## Progressive Disclosure

- Build/test tools return `buildId` or cache IDs
- Use `xcodebuild-get-details` or `simctl-get-details` to drill down
- **Never request full logs upfront** — get summaries first
- Build tools return `errors` and `warnings` arrays at top level (first 10 each)
- No need to call `xcodebuild-get-details` just to see errors/warnings
- Use `xcodebuild-get-details` only for full logs or >10 errors/warnings
- Simulator tools return `cacheId` — use `simctl-get-details` to drill down

## Best Practices

Expand All @@ -585,6 +588,21 @@ This project uses XC-MCP for iOS development automation. Follow these patterns f
- **Prefer accessibility over screenshots** — Better for efficiency AND app quality
- **Use operation enums** — `simctl-device({ operation: "boot" })` instead of separate tools

## Example: Build with Immediate Error/Warning Visibility

\`\`\`typescript
// Build returns errors/warnings at top level - no second call needed
xcodebuild-build({ projectPath: "MyApp.xcodeproj", scheme: "MyApp" })
// Returns:
{
buildId: "abc123",
success: true,
warnings: ["...warning: unused variable..."], // immediate visibility
summary: { errorCount: 0, warningCount: 1, duration: 5000 }
}
// Only use xcodebuild-get-details for full logs or >10 errors/warnings
\`\`\`

## Example: Optimal Login Flow

\`\`\`typescript
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"package.json"
],
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.1",
"@modelcontextprotocol/sdk": "1.17.1",
"@types/node": "^24.1.0"
},
"devDependencies": {
Expand Down
65 changes: 51 additions & 14 deletions src/tools/xcodebuild/build.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { validateProjectPath, validateScheme } from '../../utils/validation.js';
import { executeCommand, buildXcodebuildCommand } from '../../utils/command.js';
import { executeCommandStreaming, buildXcodebuildCommand } from '../../utils/command.js';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { responseCache, extractBuildSummary } from '../../utils/response-cache.js';
import { projectCache, type BuildConfig } from '../../state/project-cache.js';
Expand Down Expand Up @@ -107,21 +107,47 @@ export async function xcodebuildBuildTool(args: any) {

console.error(`[xcodebuild-build] Executing: ${command}`);

// Execute command with extended timeout for builds
// Execute command with early-fatal detection to avoid long retries on bad destinations
const fatalPatterns = [
/Failed to start remote service "com\.apple\.mobile\.notification_proxy"/i,
/The device is passcode protected/i,
/Unable to find a device matching the provided destination specifier/i,
];
const timeoutMs = 55_000; // Stay under MCP transport limits
const startTime = Date.now();
const result = await executeCommand(command, {
timeout: 600000, // 10 minutes for builds
const result = await executeCommandStreaming(command, {
timeout: timeoutMs,
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for build logs
fatalPatterns,
onFatalMatch: line => {
console.error(`[xcodebuild-build] Detected fatal xcodebuild output: ${line}`);
},
});
const duration = Date.now() - startTime;

// Extract build summary
const summary = extractBuildSummary(result.stdout, result.stderr, result.code);
const buildSuccess = summary.success && !result.timedOut;
const augmentedErrors = [...summary.errors];
if (result.fatalMatch) {
augmentedErrors.unshift(`Detected fatal xcodebuild output: ${result.fatalMatch}`);
}
if (result.timedOut) {
augmentedErrors.unshift(`Build aborted after ${timeoutMs}ms (timeout)`);
}
const adjustedSummary = {
...summary,
success: buildSuccess,
firstError:
summary.firstError ||
result.fatalMatch ||
(result.timedOut ? `Build timed out after ${timeoutMs}ms` : undefined),
};

// Record build result in project cache
projectCache.recordBuildResult(projectPath, finalConfig, {
timestamp: new Date(),
success: summary.success,
success: buildSuccess,
duration,
errorCount: summary.errorCount,
warningCount: summary.warningCount,
Expand All @@ -135,7 +161,7 @@ export async function xcodebuildBuildTool(args: any) {
simulatorCache.recordSimulatorUsage(udidMatch[1], projectPath);

// Save simulator preference to project config if build succeeded
if (summary.success) {
if (buildSuccess) {
try {
const configManager = createConfigManager(projectPath);
const simulator = await simulatorCache.findSimulatorByUdid(udidMatch[1]);
Expand All @@ -162,11 +188,13 @@ export async function xcodebuildBuildTool(args: any) {
destination: finalConfig.destination,
sdk: finalConfig.sdk,
duration,
success: summary.success,
success: buildSuccess,
errorCount: summary.errorCount,
warningCount: summary.warningCount,
smartDestinationUsed: !destination && smartDestination !== destination,
smartConfigurationUsed: !args.configuration && finalConfig.configuration !== 'Debug',
timedOut: result.timedOut,
fatalMatch: result.fatalMatch,
},
});

Expand All @@ -177,7 +205,7 @@ export async function xcodebuildBuildTool(args: any) {

// Handle auto-install if enabled and build succeeded
let autoInstallResult = undefined;
if (autoInstall && summary.success) {
if (autoInstall && buildSuccess) {
try {
console.error('[xcodebuild-build] Starting auto-install...');
autoInstallResult = await performAutoInstall({
Expand All @@ -196,11 +224,17 @@ export async function xcodebuildBuildTool(args: any) {
}
}

// Destructure errors/warnings from summary for top-level placement
const { errors: _ignoredErrors, warnings, ...summaryRest } = adjustedSummary;

const responseData = {
buildId: cacheId,
success: summary.success,
success: buildSuccess,
// Errors and warnings at top level for immediate visibility
errors: augmentedErrors.length > 0 ? augmentedErrors : undefined,
warnings: warnings.length > 0 ? warnings : undefined,
summary: {
...summary,
...summaryRest,
scheme: finalConfig.scheme,
configuration: finalConfig.configuration,
destination: finalConfig.destination,
Expand All @@ -214,12 +248,13 @@ export async function xcodebuildBuildTool(args: any) {
simulatorUsageRecorded: !!(
finalConfig.destination && finalConfig.destination.includes('Simulator')
),
configurationLearned: summary.success, // Successful builds get remembered
autoInstallAttempted: autoInstall && summary.success,
configurationLearned: buildSuccess, // Successful builds get remembered
autoInstallAttempted: autoInstall && buildSuccess,
},
guidance: summary.success
guidance: buildSuccess
? [
`Build completed successfully in ${duration}ms`,
...(summary.warningCount > 0 ? [`⚠️ ${summary.warningCount} warning(s) detected`] : []),
...(usedSmartDestination ? [`Used smart simulator: ${finalConfig.destination}`] : []),
...(hasPreferredConfig ? [`Applied cached project preferences`] : []),
`Use 'xcodebuild-get-details' with buildId '${cacheId}' for full logs`,
Expand All @@ -234,9 +269,11 @@ export async function xcodebuildBuildTool(args: any) {
]
: [
`Build failed with ${summary.errorCount} errors, ${summary.warningCount} warnings`,
`First error: ${summary.firstError || 'Unknown error'}`,
`First error: ${adjustedSummary.firstError || 'Unknown error'}`,
`Use 'xcodebuild-get-details' with buildId '${cacheId}' for full logs and errors`,
...(usedSmartDestination ? [`Try simctl-list to see other available simulators`] : []),
...(result.timedOut ? [`Build aborted after ${timeoutMs}ms (timeout)`] : []),
...(result.fatalMatch ? [`Detected fatal log output: ${result.fatalMatch}`] : []),
],
cacheDetails: {
note: 'Use xcodebuild-get-details with buildId for full logs',
Expand Down
91 changes: 91 additions & 0 deletions src/utils/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export interface CommandResult {
code: number;
}

export interface StreamingCommandResult extends CommandResult {
timedOut: boolean;
fatalMatch?: string;
}

export interface CommandOptions {
timeout?: number;
maxBuffer?: number;
Expand Down Expand Up @@ -55,6 +60,92 @@ export async function executeCommand(
}
}

interface StreamingOptions extends CommandOptions {
fatalPatterns?: RegExp[];
onFatalMatch?: (line: string) => void;
}

/**
* Execute a command with streaming output, optional fatal-pattern detection, and timeout.
* Kills the process early if a fatal pattern is seen or the timeout elapses.
*/
export async function executeCommandStreaming(
command: string,
options: StreamingOptions = {}
): Promise<StreamingCommandResult> {
const {
timeout = 60000,
maxBuffer = 10 * 1024 * 1024,
fatalPatterns = [],
onFatalMatch,
} = options;

return new Promise<StreamingCommandResult>((resolve, reject) => {
const child = spawn(command, { shell: true, timeout });

let stdout = '';
let stderr = '';
let timedOut = false;
let fatalMatch: string | undefined;

const checkPatterns = (chunk: string) => {
if (fatalMatch) return;
for (const pattern of fatalPatterns) {
const match = chunk.match(pattern);
if (match) {
fatalMatch = match[0];
onFatalMatch?.(match[0]);
child.kill();
break;
}
}
};

const timeoutId = setTimeout(() => {
timedOut = true;
child.kill();
}, timeout);

child.stdout?.on('data', data => {
const text = data.toString();
stdout += text;
checkPatterns(text);
if (stdout.length > maxBuffer) {
child.kill();
clearTimeout(timeoutId);
reject(
new McpError(
ErrorCode.InternalError,
`Command output exceeded max buffer size of ${maxBuffer} bytes`
)
);
}
});

child.stderr?.on('data', data => {
const text = data.toString();
stderr += text;
checkPatterns(text);
});

child.on('close', code => {
clearTimeout(timeoutId);
resolve({
stdout: stdout.trim(),
stderr: stderr.trim(),
code: code ?? 0,
timedOut,
fatalMatch,
});
});

child.on('error', error => {
clearTimeout(timeoutId);
reject(new McpError(ErrorCode.InternalError, `Failed to execute command: ${error.message}`));
});
});
}

/**
* Execute a command with arguments using spawn (safer than shell execution).
* This function does NOT invoke a shell, preventing command injection vulnerabilities.
Expand Down
3 changes: 3 additions & 0 deletions src/utils/response-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ export function extractBuildSummary(output: string, stderr: string, exitCode: nu
hasErrors: errors.length > 0,
hasWarnings: warnings.length > 0,
firstError: errors[0]?.trim(),
// Include first 10 errors and warnings for immediate visibility
errors: errors.slice(0, 10).map(e => e.trim()),
warnings: warnings.slice(0, 10).map(w => w.trim()),
Comment on lines +196 to +198
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces new fields (errors and warnings arrays) to the build summary return value. However, the PR title and description only mention fixing the @modelcontextprotocol/sdk version issue. This is an unrelated feature addition that should either:

  1. Be documented in the PR description to explain why these fields are being added
  2. Be moved to a separate PR focused on this feature

Additionally, this is a breaking change to the public API of extractBuildSummary (new required fields in the return object) but there's no documentation or discussion about backwards compatibility.

Suggested change
// Include first 10 errors and warnings for immediate visibility
errors: errors.slice(0, 10).map(e => e.trim()),
warnings: warnings.slice(0, 10).map(w => w.trim()),

Copilot uses AI. Check for mistakes.
buildSizeBytes: output.length + stderr.length,
};
}
Expand Down
Loading