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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ When reading issues:
-
## Tools
- GitHub CLI for issues/PRs
- CLI design note: do not rely on CLI session-default writes. CLI is intentionally deterministic for CI/scripting and should use explicit command arguments as the primary input surface.
- When working on skill sources in `skills/`, use the `skill-creator` skill workflow.
- After modifying any skill source, run `npx skill-check <skill-directory>` and address all errors/warnings before handoff.
-
Expand Down
1 change: 1 addition & 0 deletions docs/TOOLS-CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ XcodeBuildMCP provides 75 canonical tools organized into 14 workflow groups.
**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (16 tools)

- `build` - Build for device.
- `build-and-run` - Build, install, and launch on physical device. Preferred single-step run tool when defaults are set.
- `clean` - Clean build products.
- `discover-projects` - Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. Use when project/workspace path is unknown.
- `get-app-bundle-id` - Extract bundle id from .app.
Expand Down
1 change: 1 addition & 0 deletions docs/TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov
**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (16 tools)

- `build_device` - Build for device.
- `build_run_device` - Build, install, and launch on physical device. Preferred single-step run tool when defaults are set.
- `clean` - Clean build products.
- `discover_projs` - Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. Use when project/workspace path is unknown.
- `get_app_bundle_id` - Extract bundle id from .app.
Expand Down
1 change: 1 addition & 0 deletions example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ enabledWorkflows:
- simulator
- ui-automation
- xcode-ide
- device
debug: false
sentryDisabled: false
sessionDefaults:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ public struct ContentView: View {
}

private func handleButtonPress(_ button: String) {
print("[CalculatorApp] Button pressed: \(button)")

// Process input through the input handler
inputHandler.handleInput(button)

Expand Down
18 changes: 18 additions & 0 deletions manifests/tools/build_run_device.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
id: build_run_device
module: mcp/tools/device/build_run_device
names:
mcp: build_run_device
cli: build-and-run
description: Build, install, and launch on physical device. Preferred single-step run tool when defaults are set.
predicates:
- hideWhenXcodeAgentMode
annotations:
title: Build Run Device
destructiveHint: false
nextSteps:
- label: Capture device logs
toolId: start_device_log_cap
priority: 1
- label: Stop app on device
toolId: stop_app_device
priority: 2
7 changes: 5 additions & 2 deletions manifests/tools/build_run_sim.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ nextSteps:
- label: Capture structured logs (app continues running)
toolId: start_sim_log_cap
priority: 1
- label: Stop app in simulator
toolId: stop_app_sim
priority: 2
- label: Capture console + structured logs (app restarts)
toolId: start_sim_log_cap
priority: 2
priority: 3
- label: Launch app with logs in one step
toolId: launch_app_logs_sim
priority: 3
priority: 4
1 change: 1 addition & 0 deletions manifests/workflows/device.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ title: iOS Device Development
description: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro).
tools:
- build_device
- build_run_device
- test_device
- list_devices
- install_app_device
Expand Down
254 changes: 254 additions & 0 deletions src/mcp/tools/device/__tests__/build_run_device.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { describe, it, expect, beforeEach } from 'vitest';
import * as z from 'zod';
import {
createMockCommandResponse,
createMockFileSystemExecutor,
} from '../../../../test-utils/mock-executors.ts';
import type { CommandExecutor } from '../../../../utils/execution/index.ts';
import { sessionStore } from '../../../../utils/session-store.ts';
import { schema, handler, build_run_deviceLogic } from '../build_run_device.ts';

