Xcode MCP Server
by PolarVista
- src
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { ListResourcesRequestSchema, ReadResourceRequestSchema, ListToolsRequestSchema, CallToolRequestSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
const execAsync = promisify(exec);
const mkdir = promisify(fs.mkdir);
const writeFile = promisify(fs.writeFile);
const readFile = promisify(fs.readFile);
interface BuildArguments {
projectPath: string;
scheme: string;
configuration?: string;
destination?: string;
}
function isBuildArguments(args: unknown): args is BuildArguments {
if (typeof args !== 'object' || args === null) return false;
const a = args as Partial<BuildArguments>;
return (
typeof a.projectPath === 'string' &&
typeof a.scheme === 'string' &&
(a.configuration === undefined || typeof a.configuration === 'string') &&
(a.destination === undefined || typeof a.destination === 'string')
);
}
interface TestArguments {
projectPath: string;
scheme: string;
testIdentifier?: string;
skipTests?: string[];
configuration?: string;
destination?: string;
}
interface BuildOptions extends BuildArguments {
includeWarnings?: boolean;
}
interface TestOptions extends TestArguments {
includeWarnings?: boolean;
}
// Add these type guard functions
function isBuildOptions(args: unknown): args is BuildOptions {
if (!isBuildArguments(args)) return false;
const a = args as Partial<BuildOptions>;
return a.includeWarnings === undefined || typeof a.includeWarnings === 'boolean';
}
function isTestOptions(args: unknown): args is TestOptions {
if (!isTestArguments(args)) return false;
const a = args as Partial<TestOptions>;
return a.includeWarnings === undefined || typeof a.includeWarnings === 'boolean';
}
function isTestArguments(args: unknown): args is TestArguments {
if (typeof args !== 'object' || args === null) return false;
const a = args as Partial<TestArguments>;
return (
typeof a.projectPath === 'string' &&
typeof a.scheme === 'string' &&
(a.testIdentifier === undefined || typeof a.testIdentifier === 'string') &&
(a.skipTests === undefined || (Array.isArray(a.skipTests) && a.skipTests.every(t => typeof t === 'string'))) &&
(a.configuration === undefined || typeof a.configuration === 'string') &&
(a.destination === undefined || typeof a.destination === 'string')
);
}
class XcodeBuildServer {
private server: Server;
private baseDir: string;
private buildLogsDir: string;
private latestBuildLog: string | null = null;
constructor(baseDir: string) {
if (!baseDir) throw new Error("Base directory is required");
this.baseDir = baseDir;
this.buildLogsDir = path.join(this.baseDir, 'build-logs');
this.server = new Server(
{ name: "xcode-build-server", version: "0.1.0" },
{ capabilities: { resources: {}, tools: {} } }
);
this.setupHandlers();
this.setupErrorHandling();
}
private async initializeAsync(): Promise<void> {
try {
await mkdir(this.buildLogsDir, { recursive: true });
console.error(`Created build logs directory at ${this.buildLogsDir}`);
} catch (error) {
console.error(`Failed to create build logs directory: ${error}`);
throw error;
}
}
private setupErrorHandling(): void {
this.server.onerror = (error) => console.error("[MCP Error]", error);
process.on("SIGINT", async () => {
await this.server.close();
process.exit(0);
});
}
private setupHandlers(): void {
this.setupResourceHandlers();
this.setupToolHandlers();
}
// Add this helper function in the XcodeBuildServer class
private filterBuildOutput(jsonOutput: any[]): any[] {
const significantErrors = jsonOutput.filter(line => {
if (typeof line === 'string') {
// Include build system lines
if (line.startsWith('/usr/bin/xcodebuild') ||
line.includes('** BUILD')) {
return true;
}
// Include only actual error messages and their notes
if (line.match(/^\/.+:\d+:\d+: error:/) || // Matches error lines with file paths
line.includes('note: found this candidate')) {
return true;
}
return false;
}
if (typeof line === 'object' && line?.type === 'diagnostic') {
return line.diagnostic?.severity === 'error';
}
return false;
});
return significantErrors;
}
private async runTests(
projectPath: string,
scheme: string,
configuration: string = "Debug",
testIdentifier?: string,
skipTests?: string[],
destination: string = "platform=iOS Simulator,name=iPhone 15 Pro"
): Promise<{ success: boolean; output: string; logPath: string }> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const logPath = path.join(this.buildLogsDir, `test-${timestamp}.log`);
const projectDir = path.dirname(projectPath);
const reportsPath = path.join(projectDir, 'TestReports', `Reports-${timestamp}`);
const xcresultPath = `${reportsPath}.xcresult`;
try {
await mkdir(path.join(projectDir, 'TestReports'), { recursive: true });
} catch (error) {
console.error(`Failed to prepare test reports directory: ${error}`);
}
let testFlags = 'test';
if (testIdentifier) {
testFlags += ` -only-testing:${testIdentifier}`;
}
if (skipTests?.length) {
testFlags += ` ${skipTests.map(test => `-skip-testing:${test}`).join(' ')}`;
}
const command = `which xcodebuild && xcodebuild -project "${projectPath}" \
-scheme "${scheme}" \
-configuration "${configuration}" \
-destination '${destination}' \
-resultBundlePath "${xcresultPath}" \
-enableCodeCoverage YES \
-UseModernBuildSystem=YES \
-json \
clean ${testFlags} 2>&1 | tee ${logPath}`;
try {
const { stdout, stderr } = await execAsync(command, { maxBuffer: 100 * 1024 * 1024 });
try {
const jsonOutput = stdout.split('\n')
.filter(line => line.trim())
.map(line => {
try { return JSON.parse(line); }
catch (e) { return line; }
});
await writeFile(logPath + '.json', JSON.stringify(jsonOutput, null, 2));
} catch (parseError) {
console.error('Failed to parse JSON output:', parseError);
}
// Process test results using xcresulttool
if (fs.existsSync(xcresultPath)) {
try {
// Get test summary
const summaryCmd = `xcrun xcresulttool get --format json --path "${xcresultPath}"`;
const { stdout: summaryOutput } = await execAsync(summaryCmd);
await writeFile(path.join(this.buildLogsDir, `test-summary-${timestamp}.json`), summaryOutput);
// Get code coverage if available
const coverageOutput = await execAsync(`xcrun xccov view --report "${xcresultPath}"`);
await writeFile(path.join(this.buildLogsDir, `coverage-${timestamp}.txt`), coverageOutput.stdout);
} catch (resultsError) {
console.error('Failed to process test results:', resultsError);
}
}
const success = !stdout.includes('** TEST FAILED **') && !stdout.includes('** BUILD FAILED **');
return { success, output: stdout + stderr, logPath };
} catch (error) {
console.error('Test error:', error);
if (error instanceof Error) {
const execError = error as { stderr?: string };
const errorOutput = error.message + (execError.stderr ? `\n${execError.stderr}` : '');
await writeFile(logPath, errorOutput);
return { success: false, output: errorOutput, logPath };
}
throw error;
}
}
private setupResourceHandlers(): void {
this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: this.latestBuildLog ? [{
uri: `xcode-build://latest-log`,
name: `Latest Xcode Build Log`,
mimeType: "text/plain",
description: "Most recent Xcode build output"
}] : []
}));
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
if (request.params.uri !== 'xcode-build://latest-log' || !this.latestBuildLog) {
throw new McpError(ErrorCode.InvalidRequest, `Unknown resource: ${request.params.uri}`);
}
try {
const logContent = await readFile(this.latestBuildLog, 'utf-8');
return {
contents: [{
uri: request.params.uri,
mimeType: "text/plain",
text: logContent
}]
};
} catch (error) {
throw new McpError(ErrorCode.InternalError, `Failed to read build log: ${error}`);
}
});
}
private setupToolHandlers(): void {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: "build_project",
description: "Build an Xcode project",
inputSchema: {
type: "object",
properties: {
projectPath: {
type: "string",
description: "Path to the .xcodeproj or .xcworkspace"
},
scheme: {
type: "string",
description: "Build scheme name"
},
configuration: {
type: "string",
description: "Build configuration (e.g., Debug, Release)",
default: "Debug"
},
includeWarnings: {
type: "boolean",
description: "Include warning messages in output",
default: false
}
},
required: ["projectPath", "scheme"]
}
},
{
name: "run_tests",
description: "Run Xcode project tests with optional filtering",
inputSchema: {
type: "object",
properties: {
projectPath: {
type: "string",
description: "Path to the .xcodeproj or .xcworkspace"
},
scheme: {
type: "string",
description: "Test scheme name"
},
testIdentifier: {
type: "string",
description: "Optional specific test to run (e.g., 'MyTests/testExample')"
},
skipTests: {
type: "array",
items: { type: "string" },
description: "Optional array of test identifiers to skip"
},
configuration: {
type: "string",
description: "Build configuration (e.g., Debug, Release)",
default: "Debug"
}
},
required: ["projectPath", "scheme"]
}
}]
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
switch (request.params.name) {
case "build_project": {
if (!isBuildOptions(request.params.arguments)) {
throw new McpError(ErrorCode.InvalidParams, "Invalid build arguments provided");
}
const { projectPath, scheme, configuration = "Debug", destination, includeWarnings = false } = request.params.arguments;
const result = await this.buildProject(projectPath, scheme, configuration, destination, includeWarnings);
this.latestBuildLog = result.logPath;
return {
content: [{
type: "text",
text: result.output
}],
isError: !result.success
};
}
case "run_tests": {
if (!isTestArguments(request.params.arguments)) {
throw new McpError(ErrorCode.InvalidParams, "Invalid test arguments provided");
}
const result = await this.runTests(
request.params.arguments.projectPath,
request.params.arguments.scheme,
request.params.arguments.configuration,
request.params.arguments.testIdentifier,
request.params.arguments.skipTests,
request.params.arguments.destination
);
this.latestBuildLog = result.logPath;
return {
content: [{
type: "text",
text: result.output
}],
isError: !result.success
};
}
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
}
});
}
private async buildProject(
projectPath: string,
scheme: string,
configuration: string,
destination: string = "platform=iOS Simulator,name=iPhone 15 Pro",
includeWarnings: boolean = false
) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const logPath = path.join(this.buildLogsDir, `build-${timestamp}.log`);
const projectDir = path.dirname(projectPath);
const reportsPath = path.join(projectDir, 'Build', `Reports-${timestamp}`);
const xcresultPath = `${reportsPath}.xcresult`;
try {
await mkdir(path.join(projectDir, 'Build'), { recursive: true });
} catch (error) {
console.error(`Failed to prepare build directory: ${error}`);
}
const command = `which xcodebuild && xcodebuild -project "${projectPath}" \
-scheme "${scheme}" \
-configuration "${configuration}" \
-destination '${destination}' \
-resultBundlePath "${xcresultPath}" \
-UseModernBuildSystem=YES \
-json \
clean build 2>&1 | tee ${logPath}`;
try {
const { stdout, stderr } = await execAsync(command, { maxBuffer: 100 * 1024 * 1024 });
try {
const jsonOutput = stdout.split('\n')
.filter(line => line.trim())
.map(line => {
try {
return JSON.parse(line);
} catch (e) {
return line;
}
});
// Filter warnings if needed
const filteredOutput = includeWarnings ? jsonOutput : this.filterBuildOutput(jsonOutput);
await writeFile(logPath + '.json', JSON.stringify(filteredOutput, null, 2));
// Use filtered output for response
const outputText = filteredOutput
.map(line => typeof line === 'string' ? line : JSON.stringify(line))
.join('\n');
// Process xcresult if it exists
if (fs.existsSync(xcresultPath)) {
try {
const reportOutput = await execAsync(`xcrun xcresulttool get --format json --path "${xcresultPath}"`);
await writeFile(path.join(this.buildLogsDir, `report-${timestamp}.json`), reportOutput.stdout);
const summaryOutput = await execAsync(`xcrun xcresulttool get --format human-readable --path "${xcresultPath}"`);
await writeFile(path.join(this.buildLogsDir, `report-${timestamp}.txt`), summaryOutput.stdout);
} catch (reportError) {
console.error('Failed to process build results:', reportError);
}
}
const success = !stdout.includes('** BUILD FAILED **');
return { success, output: outputText, logPath };
} catch (parseError) {
console.error('Failed to parse JSON output:', parseError);
return { success: false, output: stdout + stderr, logPath };
}
} catch (error) {
console.error('Build error:', error);
if (error instanceof Error) {
const execError = error as { stderr?: string };
const errorOutput = error.message + (execError.stderr ? `\n${execError.stderr}` : '');
await writeFile(logPath, errorOutput);
return { success: false, output: errorOutput, logPath };
}
throw error;
}
}
async run(): Promise<void> {
await this.initializeAsync();
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error("Xcode Build MCP server running on stdio");
console.error(`Build logs will be stored in ${this.buildLogsDir}`);
}
}
const baseDir = process.argv[2];
if (!baseDir) {
console.error("Base directory argument is required");
process.exit(1);
}
const server = new XcodeBuildServer(baseDir);
server.run().catch(console.error);