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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ node_modules/
CLAUDE.md
coverage/
.DS_Store
dist/CLI_DISCREPANCIES.md
dist/

# MCP Registry tokens
.mcpregistry_*
Expand Down
89 changes: 85 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ Model Context Protocol (MCP) server that controls Xcode directly through JavaScr
- Opens projects, builds, runs, tests, and debugs from within Xcode
- Parses build logs with precise error locations using [XCLogParser](https://github.com/MobileNativeFoundation/XCLogParser)
- Provides comprehensive environment validation and health checks
- Adds simulator tooling: list/boot/shutdown, capture live logs, grab screenshots, and drive UI automation with [AXe](https://github.com/cameroncooke/axe) without touching the `xcodebuild` CLI
- Supports graceful degradation when optional dependencies are missing
- **NEW**: Includes a full-featured CLI with 100% MCP server feature parity
- **NEW**: Coordinates multi-worker build queues with exclusive locks and explicit release commands

## Requirements

Expand Down Expand Up @@ -148,7 +150,12 @@ npm install -g xcodemcp
xcodecontrol --help

# Run a tool with flags
xcodecontrol build --xcodeproj /path/to/Project.xcodeproj --scheme MyScheme
xcodecontrol build \
--xcodeproj /path/to/Project.xcodeproj \
--scheme MyScheme \
--reason "Working on onboarding flow"
# Run tests and wait for completion (default)
xcodecontrol test --xcodeproj /path/to/Project.xcodeproj --scheme MyScheme --device-type iphone --os-version 18.0

# Get help for a specific tool
xcodecontrol build --help
Expand All @@ -160,6 +167,82 @@ xcodecontrol build --json-input '{"xcodeproj": "/path/to/Project.xcodeproj", "sc
xcodecontrol --json health-check
```

### Simulator & UI Automation Tools

XcodeMCP now includes simulator management and UI automation commands that do **not** rely on `xcodebuild`. Everything runs through the existing JXA powered `xcode_build_and_run` workflow, so you can boot, launch, and interact with the simulator from the same toolchain.

> **Note:** UI automation commands use [AXe](https://github.com/cameroncooke/axe). Install it with `brew install cameroncooke/axe/axe` or set the `XCODEMCP_AXE_PATH` environment variable to an existing binary.

```bash
# List and boot simulators
xcodecontrol list-sims
xcodecontrol boot-sim --simulator-uuid "<UUID>"
xcodecontrol open-sim

# Capture a screenshot from the currently booted simulator
xcodecontrol screenshot --save-path /tmp/screenshot.png

# Drive the UI with AXe (describe → tap → type)
xcodecontrol describe-ui --simulator-uuid "<UUID>"
xcodecontrol tap --simulator-uuid "<UUID>" --x 180 --y 420
xcodecontrol type-text --simulator-uuid "<UUID>" --text "Hello world!"
```

These tools are also available through any MCP client. Refer to `xcode_list_sims`, `xcode_boot_sim`, `xcode_describe_ui`, `xcode_tap`, `xcode_type_text`, `xcode_swipe`, and `xcode_screenshot` in your client's tool list.

### Exclusive Build Locks

Build- and run-style commands now coordinate through a shared lock directory (default: `~/Library/Application Support/XcodeMCP/locks`). Every time you run `xcodecontrol build` or `xcodecontrol build-and-run`, you **must** supply a short `--reason` that summarizes the part of the app you're touching. The command will wait (via file system events, no busy polling) until it's your turn, then print a footer reminding you to release the lock when you're finished inspecting logs or simulator state.

```bash
# Acquire the lock and build
xcodecontrol build \
--xcodeproj /path/to/App.xcodeproj \
--scheme Debug \
--reason "Tweaking settings tab navigation"

# Once you've finished reviewing the results, release your slot
xcodecontrol release-lock --xcodeproj /path/to/App.xcodeproj

# Emergency: clear every outstanding lock (CLI-only safety valve)
xcodecontrol release-all-locks
```

The same release command is exposed to MCP clients as `xcode_release_lock`. Locks are represented as YAML files per project/workspace so multiple workers (or multiple MCP servers) can coordinate safely across processes and sandboxes.

### Inspect Build & Run Logs

Each `xcode_build` / `build-and-run` command now reports a **Log ID** and filesystem path for the associated `.xcactivitylog`. Use the new viewer to inspect it (optionally while the build is still running):

```bash
# Show the exact log returned by your last build command
xcodecontrol view-build-log --log-id "<LOG_ID_FROM_BUILD>"

# Or grab the latest log for a project/workspace and filter for errors
xcodecontrol view-build-log \
--xcodeproj /Users/me/ManabiReader/ManabiReader.xcodeproj \
--filter "error" \
--max-lines 200

# Match multiple patterns with glob syntax (case-insensitive by default)
xcodecontrol view-build-log \
--log-id "<LOG_ID>" \
--filter-globs "*error*,*warning*,type-check failed"

# Resume where you left off using the cursor returned from the previous command
xcodecontrol view-build-log --log-id "<LOG_ID>" --cursor "<CURSOR>" --filter "warning"

# Use regex / case-sensitive filters if needed
xcodecontrol view-build-log --log-id "<LOG_ID>" --filter "The compiler.*type-check" --filter-regex --case-sensitive

# Inspect the runtime console log (same filters & cursor support)
xcodecontrol build-and-run --xcodeproj ... --scheme ...
# ...after it finishes:
xcodecontrol view-run-log --log-id "<LOG_ID_FROM_RUN_SECTION>" --filter-globs "*# DETENTS*"
```

These are exposed as the `xcode_view_build_log` and `xcode_view_run_log` MCP tools (`filter_globs` accepts an array of glob expressions).

### Path Resolution

The CLI supports both absolute and relative paths for convenience:
Expand Down Expand Up @@ -226,12 +309,11 @@ CLI commands use kebab-case instead of underscores:
- `xcode_build_and_run` → `build-and-run`
- `xcode_health_check` → `health-check`
- `xcresult_browse` → `xcresult-browse`
- `find_xcresults` → `find-xcresults`
- `xcode_find_xcresults` → `find-xcresults`

## Available Tools

**Project Management:**
- `xcode_open_project` - Open projects and workspaces
- `xcode_get_workspace_info` - Get workspace status and details
- `xcode_get_projects` - List projects in workspace
- `xcode_open_file` - Open files with optional line number
Expand All @@ -241,7 +323,6 @@ CLI commands use kebab-case instead of underscores:
- `xcode_clean` - Clean build artifacts
- `xcode_test` - Run tests with optional arguments
- `xcode_build_and_run` - Build and run the active scheme
- `xcode_debug` - Start debugging session
- `xcode_stop` - Stop current operation

**Configuration:**
Expand Down
67 changes: 39 additions & 28 deletions __tests__/cli-parameter-consistency.vitest.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeAll } from 'vitest';
import { promisify } from 'util';

const execAsync = promisify(require('child_process').exec);
const PROJECT_ROOT = process.cwd();
const CLI_BIN = 'node dist/cli.js';

beforeAll(async () => {
await execAsync('npm run build', {
cwd: PROJECT_ROOT,
timeout: 30000
});
});

describe('CLI Parameter Consistency', () => {
it('should accept new kebab-case parameters for all affected commands', async () => {
Expand All @@ -19,27 +28,29 @@ describe('CLI Parameter Consistency', () => {
expectedError: 'Project file does not exist', // Project not found, but parameters accepted
},
{
command: 'build-and-run --xcodeproj /fake/project.xcodeproj --scheme TestScheme --command-line-arguments arg1',
expectedError: 'Project file does not exist', // Project not found, but parameters accepted
command: 'build --xcodeproj /fake/project.xcodeproj --scheme TestScheme --reason "Testing CLI locks"',
expectedError: 'Project file does not exist',
},
{
command: 'debug --xcodeproj /fake/project.xcodeproj --scheme TestScheme --skip-building',
command: 'build-and-run --xcodeproj /fake/project.xcodeproj --scheme TestScheme --reason "Testing CLI locks" --command-line-arguments arg1',
expectedError: 'Project file does not exist', // Project not found, but parameters accepted
},
];

for (const { command, expectedError } of commands) {
const result = await execAsync(`npm run build && node dist/cli.js ${command}`, {
cwd: process.cwd(),
const result = await execAsync(`${CLI_BIN} ${command}`, {
cwd: PROJECT_ROOT,
timeout: 10000
}).catch(err => err);

const combined = `${result.stdout ?? ''}${result.stderr ?? ''}`;

// Should not complain about unknown options or missing required parameters
expect(result.stderr).not.toContain('unknown option');
expect(result.stderr).not.toContain('Missing required parameter');
expect(combined).not.toContain('unknown option');
expect(combined).not.toContain('Missing required parameter');

// Should fail with expected error (file/project not found)
expect(result.stderr).toContain(expectedError);
expect(combined).toContain(expectedError);
}
}, 60000);

Expand All @@ -49,17 +60,17 @@ describe('CLI Parameter Consistency', () => {
'open-file --lineNumber 10',
'set-active-scheme --schemeName TestScheme',
'test --commandLineArguments arg1',
'debug --skipBuilding',
];

for (const command of commands) {
const result = await execAsync(`npm run build && node dist/cli.js ${command}`, {
cwd: process.cwd(),
const result = await execAsync(`${CLI_BIN} ${command}`, {
cwd: PROJECT_ROOT,
timeout: 10000
}).catch(err => err);

// Should show unknown option error
expect(result.stderr).toContain('unknown option');
const combined = `${result.stdout ?? ''}${result.stderr ?? ''}`;
expect(combined).toContain('unknown option');
}
}, 60000);

Expand All @@ -68,15 +79,15 @@ describe('CLI Parameter Consistency', () => {
{ command: 'open-file --help', expected: ['--file-path', '--line-number'] },
{ command: 'set-active-scheme --help', expected: ['--scheme-name'] },
{ command: 'test --help', expected: ['--command-line-arguments'] },
{ command: 'build-and-run --help', expected: ['--command-line-arguments'] },
{ command: 'debug --help', expected: ['--skip-building'] },
{ command: 'build --help', expected: ['--reason'] },
{ command: 'build-and-run --help', expected: ['--reason', '--command-line-arguments'] },
{ command: 'xcresult-get-ui-element --help', expected: ['--hierarchy-json-path', '--element-index'] },
{ command: 'xcresult-export-attachment --help', expected: ['--attachment-index'] },
];

for (const { command, expected } of commands) {
const result = await execAsync(`npm run build && node dist/cli.js ${command}`, {
cwd: process.cwd(),
const result = await execAsync(`${CLI_BIN} ${command}`, {
cwd: PROJECT_ROOT,
timeout: 10000
});

Expand All @@ -94,25 +105,25 @@ describe('CLI Parameter Consistency', () => {

it('should reference CLI command names in help text and usage instructions', async () => {
// Test that help text references use CLI command names, not internal tool names
const result1 = await execAsync('npm run build && node dist/cli.js xcresult-get-ui-element --help', {
cwd: process.cwd(),
const result1 = await execAsync(`${CLI_BIN} xcresult-get-ui-element --help`, {
cwd: PROJECT_ROOT,
timeout: 10000
});

expect(result1.stdout).toContain('xcresult-get-ui-hierarchy');
expect(result1.stdout).not.toContain('xcresult_get_ui_hierarchy');
expect(result1.stdout).not.toContain('xcode_xcresult_get_ui_hierarchy');

const result2 = await execAsync('npm run build && node dist/cli.js xcresult-export-attachment --help', {
cwd: process.cwd(),
const result2 = await execAsync(`${CLI_BIN} xcresult-export-attachment --help`, {
cwd: PROJECT_ROOT,
timeout: 10000
});

expect(result2.stdout).toContain('xcresult-list-attachments');
expect(result2.stdout).not.toContain('xcresult_list_attachments');
expect(result2.stdout).not.toContain('xcode_xcresult_list_attachments');

// Test find-xcresults usage instructions with a real project
const result3 = await execAsync('npm run build && node dist/cli.js find-xcresults --xcodeproj __tests__/TestApp/TestApp.xcodeproj', {
cwd: process.cwd(),
const result3 = await execAsync(`${CLI_BIN} find-xcresults --xcodeproj __tests__/TestApp/TestApp.xcodeproj`, {
cwd: PROJECT_ROOT,
timeout: 10000
});

Expand All @@ -125,8 +136,8 @@ describe('CLI Parameter Consistency', () => {
// If there are usage instructions, verify they use kebab-case
if (hasUsageInstructions) {
expect(result3.stdout).toContain('xcresult-browser-get-console --xcresult-path');
expect(result3.stdout).not.toContain('xcresult_browse');
expect(result3.stdout).not.toContain('xcresult_browser_get_console');
expect(result3.stdout).not.toContain('xcode_xcresult_browse');
expect(result3.stdout).not.toContain('xcode_xcresult_browser_get_console');
}
}, 30000);
});
});
10 changes: 5 additions & 5 deletions __tests__/cli.integration.vitest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('CLI Integration Tests', () => {

expect(stdout).toContain('Available tools organized by category:');
expect(stdout).toContain('📁 Project Management:');
expect(stdout).toContain('open-project');
expect(stdout).toContain('get-schemes');
expect(stdout).toContain('build');
expect(stdout).toContain('health-check');
});
Expand Down Expand Up @@ -106,7 +106,8 @@ describe('CLI Integration Tests', () => {
CLI_PATH,
'build',
'--xcodeproj', '/non/existent/project.xcodeproj',
'--scheme', 'Test'
'--scheme', 'Test',
'--reason', 'CLI integration test',
])
).rejects.toMatchObject({
exitCode: 1,
Expand All @@ -132,8 +133,7 @@ describe('CLI Integration Tests', () => {
const { stdout } = await execa('node', [CLI_PATH, 'list-tools']);

// Tool names in list-tools output show CLI command names
expect(stdout).toContain('open-project');
expect(stdout).toContain('close-project');
expect(stdout).toContain('get-schemes');
expect(stdout).toContain('build');
});

Expand Down Expand Up @@ -175,4 +175,4 @@ describe('CLI Exit Codes', () => {
const { exitCode } = await execa('node', [CLI_PATH, '--help']);
expect(exitCode).toBe(0);
});
});
});
5 changes: 2 additions & 3 deletions __tests__/cli.vitest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ vi.mock('commander', async () => {
description() { return this; }
option() { return this; }
command() { return this; }
alias() { return this; }
action() { return this; }
parseAsync() { return Promise.resolve(); }
}
Expand Down Expand Up @@ -79,8 +80,6 @@ describe('CLI Tool Argument Parsing', () => {
describe('CLI Tool Registration', () => {
it('should register hardcoded tools as commands', async () => {
// The CLI currently has hardcoded tools:
// - xcode_open_project -> open-project
// - xcode_close_project -> close-project
// - xcode_build -> build
// - xcode_health_check -> health-check
// This is a temporary solution as noted in the CLI code
Expand All @@ -94,4 +93,4 @@ describe('CLI Help System', () => {
// The CLI shows help for its hardcoded tools
await expect(main()).resolves.toBeUndefined();
});
});
});
11 changes: 9 additions & 2 deletions __tests__/environment-validator.vitest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ import type { EnvironmentValidation, EnvironmentValidationResult } from '../src/

// Mock the child_process module
vi.mock('child_process', () => ({
spawn: vi.fn()
spawn: vi.fn(),
execFile: vi.fn((...args: any[]) => {
const callback = typeof args[args.length - 1] === 'function' ? args[args.length - 1] : undefined;
if (callback) {
callback(null, '', '');
}
return { pid: 123 };
}),
}));

// Mock fs module
Expand Down Expand Up @@ -578,4 +585,4 @@ function createMockProcess(stdout: string, stderr: string, exitCode: number, del
});

return mockProcess;
}
}
Loading