describe('build_run_device tool', () => {
beforeEach(() => {
sessionStore.clear();
});

it('exposes only non-session fields in public schema', () => {
const schemaObj = z.strictObject(schema);

expect(schemaObj.safeParse({}).success).toBe(true);
expect(schemaObj.safeParse({ extraArgs: ['-quiet'] }).success).toBe(true);
expect(schemaObj.safeParse({ env: { FOO: 'bar' } }).success).toBe(true);

expect(schemaObj.safeParse({ scheme: 'App' }).success).toBe(false);
expect(schemaObj.safeParse({ deviceId: 'device-id' }).success).toBe(false);

const schemaKeys = Object.keys(schema).sort();
expect(schemaKeys).toEqual(['env', 'extraArgs']);
});

it('requires scheme + deviceId and project/workspace via handler', async () => {
const missingAll = await handler({});
expect(missingAll.isError).toBe(true);
expect(missingAll.content[0].text).toContain('Provide scheme and deviceId');

const missingSource = await handler({ scheme: 'MyApp', deviceId: 'DEVICE-UDID' });
expect(missingSource.isError).toBe(true);
expect(missingSource.content[0].text).toContain('Provide a project or workspace');
});

it('builds, installs, and launches successfully', async () => {
const commands: string[] = [];
const mockExecutor: CommandExecutor = async (command) => {
commands.push(command.join(' '));

if (command.includes('-showBuildSettings')) {
return createMockCommandResponse({
success: true,
output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n',
});
}

if (command[0] === '/bin/sh') {
return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' });
}

return createMockCommandResponse({ success: true, output: 'OK' });
};

const result = await build_run_deviceLogic(
{
projectPath: '/tmp/MyApp.xcodeproj',
scheme: 'MyApp',
deviceId: 'DEVICE-UDID',
},
mockExecutor,
createMockFileSystemExecutor({
existsSync: () => true,
readFile: async () => JSON.stringify({ result: { process: { processIdentifier: 1234 } } }),
}),
);

expect(result.isError).toBe(false);
expect(result.content[0].text).toContain('device build and run succeeded');
expect(result.nextStepParams).toMatchObject({
start_device_log_cap: { deviceId: 'DEVICE-UDID', bundleId: 'io.sentry.MyApp' },
stop_app_device: { deviceId: 'DEVICE-UDID', processId: 1234 },
});

expect(commands.some((c) => c.includes('xcodebuild') && c.includes('build'))).toBe(true);
expect(commands.some((c) => c.includes('xcodebuild') && c.includes('-showBuildSettings'))).toBe(
true,
);
expect(commands.some((c) => c.includes('devicectl') && c.includes('install'))).toBe(true);
expect(commands.some((c) => c.includes('devicectl') && c.includes('launch'))).toBe(true);
});

it('uses generic destination for build-settings lookup', async () => {
const commandCalls: string[][] = [];
const mockExecutor: CommandExecutor = async (command) => {
commandCalls.push(command);

if (command.includes('-showBuildSettings')) {
return createMockCommandResponse({
success: true,
output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyWatchApp.app\n',
});
}

if (command[0] === '/bin/sh') {
return createMockCommandResponse({ success: true, output: 'io.sentry.MyWatchApp' });
}

if (command.includes('launch')) {
return createMockCommandResponse({
success: true,
output: JSON.stringify({ result: { process: { processIdentifier: 9876 } } }),
});
}

return createMockCommandResponse({ success: true, output: 'OK' });
};

const result = await build_run_deviceLogic(
{
projectPath: '/tmp/MyWatchApp.xcodeproj',
scheme: 'MyWatchApp',
platform: 'watchOS',
deviceId: 'DEVICE-UDID',
},
mockExecutor,
createMockFileSystemExecutor({ existsSync: () => true }),
);

expect(result.isError).toBe(false);

const showBuildSettingsCommand = commandCalls.find((command) =>
command.includes('-showBuildSettings'),
);
expect(showBuildSettingsCommand).toBeDefined();
expect(showBuildSettingsCommand).toContain('-destination');

const destinationIndex = showBuildSettingsCommand!.indexOf('-destination');
expect(showBuildSettingsCommand![destinationIndex + 1]).toBe('generic/platform=watchOS');
});

it('includes fallback stop guidance when process id is unavailable', async () => {
const mockExecutor: CommandExecutor = async (command) => {
if (command.includes('-showBuildSettings')) {
return createMockCommandResponse({
success: true,
output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n',
});
}

if (command[0] === '/bin/sh') {
return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' });
}

return createMockCommandResponse({ success: true, output: 'OK' });
};

const result = await build_run_deviceLogic(
{
projectPath: '/tmp/MyApp.xcodeproj',
scheme: 'MyApp',
deviceId: 'DEVICE-UDID',
},
mockExecutor,
createMockFileSystemExecutor({
existsSync: () => true,
readFile: async () => 'not-json',
}),
);

expect(result.isError).toBe(false);
expect(result.content[0].text).toContain('Process ID was unavailable');
expect(result.nextStepParams).toMatchObject({
start_device_log_cap: { deviceId: 'DEVICE-UDID', bundleId: 'io.sentry.MyApp' },
});
expect(result.nextStepParams?.stop_app_device).toBeUndefined();
});

it('returns an error when app-path lookup fails after successful build', async () => {
const mockExecutor: CommandExecutor = async (command) => {
if (command.includes('-showBuildSettings')) {
return createMockCommandResponse({ success: false, error: 'no build settings' });
}
return createMockCommandResponse({ success: true, output: 'OK' });
};

const result = await build_run_deviceLogic(
{
projectPath: '/tmp/MyApp.xcodeproj',
scheme: 'MyApp',
deviceId: 'DEVICE-UDID',
},
mockExecutor,
createMockFileSystemExecutor({ existsSync: () => true }),
);

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('failed to get app path');
});

it('returns an error when install fails', async () => {
const mockExecutor: CommandExecutor = async (command) => {
if (command.includes('-showBuildSettings')) {
return createMockCommandResponse({
success: true,
output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n',
});
}

if (command.includes('install')) {
return createMockCommandResponse({ success: false, error: 'install failed' });
}

return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' });
};

const result = await build_run_deviceLogic(
{
projectPath: '/tmp/MyApp.xcodeproj',
scheme: 'MyApp',
deviceId: 'DEVICE-UDID',
},
mockExecutor,
createMockFileSystemExecutor({ existsSync: () => true }),
);

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('error installing app on device');
});

it('returns an error when launch fails', async () => {
const mockExecutor: CommandExecutor = async (command) => {
if (command.includes('-showBuildSettings')) {
return createMockCommandResponse({
success: true,
output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n',
});
}

if (command.includes('launch')) {
return createMockCommandResponse({ success: false, error: 'launch failed' });
}

return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' });
};

const result = await build_run_deviceLogic(
{
projectPath: '/tmp/MyApp.xcodeproj',
scheme: 'MyApp',
deviceId: 'DEVICE-UDID',
},
mockExecutor,
createMockFileSystemExecutor({ existsSync: () => true }),
);

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('error launching app on device');
});
});
8 changes: 4 additions & 4 deletions src/mcp/tools/device/__tests__/get_device_app_path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ describe('get_device_app_path plugin', () => {
],
logPrefix: 'Get App Path',
useShell: false,
opts: undefined,
opts: { cwd: '/path/to' },
});
});

