Skip to content

Commit 87c389c

Browse files
committed
feat(device): Add unified build-and-run command
Introduce a first-class device build-and-run flow that builds, installs, and launches in one step for physical devices. Add shared build-settings and bundle-id helpers to keep app path and bundle resolution consistent across device and discovery tools, and update manifests, CLI docs, tests, and smoke coverage for the new command surface.
1 parent 91f44e7 commit 87c389c

25 files changed

Lines changed: 796 additions & 125 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ When reading issues:
1717
-
1818
## Tools
1919
- GitHub CLI for issues/PRs
20+
- 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.
2021
- When working on skill sources in `skills/`, use the `skill-creator` skill workflow.
2122
- After modifying any skill source, run `npx skill-check <skill-directory>` and address all errors/warnings before handoff.
2223
-

docs/TOOLS-CLI.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ XcodeBuildMCP provides 75 canonical tools organized into 14 workflow groups.
2525
**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (16 tools)
2626

2727
- `build` - Build for device.
28+
- `build-and-run` - Build, install, and launch on physical device. Preferred single-step run tool when defaults are set.
2829
- `clean` - Clean build products.
2930
- `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.
3031
- `get-app-bundle-id` - Extract bundle id from .app.

docs/TOOLS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov
2323
**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (16 tools)
2424

2525
- `build_device` - Build for device.
26+
- `build_run_device` - Build, install, and launch on physical device. Preferred single-step run tool when defaults are set.
2627
- `clean` - Clean build products.
2728
- `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.
2829
- `get_app_bundle_id` - Extract bundle id from .app.

example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ enabledWorkflows:
44
- simulator
55
- ui-automation
66
- xcode-ide
7+
- device
78
debug: false
89
sentryDisabled: false
910
sessionDefaults:

example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/ContentView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ public struct ContentView: View {
7070
}
7171

