Skip to content

Commit 402f423

Browse files
cameroncookecodex
andcommitted
feat(tools)!: Standardize launch arguments
Add launchArgs to build-and-run tools and use it as the canonical launch-argument parameter for launch-only tools. Keep extraArgs scoped to xcodebuild and build settings so app runtime arguments cannot leak into build commands. BREAKING CHANGE: launch-only tools now use launchArgs instead of args for app launch arguments. Co-Authored-By: Codex <noreply@openai.com>
1 parent 5f8c977 commit 402f423

14 files changed

Lines changed: 333 additions & 27 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Added configurable file artifact text rendering with CLI output defaulting to labeled `Files:` lists, MCP text preserving compact trees, and `filePathRenderStyle` / `XCODEBUILDMCP_FILE_PATH_RENDER_STYLE` / `--file-path-render-style` overrides.
88
- Added workspace-scoped default xcresult bundles for simulator, device, and macOS test tools so test artifacts are available in structured and text output even when callers do not pass `-resultBundlePath`.
99
- Added opt-in MCP server idle shutdown via `XCODEBUILDMCP_MCP_IDLE_TIMEOUT_MS`, allowing unused MCP server processes to gracefully exit after a configured idle period ([#394](https://github.com/getsentry/XcodeBuildMCP/issues/394)).
10+
- Added canonical `launchArgs` launch-argument input across build-and-run tools (`build_run_sim`, `build_run_device`, `build_run_macos`) and launch-only tools (`launch_app_sim`, `launch_app_device`, `launch_mac_app`) so runtime launch arguments are passed only to app launch steps; `extraArgs` remains build-system-only and launch-only tools no longer use generic `args`.
1011

1112
### Fixed
1213

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

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe('build_run_device tool', () => {
3232

3333
expect(schemaObj.safeParse({}).success).toBe(true);
3434
expect(schemaObj.safeParse({ extraArgs: ['-quiet'] }).success).toBe(true);
35+
expect(schemaObj.safeParse({ launchArgs: ['--uitesting'] }).success).toBe(true);
3536
expect(schemaObj.safeParse({ env: { FOO: 'bar' } }).success).toBe(true);
3637
expect(schemaObj.safeParse({ platform: 'tvOS' }).success).toBe(true);
3738
expect(schemaObj.safeParse({ platform: 'tvOS Simulator' }).success).toBe(true);
@@ -40,8 +41,10 @@ describe('build_run_device tool', () => {
4041
expect(schemaObj.safeParse({ scheme: 'App' }).success).toBe(false);
4142
expect(schemaObj.safeParse({ deviceId: 'device-id' }).success).toBe(false);
4243

44+
expect(schemaObj.safeParse({ launchArgs: [123] }).success).toBe(false);
45+
4346
const schemaKeys = Object.keys(schema).sort();
44-
expect(schemaKeys).toEqual(['env', 'extraArgs', 'platform']);
47+
expect(schemaKeys).toEqual(['env', 'extraArgs', 'launchArgs', 'platform']);
4548
});
4649
});
4750

@@ -246,6 +249,68 @@ describe('build_run_device tool', () => {
246249
expect(text).not.toContain('Process ID');
247250
});
248251

252+
it('passes launchArgs only to launch command and keeps extraArgs on xcodebuild commands', async () => {
253+
const commandCalls: string[][] = [];
254+
const mockExecutor: CommandExecutor = async (command) => {
255+
commandCalls.push(command);
256+
257+
if (command.includes('-showBuildSettings')) {
258+
return createMockCommandResponse({
259+
success: true,
260+
output: 'BUILT_PRODUCTS_DIR = /tmp/build\nFULL_PRODUCT_NAME = MyWatchApp.app\n',
261+
});
262+
}
263+
264+
if (command[0] === 'defaults' || command[0] === '/usr/libexec/PlistBuddy') {
265+
return createMockCommandResponse({ success: true, output: 'io.sentry.MyWatchApp' });
266+
}
267+
268+
if (command.includes('launch')) {
269+
return createMockCommandResponse({
270+
success: true,
271+
output: JSON.stringify({ result: { process: { processIdentifier: 9876 } } }),
272+
});
273+
}
274+
275+
return createMockCommandResponse({ success: true, output: 'OK' });
276+
};
277+
278+
const { result } = await runBuildRunDeviceLogic(
279+
{
280+
projectPath: '/tmp/MyWatchApp.xcodeproj',
281+
scheme: 'MyWatchApp',
282+
platform: 'watchOS',
283+
deviceId: 'DEVICE-UDID',
284+
extraArgs: ['-quiet'],
285+
launchArgs: ['--uitesting', '--reset-state'],
286+
},
287+
mockExecutor,
288+
createMockFileSystemExecutor({ existsSync: () => true }),
289+
);
290+
291+
expectPendingBuildRunResponse(result, false);
292+
293+
const xcodebuildCommands = commandCalls.filter((command) => command[0] === 'xcodebuild');
294+
expect(xcodebuildCommands.length).toBeGreaterThan(0);
295+
for (const command of xcodebuildCommands) {
296+
expect(command).toContain('-quiet');
297+
expect(command).not.toContain('--uitesting');
298+
expect(command).not.toContain('--reset-state');
299+
}
300+
301+
const launchCommand = commandCalls.find(
302+
(command) =>
303+
command[0] === 'xcrun' &&
304+
command[1] === 'devicectl' &&
305+
command[2] === 'device' &&
306+
command[3] === 'process' &&
307+
command[4] === 'launch',
308+
);
309+
expect(launchCommand).toBeDefined();
310+
expect(launchCommand).toContain('--uitesting');
311+
expect(launchCommand).toContain('--reset-state');
312+
});
313+
249314
it('uses generic destination for build-settings lookup', async () => {
250315
const commandCalls: string[][] = [];
251316
const mockExecutor: CommandExecutor = async (command) => {

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ describe('launch_app_device plugin (device-shared)', () => {
2222
const schemaObj = z.strictObject(schema);
2323
expect(schemaObj.safeParse({}).success).toBe(true);
2424
expect(schemaObj.safeParse({ bundleId: 'io.sentry.app' }).success).toBe(false);
25-
expect(Object.keys(schema).sort()).toEqual(['env']);
25+
expect(schemaObj.safeParse({ launchArgs: ['--uitesting'] }).success).toBe(true);
26+
expect(schemaObj.safeParse({ args: ['--legacy'] }).success).toBe(false);
27+
expect(Object.keys(schema).sort()).toEqual(['env', 'launchArgs']);
2628
});
2729

2830
it('should validate schema with invalid inputs', () => {
@@ -124,6 +126,37 @@ describe('launch_app_device plugin (device-shared)', () => {
124126
expect(JSON.parse(cmd[envIdx + 1])).toEqual({ STAGING_ENABLED: '1', DEBUG: 'true' });
125127
});
126128

129+
it('should append launchArgs after bundleId when launchArgs is provided', async () => {
130+
const calls: any[] = [];
131+
const mockExecutor = createMockExecutor({
132+
success: true,
133+
output: 'App launched successfully',
134+
process: { pid: 12345 },
135+
});
136+
137+
const trackingExecutor = async (command: string[]) => {
138+
calls.push({ command });
139+
return mockExecutor(command);
140+
};
141+
142+
await runLogic(() =>
143+
launch_app_deviceLogic(
144+
{
145+
deviceId: 'test-device-123',
146+
bundleId: 'io.sentry.app',
147+
launchArgs: ['--uitesting', '--reset-state'],
148+
},
149+
trackingExecutor,
150+
createMockFileSystemExecutor(),
151+
),
152+
);
153+
154+
const cmd = calls[0].command;
155+
const bundleIdIndex = cmd.indexOf('io.sentry.app');
156+
expect(bundleIdIndex).toBeGreaterThan(-1);
157+
expect(cmd.slice(bundleIdIndex + 1)).toEqual(['--uitesting', '--reset-state']);
158+
});
159+
127160
it('should not include --environment-variables when env is not provided', async () => {
128161
const calls: any[] = [];
129162
const mockExecutor = createMockExecutor({

src/mcp/tools/device/build_run_device.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,14 @@ const baseSchemaObject = z.object({
5858
platform: devicePlatformSchema,
5959
configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'),
6060
derivedDataPath: z.string().optional(),
61-
extraArgs: z.array(z.string()).optional(),
61+
extraArgs: z
62+
.array(z.string())
63+
.optional()
64+
.describe('Additional xcodebuild/build-settings arguments (not app launch arguments)'),
65+
launchArgs: z
66+
.array(z.string())
67+
.optional()
68+
.describe('Arguments passed to the launched app process on physical device runtime'),
6269
preferXcodebuild: z.boolean().optional(),
6370
env: z
6471
.record(z.string(), z.string())
@@ -229,7 +236,7 @@ export function createBuildRunDeviceExecutor(
229236
bundleId,
230237
executor,
231238
fileSystemExecutor,
232-
{ env: params.env },
239+
{ env: params.env, args: params.launchArgs },
233240
);
234241
if (!launchResult.success) {
235242
const errorMessage = launchResult.error ?? 'Failed to launch app';

src/mcp/tools/device/launch_app_device.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ import {
3131
const launchAppDeviceSchema = z.object({
3232
deviceId: z.string().describe('UDID of the device (obtained from list_devices)'),
3333
bundleId: z.string(),
34+
launchArgs: z
35+
.array(z.string())
36+
.optional()
37+
.describe('Arguments passed to the launched app process on physical device runtime'),
3438
env: z
3539
.record(z.string(), z.string())
3640
.optional()
@@ -88,6 +92,7 @@ export function createLaunchAppDeviceExecutor(
8892
fileSystem,
8993
{
9094
env: params.env,
95+
args: params.launchArgs,
9196
},
9297
);
9398

src/mcp/tools/macos/__tests__/build_run_macos.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ describe('build_run_macos', () => {
3131

3232
expect(zodSchema.safeParse({}).success).toBe(true);
3333
expect(zodSchema.safeParse({ extraArgs: ['--verbose'] }).success).toBe(true);
34+
expect(zodSchema.safeParse({ launchArgs: ['--uitesting'] }).success).toBe(true);
3435

3536
expect(zodSchema.safeParse({ derivedDataPath: '/tmp/derived' }).success).toBe(false);
3637
expect(zodSchema.safeParse({ extraArgs: ['--ok', 2] }).success).toBe(false);
38+
expect(zodSchema.safeParse({ launchArgs: ['--ok', 2] }).success).toBe(false);
3739
expect(zodSchema.safeParse({ preferXcodebuild: true }).success).toBe(false);
3840

3941
const schemaKeys = Object.keys(schema).sort();
40-
expect(schemaKeys).toEqual(['extraArgs']);
42+
expect(schemaKeys).toEqual(['extraArgs', 'launchArgs']);
4143
});
4244
});
4345

@@ -398,6 +400,71 @@ describe('build_run_macos', () => {
398400
expect(result.nextStepParams).toBeUndefined();
399401
});
400402

403+
it('should pass launchArgs only to app launch and keep extraArgs on xcodebuild commands', async () => {
404+
let callCount = 0;
405+
const executorCalls: any[] = [];
406+
const mockExecutor = (
407+
command: string[],
408+
description?: string,
409+
logOutput?: boolean,
410+
opts?: { cwd?: string },
411+
detached?: boolean,
412+
) => {
413+
callCount++;
414+
executorCalls.push({ command, description, logOutput, opts });
415+
void detached;
416+
417+
if (callCount === 1) {
418+
return Promise.resolve({
419+
success: true,
420+
output: 'BUILD SUCCEEDED',
421+
error: '',
422+
process: mockProcess,
423+
});
424+
} else if (callCount === 2) {
425+
return Promise.resolve({
426+
success: true,
427+
output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app',
428+
error: '',
429+
process: mockProcess,
430+
});
431+
}
432+
return Promise.resolve({ success: true, output: '', error: '', process: mockProcess });
433+
};
434+
435+
const args = {
436+
projectPath: '/path/to/project.xcodeproj',
437+
scheme: 'MyApp',
438+
configuration: 'Debug',
439+
preferXcodebuild: false,
440+
extraArgs: ['-quiet'],
441+
launchArgs: ['--uitesting', '--reset-state'],
442+
};
443+
444+
await runBuildRunMacOSLogic(args, mockExecutor);
445+
446+
const xcodebuildCommands = executorCalls
447+
.map(({ command }) => command)
448+
.filter((command) => command[0] === 'xcodebuild');
449+
expect(xcodebuildCommands.length).toBeGreaterThan(0);
450+
for (const command of xcodebuildCommands) {
451+
expect(command).toContain('-quiet');
452+
expect(command).not.toContain('--uitesting');
453+
expect(command).not.toContain('--reset-state');
454+
}
455+
456+
const openCommand = executorCalls
457+
.map(({ command }) => command)
458+
.find((command) => command[0] === 'open');
459+
expect(openCommand).toEqual([
460+
'open',
461+
'/path/to/build/MyApp.app',
462+
'--args',
463+
'--uitesting',
464+
'--reset-state',
465+
]);
466+
});
467+
401468
it('should use default configuration when not provided', async () => {
402469
let callCount = 0;
403470
const executorCalls: any[] = [];

src/mcp/tools/macos/__tests__/launch_mac_app.test.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,22 @@ describe('launch_mac_app plugin', () => {
2323
expect(
2424
zodSchema.safeParse({
2525
appPath: '/Applications/Calculator.app',
26-
args: ['--debug'],
26+
launchArgs: ['--debug'],
2727
}).success,
2828
).toBe(true);
2929
expect(
3030
zodSchema.safeParse({
3131
appPath: '/path/to/MyApp.app',
32-
args: ['--debug', '--verbose'],
32+
launchArgs: ['--debug', '--verbose'],
3333
}).success,
3434
).toBe(true);
35+
const strictSchema = z.strictObject(schema);
36+
expect(
37+
strictSchema.safeParse({
38+
appPath: '/path/to/MyApp.app',
39+
args: ['--legacy'],
40+
}).success,
41+
).toBe(false);
3542
});
3643

3744
it('should validate schema with invalid inputs', () => {
@@ -40,7 +47,7 @@ describe('launch_mac_app plugin', () => {
4047
expect(zodSchema.safeParse({ appPath: null }).success).toBe(false);
4148
expect(zodSchema.safeParse({ appPath: 123 }).success).toBe(false);
4249
expect(
43-
zodSchema.safeParse({ appPath: '/path/to/MyApp.app', args: 'not-array' }).success,
50+
zodSchema.safeParse({ appPath: '/path/to/MyApp.app', launchArgs: 'not-array' }).success,
4451
).toBe(false);
4552
});
4653
});
@@ -93,7 +100,7 @@ describe('launch_mac_app plugin', () => {
93100
expect(calls[0].command).toEqual(['open', '/path/to/MyApp.app']);
94101
});
95102

96-
it('should generate correct command with args parameter', async () => {
103+
it('should generate correct command with launchArgs parameter', async () => {
97104
const calls: any[] = [];
98105
const mockExecutor = async (command: string[]) => {
99106
calls.push({ command });
@@ -108,7 +115,7 @@ describe('launch_mac_app plugin', () => {
108115
launch_mac_appLogic(
109116
{
110117
appPath: '/path/to/MyApp.app',
111-
args: ['--debug', '--verbose'],
118+
launchArgs: ['--debug', '--verbose'],
112119
},
113120
mockExecutor,
114121
mockFileSystem,
@@ -124,7 +131,7 @@ describe('launch_mac_app plugin', () => {
124131
]);
125132
});
126133

127-
it('should generate correct command with empty args array', async () => {
134+
it('should generate correct command with empty launchArgs array', async () => {
128135
const calls: any[] = [];
129136
const mockExecutor = async (command: string[]) => {
130137
calls.push({ command });
@@ -139,7 +146,7 @@ describe('launch_mac_app plugin', () => {
139146
launch_mac_appLogic(
140147
{
141148
appPath: '/path/to/MyApp.app',
142-
args: [],
149+
launchArgs: [],
143150
},
144151
mockExecutor,
145152
mockFileSystem,

src/mcp/tools/macos/build_run_macos.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,14 @@ const baseSchemaObject = z.object({
4949
.enum(['arm64', 'x86_64'])
5050
.optional()
5151
.describe('Architecture to build for (arm64 or x86_64). For macOS only.'),
52-
extraArgs: z.array(z.string()).optional(),
52+
extraArgs: z
53+
.array(z.string())
54+
.optional()
55+
.describe('Additional xcodebuild/build-settings arguments (not app launch arguments)'),
56+
launchArgs: z
57+
.array(z.string())
58+
.optional()
59+
.describe('Arguments passed to the launched app process on macOS runtime'),
5360
preferXcodebuild: z.boolean().optional(),
5461
});
5562

@@ -153,7 +160,7 @@ export function createBuildRunMacOSExecutor(
153160
status: 'started',
154161
});
155162

156-
const macLaunchResult = await launchMacApp(appPath, executor);
163+
const macLaunchResult = await launchMacApp(appPath, executor, { args: params.launchArgs });
157164
if (!macLaunchResult.success) {
158165
return createBuildRunDomainResult({
159166
started,

src/mcp/tools/macos/launch_mac_app.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import {
1616

1717
const launchMacAppSchema = z.object({
1818
appPath: z.string(),
19-
args: z.array(z.string()).optional(),
19+
launchArgs: z
20+
.array(z.string())
21+
.optional()
22+
.describe('Arguments passed to the launched app process on macOS runtime'),
2023
});
2124

2225
type LaunchMacAppParams = z.infer<typeof launchMacAppSchema>;
@@ -57,7 +60,7 @@ export function createLaunchMacAppExecutor(
5760
log('info', `Starting launch macOS app request for ${params.appPath}`);
5861

5962
try {
60-
const result = await launchMacApp(params.appPath, executor, { args: params.args });
63+
const result = await launchMacApp(params.appPath, executor, { args: params.launchArgs });
6164

6265
if (!result.success) {
6366
return buildLaunchFailure(

0 commit comments

Comments
 (0)