Expand Down Expand Up @@ -186,7 +186,7 @@ describe('get_device_app_path plugin', () => {
],
logPrefix: 'Get App Path',
useShell: false,
opts: undefined,
opts: { cwd: '/path/to' },
});
});

Expand Down Expand Up @@ -240,7 +240,7 @@ describe('get_device_app_path plugin', () => {
],
logPrefix: 'Get App Path',
useShell: false,
opts: undefined,
opts: { cwd: '/path/to' },
});
});

Expand Down Expand Up @@ -378,7 +378,7 @@ describe('get_device_app_path plugin', () => {
],
logPrefix: 'Get App Path',
useShell: false,
opts: undefined,
opts: { cwd: '/path/to' },
});
});

Expand Down
3 changes: 2 additions & 1 deletion src/mcp/tools/device/__tests__/list_devices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,11 +221,12 @@ describe('list_devices plugin (device-shared)', () => {
content: [
{
type: 'text',
text: "Connected Devices:\n\n✅ Available Devices:\n\n📱 Test iPhone\n UDID: test-device-123\n Model: iPhone15,2\n Product Type: iPhone15,2\n Platform: iOS 17.0\n Connection: USB\n\nNote: Use the device ID/UDID from above when required by other tools.\nHint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n",
text: "Connected Devices:\n\n✅ Available Devices:\n\n📱 Test iPhone\n UDID: test-device-123\n Model: iPhone15,2\n Product Type: iPhone15,2\n Platform: iOS 17.0\n Connection: USB\n\nNote: Use the device ID/UDID from above when required by other tools.\nHint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\nBefore running build/run/test/UI automation tools, set the desired device identifier in session defaults.\n",
},
],
nextStepParams: {
build_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' },
build_run_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' },
test_device: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' },
get_device_app_path: { scheme: 'SCHEME' },
},
Expand Down
Loading
Loading