7272
private func handleButtonPress(_ button: String) {
73+
print("[CalculatorApp] Button pressed: \(button)")
74+
7375
// Process input through the input handler
7476
inputHandler.handleInput(button)
7577

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
id: build_run_device
2+
module: mcp/tools/device/build_run_device
3+
names:
4+
mcp: build_run_device
5+
cli: build-and-run
6+
description: Build, install, and launch on physical device. Preferred single-step run tool when defaults are set.
7+
predicates:
8+
- hideWhenXcodeAgentMode
9+
annotations:
10+
title: Build Run Device
11+
destructiveHint: false
12+
nextSteps:
13+
- label: Capture device logs
14+
toolId: start_device_log_cap
15+
priority: 1
16+
- label: Stop app on device
17+
toolId: stop_app_device
18+
priority: 2

manifests/tools/build_run_sim.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ nextSteps:
1313
- label: Capture structured logs (app continues running)
1414
toolId: start_sim_log_cap
1515
priority: 1
16+
- label: Stop app in simulator
17+
toolId: stop_app_sim
18+
priority: 2
1619
- label: Capture console + structured logs (app restarts)
1720
toolId: start_sim_log_cap
18-
priority: 2
21+
priority: 3
1922
- label: Launch app with logs in one step
2023
toolId: launch_app_logs_sim
21-
priority: 3
24+
priority: 4

manifests/workflows/device.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ title: iOS Device Development
33
description: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro).
44
tools:
55
- build_device
6+
- build_run_device
67
- test_device
78
- list_devices
89
- install_app_device
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import * as z from 'zod';
3+
import {
4+
createMockCommandResponse,
5+
createMockFileSystemExecutor,
6+
} from '../../../../test-utils/mock-executors.ts';
7+
import type { CommandExecutor } from '../../../../utils/execution/index.ts';
8+
import { sessionStore } from '../../../../utils/session-store.ts';
9+
import { schema, handler, build_run_deviceLogic } from '../build_run_device.ts';
10+
11+
describe('build_run_device tool', () => {
12+
beforeEach(() => {
13+
sessionStore.clear();
14+
});
15+
16+
it('exposes only non-session fields in public schema', () => {
17+
const schemaObj = z.strictObject(schema);
18+
19+
expect(schemaObj.safeParse({}).success).toBe(true);
20+
expect(schemaObj.safeParse({ extraArgs: ['-quiet'] }).success).toBe(true);
21+
expect(schemaObj.safeParse({ env: { FOO: 'bar' } }).success).toBe(true);
22+
23+
expect(schemaObj.safeParse({ scheme: 'App' }).success).toBe(false);
24+
expect(schemaObj.safeParse({ deviceId: 'device-id' }).success).toBe(false);
25+
26+
const schemaKeys = Object.keys(schema).sort();
27+
expect(schemaKeys).toEqual(['env', 'extraArgs']);
28+
});
29+
30+
it('requires scheme + deviceId and project/workspace via handler', async () => {
31+
const missingAll = await handler({});
32+
expect(missingAll.isError).toBe(true);
33+
expect(missingAll.content[0].text).toContain('Provide scheme and deviceId');
34+
35+
const missingSource = await handler({ scheme: 'MyApp', deviceId: 'DEVICE-UDID' });
36+
expect(missingSource.isError).toBe(true);
37+
expect(missingSource.content[0].text).toContain('Provide a project or workspace');
38+
});
39+
40+
it('builds, installs, and launches successfully', async () => {
41+
const commands: string[] = [];
42+
const mockExecutor: CommandExecutor = async (command) => {
43+
commands.push(command.join(' '));
44+
45+
if (command.includes('-showBuildSettings')) {
46+
return createMockCommandResponse({
47+
success: true,
48+
output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n',
49+
});
50+
}
51+
52+
if (command[0] === '/bin/sh') {
53+
return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' });
54+
}
55+
56+
return createMockCommandResponse({ success: true, output: 'OK' });
57+
};
58+
59+
const result = await build_run_deviceLogic(
60+
{
61+
projectPath: '/tmp/MyApp.xcodeproj',
62+
scheme: 'MyApp',
63+
deviceId: 'DEVICE-UDID',
64+
},
65+
mockExecutor,
66+
createMockFileSystemExecutor({
67+
existsSync: () => true,
68+
readFile: async () => JSON.stringify({ result: { process: { processIdentifier: 1234 } } }),
69+
}),
70+
);
71+
72+
expect(result.isError).toBe(false);
73+
expect(result.content[0].text).toContain('device build and run succeeded');
74+
expect(result.nextStepParams).toMatchObject({
75+
start_device_log_cap: { deviceId: 'DEVICE-UDID', bundleId: 'io.sentry.MyApp' },
76+
stop_app_device: { deviceId: 'DEVICE-UDID', processId: 1234 },
77+
});
78+
79+
expect(commands.some((c) => c.includes('xcodebuild') && c.includes('build'))).toBe(true);
80+
expect(commands.some((c) => c.includes('xcodebuild') && c.includes('-showBuildSettings'))).toBe(
81+
true,
82+
);
83+
expect(commands.some((c) => c.includes('devicectl') && c.includes('install'))).toBe(true);
84+
expect(commands.some((c) => c.includes('devicectl') && c.includes('launch'))).toBe(true);
85+
});
86+
87+
it('uses generic destination for build-settings lookup', async () => {
88+
const commandCalls: string[][] = [];
89+
const mockExecutor: CommandExecutor = async (command) => {
90+
commandCalls.push(command);
91+
92+
if (command.includes('-showBuildSettings')) {
93+
return createMockCommandResponse({
94+
success: true,
95+
output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyWatchApp.app\n',
96+
});
97+
}
98+
99+
if (command[0] === '/bin/sh') {
100+
return createMockCommandResponse({ success: true, output: 'io.sentry.MyWatchApp' });
101+
}
102+
103+
if (command.includes('launch')) {
104+
return createMockCommandResponse({
105+
success: true,
106+
output: JSON.stringify({ result: { process: { processIdentifier: 9876 } } }),
107+
});
108+
}
109+
110+
return createMockCommandResponse({ success: true, output: 'OK' });
111+
};
112+
113+
const result = await build_run_deviceLogic(
114+
{
115+
projectPath: '/tmp/MyWatchApp.xcodeproj',
116+
scheme: 'MyWatchApp',
117+
platform: 'watchOS',
118+
deviceId: 'DEVICE-UDID',
119+
},
120+
mockExecutor,
121+
createMockFileSystemExecutor({ existsSync: () => true }),
122+
);
123+
124+
expect(result.isError).toBe(false);
125+
126+
const showBuildSettingsCommand = commandCalls.find((command) =>
127+
command.includes('-showBuildSettings'),
128+
);
129+
expect(showBuildSettingsCommand).toBeDefined();
130+
expect(showBuildSettingsCommand).toContain('-destination');
131+
132+
const destinationIndex = showBuildSettingsCommand!.indexOf('-destination');
133+
expect(showBuildSettingsCommand![destinationIndex + 1]).toBe('generic/platform=watchOS');
134+
});
135+
136+
it('includes fallback stop guidance when process id is unavailable', async () => {
137+
const mockExecutor: CommandExecutor = async (command) => {
138+
if (command.includes('-showBuildSettings')) {
139+
return createMockCommandResponse({
140+
success: true,
141+
output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n',
142+
});
143+
}
144+
145+
if (command[0] === '/bin/sh') {
146+
return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' });
147+
}
148+
149+
return createMockCommandResponse({ success: true, output: 'OK' });
150+
};
151+
152+
const result = await build_run_deviceLogic(
153+
{
154+
projectPath: '/tmp/MyApp.xcodeproj',
155+
scheme: 'MyApp',
156+
deviceId: 'DEVICE-UDID',
157+
},
158+
mockExecutor,
159+
createMockFileSystemExecutor({
160+
existsSync: () => true,
161+
readFile: async () => 'not-json',
162+
}),
163+
);
164+
165+
expect(result.isError).toBe(false);
166+
expect(result.content[0].text).toContain('Process ID was unavailable');
167+
expect(result.nextStepParams).toMatchObject({
168+
start_device_log_cap: { deviceId: 'DEVICE-UDID', bundleId: 'io.sentry.MyApp' },
169+
});
170+
expect(result.nextStepParams?.stop_app_device).toBeUndefined();
171+
});
172+
173+
it('returns an error when app-path lookup fails after successful build', async () => {
174+
const mockExecutor: CommandExecutor = async (command) => {
175+
if (command.includes('-showBuildSettings')) {
176+
return createMockCommandResponse({ success: false, error: 'no build settings' });
177+
}
178+
return createMockCommandResponse({ success: true, output: 'OK' });
179+
};
180+
181+
const result = await build_run_deviceLogic(
182+
{
183+
projectPath: '/tmp/MyApp.xcodeproj',
184+
scheme: 'MyApp',
185+
deviceId: 'DEVICE-UDID',
186+
},
187+
mockExecutor,
188+
createMockFileSystemExecutor({ existsSync: () => true }),
189+
);
190+
191+
expect(result.isError).toBe(true);
192+
expect(result.content[0].text).toContain('failed to get app path');
193+
});
194+
195+
it('returns an error when install fails', async () => {
196+
const mockExecutor: CommandExecutor = async (command) => {
197+
if (command.includes('-showBuildSettings')) {
198+
return createMockCommandResponse({
199+
success: true,
200+
output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n',
201+
});
202+
}
203+
204+
if (command.includes('install')) {
205+
return createMockCommandResponse({ success: false, error: 'install failed' });
206+
}
207+
208+
return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' });
209+
};
210+
211+
const result = await build_run_deviceLogic(
212+
{
213+
projectPath: '/tmp/MyApp.xcodeproj',
214+
scheme: 'MyApp',
215+
deviceId: 'DEVICE-UDID',
216+
},
217+
mockExecutor,
218+
createMockFileSystemExecutor({ existsSync: () => true }),
219+
);
220+
221+
expect(result.isError).toBe(true);
222+
expect(result.content[0].text).toContain('error installing app on device');
223+
});
224+
225+
it('returns an error when launch fails', async () => {
226+
const mockExecutor: CommandExecutor = async (command) => {
227+
if (command.includes('-showBuildSettings')) {
228+
return createMockCommandResponse({
229+
success: true,
230+
output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyApp.app\n',
231+
});
232+
}
233+
234+
if (command.includes('launch')) {
235+
return createMockCommandResponse({ success: false, error: 'launch failed' });
236+
}
237+
238+
return createMockCommandResponse({ success: true, output: 'io.sentry.MyApp' });
239+
};
240+
241+
const result = await build_run_deviceLogic(
242+
{
243+
projectPath: '/tmp/MyApp.xcodeproj',
244+
scheme: 'MyApp',
245+
deviceId: 'DEVICE-UDID',
246+
},
247+
mockExecutor,
248+
createMockFileSystemExecutor({ existsSync: () => true }),
249+
);
250+
251+
expect(result.isError).toBe(true);
252+
expect(result.content[0].text).toContain('error launching app on device');
253+
});
254+
});

src/mcp/tools/device/__tests__/get_device_app_path.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ describe('get_device_app_path plugin', () => {
131131
],
132132
logPrefix: 'Get App Path',
133133
useShell: false,
134-
opts: undefined,
134+
opts: { cwd: '/path/to' },
135135
});
136136
});
137137

@@ -186,7 +186,7 @@ describe('get_device_app_path plugin', () => {
186186
],
187187
logPrefix: 'Get App Path',
188188
useShell: false,
189-
opts: undefined,
189+
opts: { cwd: '/path/to' },
190190
});
191191
});
192192

@@ -240,7 +240,7 @@ describe('get_device_app_path plugin', () => {
240240
],
241241
logPrefix: 'Get App Path',
242242
useShell: false,
243-
opts: undefined,
243+
opts: { cwd: '/path/to' },
244244
});
245245
});
246246

@@ -378,7 +378,7 @@ describe('get_device_app_path plugin', () => {
378378
],
379379
logPrefix: 'Get App Path',
380380
useShell: false,
381-
opts: undefined,
381+
opts: { cwd: '/path/to' },
382382
});
383383
});
384384

0 commit comments

Comments
 (0)