xcodemcp
Version:
Model Context Protocol server for Xcode build automation and log parsing
690 lines • 30.7 kB
JavaScript
import { Command } from 'commander';
import { readFile } from 'fs/promises';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { XcodeServer } from './XcodeServer.js';
import { Logger } from './utils/Logger.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Load package.json to get version info
*/
async function loadPackageJson() {
try {
const packagePath = join(__dirname, '../package.json');
const packageContent = await readFile(packagePath, 'utf-8');
return JSON.parse(packageContent);
}
catch (error) {
Logger.error('Failed to load package.json:', error);
return { version: '0.0.0' };
}
}
/**
* Convert JSON schema property to commander option
*/
function schemaPropertyToOption(name, property) {
const flags = property.type === 'boolean' ? `--${name}` : `--${name} <value>`;
const description = property.description || `${name} parameter`;
const option = { flags, description };
if (property.default !== undefined) {
option.defaultValue = property.default;
}
return option;
}
/**
* Parse command line arguments into tool arguments
*/
function parseToolArgs(tool, cliArgs) {
const toolArgs = {};
if (!tool.inputSchema?.properties) {
return toolArgs;
}
for (const [propName, propSchema] of Object.entries(tool.inputSchema.properties)) {
const propDef = propSchema;
const cliValue = cliArgs[propName];
if (cliValue !== undefined) {
// Handle array types
if (propDef.type === 'array') {
if (Array.isArray(cliValue)) {
toolArgs[propName] = cliValue;
}
else {
// Split string by comma for array values
toolArgs[propName] = cliValue.split(',').map((s) => s.trim());
}
}
else if (propDef.type === 'number') {
toolArgs[propName] = parseFloat(cliValue);
}
else if (propDef.type === 'boolean') {
toolArgs[propName] = cliValue === true || cliValue === 'true';
}
else {
toolArgs[propName] = cliValue;
}
}
}
return toolArgs;
}
/**
* Format tool result for console output
*/
function formatResult(result, jsonOutput) {
if (jsonOutput) {
return JSON.stringify(result, null, 2);
}
// Pretty format for console
if (result?.content && Array.isArray(result.content)) {
return result.content
.map((item) => {
if (item.type === 'text') {
return item.text;
}
else if (item.type === 'image') {
return `[Image: ${item.source?.data ? 'base64 data' : item.source?.url || 'unknown'}]`;
}
else {
return `[${item.type}: ${JSON.stringify(item)}]`;
}
})
.join('\n');
}
return JSON.stringify(result, null, 2);
}
// Note: handleSseEvent is defined but not used in CLI-first architecture
// Events are handled by the spawning process (MCP library)
// function handleSseEvent(event: SseEvent): void {
// const eventData = `event:${event.type}\ndata:${JSON.stringify(event.data)}\n\n`;
// process.stderr.write(eventData);
// }
/**
* Main CLI function
*/
async function main() {
try {
const pkg = await loadPackageJson();
const server = new XcodeServer();
// Complete tool definitions (copied from XcodeServer.ts)
const tools = [
{
name: 'xcode_open_project',
description: 'Open an Xcode project or workspace',
inputSchema: {
type: 'object',
properties: {
xcodeproj: {
type: 'string',
description: 'Absolute path to the .xcodeproj file (or .xcworkspace if available) - e.g., /path/to/project.xcodeproj',
},
},
required: ['xcodeproj'],
},
},
{
name: 'xcode_close_project',
description: 'Close the currently active Xcode project or workspace (automatically stops any running actions first)',
inputSchema: {
type: 'object',
properties: {
xcodeproj: {
type: 'string',
description: 'Absolute path to the .xcodeproj file (or .xcworkspace if available) - e.g., /path/to/project.xcodeproj',
},
},
required: ['xcodeproj'],
},
},
{
name: 'xcode_build',
description: 'Build a specific Xcode project or workspace with the specified scheme. If destination is not provided, uses the currently active destination.',
inputSchema: {
type: 'object',
properties: {
xcodeproj: {
type: 'string',
description: 'Absolute path to the .xcodeproj file to build (or .xcworkspace if available) - e.g., /path/to/project.xcodeproj',
},
scheme: {
type: 'string',
description: 'Name of the scheme to build',
},
destination: {
type: 'string',
description: 'Build destination (optional - uses active destination if not provided)',
},
},
required: ['xcodeproj', 'scheme'],
},
},
{
name: 'xcode_get_schemes',
description: 'Get list of available schemes for a specific project',
inputSchema: {
type: 'object',
properties: {
xcodeproj: {
type: 'string',
description: 'Absolute path to the .xcodeproj file (or .xcworkspace if available) - e.g., /path/to/project.xcodeproj',
},
},
required: ['xcodeproj'],
},
},
{
name: 'xcode_set_active_scheme',
description: 'Set the active scheme for a specific project',
inputSchema: {
type: 'object',
properties: {
xcodeproj: {
type: 'string',
description: 'Absolute path to the .xcodeproj file (or .xcworkspace if available) - e.g., /path/to/project.xcodeproj',
},
schemeName: {
type: 'string',
description: 'Name of the scheme to activate',
},
},
required: ['xcodeproj', 'schemeName'],
},
},
{
name: 'xcode_clean',
description: 'Clean the build directory for a specific project',
inputSchema: {
type: 'object',
properties: {
xcodeproj: {
type: 'string',
description: 'Absolute path to the .xcodeproj file (or .xcworkspace if available) - e.g., /path/to/project.xcodeproj',
},
},
required: ['xcodeproj'],
},
},
{
name: 'xcode_test',
description: 'Run tests for a specific project',
inputSchema: {
type: 'object',
properties: {
xcodeproj: {
type: 'string',
description: 'Absolute path to the .xcodeproj file (or .xcworkspace if available) - e.g., /path/to/project.xcodeproj',
},
commandLineArguments: {
type: 'array',
items: { type: 'string' },
description: 'Additional command line arguments',
},
},
required: ['xcodeproj'],
},
},
{
name: 'xcode_run',
description: 'Run a specific project with the specified scheme',
inputSchema: {
type: 'object',
properties: {
xcodeproj: {
type: 'string',
description: 'Absolute path to the .xcodeproj file (or .xcworkspace if available) - e.g., /path/to/project.xcodeproj',
},
scheme: {
type: 'string',
description: 'Name of the scheme to run',
},
commandLineArguments: {
type: 'array',
items: { type: 'string' },
description: 'Additional command line arguments',
},
},
required: ['xcodeproj', 'scheme'],
},
},
{
name: 'xcode_debug',
description: 'Start debugging session for a specific project',
inputSchema: {
type: 'object',
properties: {
xcodeproj: {
type: 'string',
description: 'Absolute path to the .xcodeproj file (or .xcworkspace if available) - e.g., /path/to/project.xcodeproj',
},
scheme: {
type: 'string',
description: 'Scheme name (optional)',
},
skipBuilding: {
type: 'boolean',
description: 'Whether to skip building',
},
},
required: ['xcodeproj'],
},
},
{
name: 'xcode_stop',
description: 'Stop the current scheme action',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'find_xcresults',
description: 'Find all XCResult files for a specific project with timestamps and file information',
inputSchema: {
type: 'object',
properties: {
xcodeproj: {
type: 'string',
description: 'Absolute path to the .xcodeproj file (or .xcworkspace if available) - e.g., /path/to/project.xcodeproj',
},
},
required: ['xcodeproj'],
},
},
{
name: 'xcode_get_run_destinations',
description: 'Get list of available run destinations for a specific project',
inputSchema: {
type: 'object',
properties: {
xcodeproj: {
type: 'string',
description: 'Absolute path to the .xcodeproj file (or .xcworkspace if available) - e.g., /path/to/project.xcodeproj',
},
},
required: ['xcodeproj'],
},
},
{
name: 'xcode_get_workspace_info',
description: 'Get information about a specific workspace',
inputSchema: {
type: 'object',
properties: {
xcodeproj: {
type: 'string',
description: 'Absolute path to the .xcodeproj file (or .xcworkspace if available) - e.g., /path/to/project.xcodeproj',
},
},
required: ['xcodeproj'],
},
},
{
name: 'xcode_get_projects',
description: 'Get list of projects in a specific workspace',
inputSchema: {
type: 'object',
properties: {
xcodeproj: {
type: 'string',
description: 'Absolute path to the .xcodeproj file (or .xcworkspace if available) - e.g., /path/to/project.xcodeproj',
},
},
required: ['xcodeproj'],
},
},
{
name: 'xcode_open_file',
description: 'Open a file in Xcode',
inputSchema: {
type: 'object',
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the file to open',
},
lineNumber: {
type: 'number',
description: 'Optional line number to navigate to',
},
},
required: ['filePath'],
},
},
{
name: 'xcode_health_check',
description: 'Perform a comprehensive health check of the XcodeMCP environment and configuration',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'xcresult_browse',
description: 'Browse XCResult files - list all tests or show details for a specific test. Returns comprehensive test results including pass/fail status, failure details, and browsing instructions. Large console output (>20 lines or >2KB) is automatically saved to a temporary file.',
inputSchema: {
type: 'object',
properties: {
xcresult_path: {
type: 'string',
description: 'Absolute path to the .xcresult file',
},
test_id: {
type: 'string',
description: 'Optional test ID or index number to show details for a specific test',
},
include_console: {
type: 'boolean',
description: 'Whether to include console output and test activities (only used with test_id)',
default: false,
},
},
required: ['xcresult_path'],
},
},
{
name: 'xcresult_browser_get_console',
description: 'Get console output and test activities for a specific test in an XCResult file. Large output (>20 lines or >2KB) is automatically saved to a temporary file.',
inputSchema: {
type: 'object',
properties: {
xcresult_path: {
type: 'string',
description: 'Absolute path to the .xcresult file',
},
test_id: {
type: 'string',
description: 'Test ID or index number to get console output for',
},
},
required: ['xcresult_path', 'test_id'],
},
},
{
name: 'xcresult_summary',
description: 'Get a quick summary of test results from an XCResult file',
inputSchema: {
type: 'object',
properties: {
xcresult_path: {
type: 'string',
description: 'Absolute path to the .xcresult file',
},
},
required: ['xcresult_path'],
},
},
{
name: 'xcresult_get_screenshot',
description: 'Get screenshot from a failed test at specific timestamp - extracts frame from video attachment using ffmpeg',
inputSchema: {
type: 'object',
properties: {
xcresult_path: {
type: 'string',
description: 'Absolute path to the .xcresult file',
},
test_id: {
type: 'string',
description: 'Test ID or index number to get screenshot for',
},
timestamp: {
type: 'number',
description: 'Timestamp in seconds when to extract the screenshot. WARNING: Use a timestamp BEFORE the failure (e.g., if failure is at 30.71s, use 30.69s) as failure timestamps often show the home screen after the app has crashed or reset.',
},
},
required: ['xcresult_path', 'test_id', 'timestamp'],
},
},
{
name: 'xcresult_get_ui_hierarchy',
description: 'Get UI hierarchy attachment from test. Returns raw accessibility tree (best for AI), slim AI-readable JSON (default), or full JSON.',
inputSchema: {
type: 'object',
properties: {
xcresult_path: {
type: 'string',
description: 'Absolute path to the .xcresult file',
},
test_id: {
type: 'string',
description: 'Test ID or index number to get UI hierarchy for',
},
timestamp: {
type: 'number',
description: 'Optional timestamp in seconds to find the closest UI snapshot. If not provided, uses the first available UI snapshot.',
},
full_hierarchy: {
type: 'boolean',
description: 'Set to true to get the full hierarchy (several MB). Default is false for AI-readable slim version.',
},
raw_format: {
type: 'boolean',
description: 'Set to true to get the raw accessibility tree text (most AI-friendly). Default is false for JSON format.',
},
},
required: ['xcresult_path', 'test_id'],
},
},
{
name: 'xcresult_get_ui_element',
description: 'Get full details of a specific UI element by index from a previously exported UI hierarchy JSON file',
inputSchema: {
type: 'object',
properties: {
hierarchy_json_path: {
type: 'string',
description: 'Absolute path to the UI hierarchy JSON file (the full version saved by xcresult_get_ui_hierarchy)',
},
element_index: {
type: 'number',
description: 'Index of the element to get details for (the "j" value from the slim hierarchy)',
},
include_children: {
type: 'boolean',
description: 'Whether to include children in the response. Defaults to false.',
},
},
required: ['hierarchy_json_path', 'element_index'],
},
},
{
name: 'xcresult_list_attachments',
description: 'List all attachments for a specific test - shows attachment names, types, and indices for export',
inputSchema: {
type: 'object',
properties: {
xcresult_path: {
type: 'string',
description: 'Absolute path to the .xcresult file',
},
test_id: {
type: 'string',
description: 'Test ID or index number to list attachments for',
},
},
required: ['xcresult_path', 'test_id'],
},
},
{
name: 'xcresult_export_attachment',
description: 'Export a specific attachment by index - can convert App UI hierarchy attachments to JSON',
inputSchema: {
type: 'object',
properties: {
xcresult_path: {
type: 'string',
description: 'Absolute path to the .xcresult file',
},
test_id: {
type: 'string',
description: 'Test ID or index number that contains the attachment',
},
attachment_index: {
type: 'number',
description: 'Index number of the attachment to export (1-based, from xcresult_list_attachments)',
},
convert_to_json: {
type: 'boolean',
description: 'If true and attachment is an App UI hierarchy, convert to JSON format',
},
},
required: ['xcresult_path', 'test_id', 'attachment_index'],
},
},
];
const program = new Command('xcodecontrol')
.version(pkg.version)
.description('Command-line interface for Xcode automation and control')
.option('--json', 'Output results in JSON format', false)
.option('-v, --verbose', 'Enable verbose output (shows INFO logs)', false)
.option('-q, --quiet', 'Suppress all logs except errors', false);
// Add global help command
program
.command('help')
.description('Show help information')
.action(() => {
program.help();
});
// Add list-tools command for compatibility
program
.command('list-tools')
.description('List all available tools')
.action(() => {
console.log('Available tools:');
console.log('');
for (const tool of tools) {
const commandName = tool.name.replace(/^xcode_/, '').replace(/_/g, '-');
console.log(` ${commandName.padEnd(30)} ${tool.description}`);
}
});
// Dynamically create subcommands for each tool
for (const tool of tools) {
// Convert tool name: remove "xcode_" prefix and replace underscores with dashes
const commandName = tool.name.replace(/^xcode_/, '').replace(/_/g, '-');
const cmd = program
.command(commandName)
.description(tool.description);
// Add options based on the tool's input schema
if (tool.inputSchema?.properties) {
for (const [propName, propSchema] of Object.entries(tool.inputSchema.properties)) {
const propDef = propSchema;
const option = schemaPropertyToOption(propName, propDef);
if (option.defaultValue !== undefined) {
cmd.option(option.flags, option.description, option.defaultValue);
}
else {
cmd.option(option.flags, option.description);
}
}
}
// Handle JSON input option
cmd.option('--json-input <json>', 'Provide arguments as JSON string');
// Set up the action handler
cmd.action(async (cliArgs) => {
try {
// Set log level based on CLI options
const globalOpts = program.opts();
if (globalOpts.quiet) {
process.env.LOG_LEVEL = 'ERROR';
}
else if (globalOpts.verbose) {
process.env.LOG_LEVEL = 'DEBUG';
}
else {
process.env.LOG_LEVEL = 'WARN'; // Default: only show warnings and errors
}
let toolArgs;
// Parse arguments from JSON input or CLI flags
if (cliArgs.jsonInput) {
try {
toolArgs = JSON.parse(cliArgs.jsonInput);
}
catch (error) {
console.error('❌ Invalid JSON input:', error);
process.exit(1);
}
}
else {
toolArgs = parseToolArgs(tool, cliArgs);
}
// Validate required parameters
if (tool.inputSchema?.required) {
for (const required of tool.inputSchema.required) {
if (toolArgs[required] === undefined) {
console.error(`❌ Missing required parameter: ${required}`);
process.exit(1);
}
}
}
// Call the tool directly on server
const result = await server.callToolDirect(tool.name, toolArgs);
// Check if the result indicates an error
let hasError = false;
if (result?.content && Array.isArray(result.content)) {
for (const item of result.content) {
if (item.type === 'text' && item.text) {
const text = item.text;
// Special case for health-check: don't treat degraded mode as error
if (tool.name === 'xcode_health_check') {
// Only treat as error if there are critical failures
hasError = text.includes('⚠️ CRITICAL ERRORS DETECTED') ||
text.includes('❌ OS:') ||
text.includes('❌ OSASCRIPT:');
}
else {
// Check for common error patterns
if (text.includes('❌') ||
text.includes('does not exist') ||
text.includes('failed') ||
text.includes('error') ||
text.includes('Error') ||
text.includes('missing required parameter') ||
text.includes('cannot find') ||
text.includes('not found') ||
text.includes('invalid') ||
text.includes('Invalid')) {
hasError = true;
break;
}
}
}
}
}
// Output the result
const output = formatResult(result, program.opts().json);
if (hasError) {
console.error(output);
}
else {
console.log(output);
}
// Exit with appropriate code
process.exit(hasError ? 1 : 0);
}
catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`❌ ${tool.name} failed:`, errorMsg);
process.exit(1);
}
});
}
// Parse command line arguments
await program.parseAsync(process.argv);
}
catch (error) {
Logger.error('CLI initialization failed:', error);
console.error('❌ Failed to initialize CLI:', error);
// Re-throw the error so it can be caught by tests
throw error;
}
}
// Run the CLI if this file is executed directly
// Don't run if we're in test mode and not executing the CLI directly
if (process.env.NODE_ENV !== 'test' || process.argv[1]?.includes('cli.js')) {
main().catch((error) => {
Logger.error('CLI execution failed:', error);
console.error('❌ CLI execution failed:', error);
process.exit(1);
});
}
export { main };
//# sourceMappingURL=cli.js.map