Skip to content

Commit 9c58546

Browse files
committed
Add env parameter to XcodeBuildMCP launch tools for passing environment variables
1 parent 1688c83 commit 9c58546

9 files changed

Lines changed: 312 additions & 18 deletions

File tree

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

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ describe('launch_app_device plugin (device-shared)', () => {
3838
const schema = z.strictObject(launchAppDevice.schema);
3939
expect(schema.safeParse({}).success).toBe(true);
4040
expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(false);
41-
expect(Object.keys(launchAppDevice.schema)).toEqual([]);
41+
expect(Object.keys(launchAppDevice.schema).sort()).toEqual(['env']);
4242
});
4343

4444
it('should validate schema with invalid inputs', () => {
@@ -142,6 +142,67 @@ describe('launch_app_device plugin (device-shared)', () => {
142142
'com.apple.mobilesafari',
143143
]);
144144
});
145+
146+
it('should append --environment-variables flags before bundleId when env is provided', async () => {
147+
const calls: any[] = [];
148+
const mockExecutor = createMockExecutor({
149+
success: true,
150+
output: 'App launched successfully',
151+
process: { pid: 12345 },
152+
});
153+
154+
const trackingExecutor = async (command: string[]) => {
155+
calls.push({ command });
156+
return mockExecutor(command);
157+
};
158+
159+
await launch_app_deviceLogic(
160+
{
161+
deviceId: 'test-device-123',
162+
bundleId: 'com.example.app',
163+
env: { STAGING_ENABLED: '1', DEBUG: 'true' },
164+
},
165+
trackingExecutor,
166+
createMockFileSystemExecutor(),
167+
);
168+
169+
expect(calls).toHaveLength(1);
170+
const cmd = calls[0].command;
171+
// bundleId should be the last element
172+
expect(cmd[cmd.length - 1]).toBe('com.example.app');
173+
// --environment-variables flags should appear before bundleId
174+
const envIdx1 = cmd.indexOf('--environment-variables');
175+
expect(envIdx1).toBeGreaterThan(-1);
176+
expect(cmd[envIdx1 + 1]).toBe('STAGING_ENABLED=1');
177+
const envIdx2 = cmd.indexOf('--environment-variables', envIdx1 + 1);
178+
expect(envIdx2).toBeGreaterThan(-1);
179+
expect(cmd[envIdx2 + 1]).toBe('DEBUG=true');
180+
});
181+
182+
it('should not include --environment-variables when env is not provided', async () => {
183+
const calls: any[] = [];
184+
const mockExecutor = createMockExecutor({
185+
success: true,
186+
output: 'App launched successfully',
187+
process: { pid: 12345 },
188+
});
189+
190+
const trackingExecutor = async (command: string[]) => {
191+
calls.push({ command });
192+
return mockExecutor(command);
193+
};
194+
195+
await launch_app_deviceLogic(
196+
{
197+
deviceId: 'test-device-123',
198+
bundleId: 'com.example.app',
199+
},
200+
trackingExecutor,
201+
createMockFileSystemExecutor(),
202+
);
203+
204+
expect(calls[0].command).not.toContain('--environment-variables');
205+
});
145206
});
146207

147208
describe('Success Path Tests', () => {

src/mcp/tools/device/launch_app_device.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ type LaunchDataResponse = {
3232
const launchAppDeviceSchema = z.object({
3333
deviceId: z.string().describe('UDID of the device (obtained from list_devices)'),
3434
bundleId: z.string(),
35+
env: z
36+
.record(z.string(), z.string())
37+
.optional()
38+
.describe('Environment variables to pass to the launched app (as KEY=VALUE pairs)'),
3539
});
3640

3741
const publicSchemaObject = launchAppDeviceSchema.omit({
@@ -55,20 +59,29 @@ export async function launch_app_deviceLogic(
5559
// Use JSON output to capture process ID
5660
const tempJsonPath = join(fileSystem.tmpdir(), `launch-${Date.now()}.json`);
5761

62+
const command = [
63+
'xcrun',
64+
'devicectl',
65+
'device',
66+
'process',
67+
'launch',
68+
'--device',
69+
deviceId,
70+
'--json-output',
71+
tempJsonPath,
72+
'--terminate-existing',
73+
];
74+
75+
if (params.env) {
76+
for (const [key, value] of Object.entries(params.env)) {
77+
command.push('--environment-variables', `${key}=${value}`);
78+
}
79+
}
80+
81+
command.push(bundleId);
82+
5883
const result = await executor(
59-
[
60-
'xcrun',
61-
'devicectl',
62-
'device',
63-
'process',
64-
'launch',
65-
'--device',
66-
deviceId,
67-
'--json-output',
68-
tempJsonPath,
69-
'--terminate-existing',
70-
bundleId,
71-
],
84+
command,
7285
'Launch app on device',
7386
false, // useShell
7487
undefined, // env

src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe('launch_app_logs_sim tool', () => {
3131
expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true);
3232
expect(schema.safeParse({ bundleId: 42 }).success).toBe(true);
3333

34-
expect(Object.keys(launchAppLogsSim.schema).sort()).toEqual(['args']);
34+
expect(Object.keys(launchAppLogsSim.schema).sort()).toEqual(['args', 'env']);
3535

3636
const withSimId = schema.safeParse({
3737
simulatorId: 'abc123',
@@ -143,6 +143,68 @@ describe('launch_app_logs_sim tool', () => {
143143
});
144144
});
145145

146+
it('should pass env vars through to log capture function', async () => {
147+
let capturedParams: unknown = null;
148+
const logCaptureStub: LogCaptureFunction = async (params) => {
149+
capturedParams = params;
150+
return {
151+
sessionId: 'test-session-789',
152+
logFilePath: '/tmp/xcodemcp_sim_log_test-session-789.log',
153+
processes: [],
154+
error: undefined,
155+
};
156+
};
157+
158+
const mockExecutor = createMockExecutor({ success: true, output: '' });
159+
160+
await launch_app_logs_simLogic(
161+
{
162+
simulatorId: 'test-uuid-123',
163+
bundleId: 'com.example.testapp',
164+
env: { STAGING_ENABLED: '1' },
165+
},
166+
mockExecutor,
167+
logCaptureStub,
168+
);
169+
170+
expect(capturedParams).toEqual({
171+
simulatorUuid: 'test-uuid-123',
172+
bundleId: 'com.example.testapp',
173+
captureConsole: true,
174+
env: { STAGING_ENABLED: '1' },
175+
});
176+
});
177+
178+
it('should not include env in capture params when env is undefined', async () => {
179+
let capturedParams: unknown = null;
180+
const logCaptureStub: LogCaptureFunction = async (params) => {
181+
capturedParams = params;
182+
return {
183+
sessionId: 'test-session-101',
184+
logFilePath: '/tmp/xcodemcp_sim_log_test-session-101.log',
185+
processes: [],
186+
error: undefined,
187+
};
188+
};
189+
190+
const mockExecutor = createMockExecutor({ success: true, output: '' });
191+
192+
await launch_app_logs_simLogic(
193+
{
194+
simulatorId: 'test-uuid-123',
195+
bundleId: 'com.example.testapp',
196+
},
197+
mockExecutor,
198+
logCaptureStub,
199+
);
200+
201+
expect(capturedParams).toEqual({
202+
simulatorUuid: 'test-uuid-123',
203+
bundleId: 'com.example.testapp',
204+
captureConsole: true,
205+
});
206+
});
207+
146208
it('should surface log capture failure', async () => {
147209
const logCaptureStub: LogCaptureFunction = async () => ({
148210
sessionId: '',

src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe('launch_app_sim tool', () => {
2929
expect(schema.safeParse({ bundleId: 'com.example.testapp' }).success).toBe(false);
3030
expect(schema.safeParse({ bundleId: 123 }).success).toBe(false);
3131

32-
expect(Object.keys(launchAppSim.schema).sort()).toEqual(['args']);
32+
expect(Object.keys(launchAppSim.schema).sort()).toEqual(['args', 'env']);
3333

3434
const withSimDefaults = schema.safeParse({
3535
simulatorId: 'sim-default',
@@ -422,6 +422,92 @@ describe('launch_app_sim tool', () => {
422422
});
423423
});
424424

425+
it('should pass env vars with SIMCTL_CHILD_ prefix to executor opts', async () => {
426+
let callCount = 0;
427+
const capturedOpts: (Record<string, unknown> | undefined)[] = [];
428+
429+
const sequencedExecutor = async (
430+
command: string[],
431+
_logPrefix?: string,
432+
_useShell?: boolean,
433+
opts?: { env?: Record<string, string> },
434+
) => {
435+
callCount++;
436+
capturedOpts.push(opts);
437+
if (callCount === 1) {
438+
return {
439+
success: true,
440+
output: '/path/to/app/container',
441+
error: '',
442+
process: {} as any,
443+
};
444+
}
445+
return {
446+
success: true,
447+
output: 'App launched successfully',
448+
error: '',
449+
process: {} as any,
450+
};
451+
};
452+
453+
await launch_app_simLogic(
454+
{
455+
simulatorId: 'test-uuid-123',
456+
bundleId: 'com.example.testapp',
457+
env: { STAGING_ENABLED: '1', DEBUG: 'true' },
458+
},
459+
sequencedExecutor,
460+
);
461+
462+
// First call is get_app_container (no env), second is launch (with env)
463+
expect(capturedOpts[1]).toEqual({
464+
env: {
465+
SIMCTL_CHILD_STAGING_ENABLED: '1',
466+
SIMCTL_CHILD_DEBUG: 'true',
467+
},
468+
});
469+
});
470+
471+
it('should not pass env opts when env is undefined', async () => {
472+
let callCount = 0;
473+
const capturedOpts: (Record<string, unknown> | undefined)[] = [];
474+
475+
const sequencedExecutor = async (
476+
command: string[],
477+
_logPrefix?: string,
478+
_useShell?: boolean,
479+
opts?: { env?: Record<string, string> },
480+
) => {
481+
callCount++;
482+
capturedOpts.push(opts);
483+
if (callCount === 1) {
484+
return {
485+
success: true,
486+
output: '/path/to/app/container',
487+
error: '',
488+
process: {} as any,
489+
};
490+
}
491+
return {
492+
success: true,
493+
output: 'App launched successfully',
494+
error: '',
495+
process: {} as any,
496+
};
497+
};
498+
499+
await launch_app_simLogic(
500+
{
501+
simulatorId: 'test-uuid-123',
502+
bundleId: 'com.example.testapp',
503+
},
504+
sequencedExecutor,
505+
);
506+
507+
// Launch call opts should be undefined when no env provided
508+
expect(capturedOpts[1]).toBeUndefined();
509+
});
510+
425511
it('should return error when simctl list fails', async () => {
426512
const mockExecutor = createMockExecutor({
427513
success: false,

src/mcp/tools/simulator/launch_app_logs_sim.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type LogCaptureFunction = (
1515
bundleId: string;
1616
captureConsole?: boolean;
1717
args?: string[];
18+
env?: Record<string, string>;
1819
},
1920
executor: CommandExecutor,
2021
) => Promise<{ sessionId: string; logFilePath: string; processes: unknown[]; error?: string }>;
@@ -23,6 +24,12 @@ const launchAppLogsSimSchemaObject = z.object({
2324
simulatorId: z.string().describe('UUID of the simulator to use (obtained from list_sims)'),
2425
bundleId: z.string(),
2526
args: z.array(z.string()).optional(),
27+
env: z
28+
.record(z.string(), z.string())
29+
.optional()
30+
.describe(
31+
'Environment variables to pass to the launched app (SIMCTL_CHILD_ prefix added automatically)',
32+
),
2633
});
2734

2835
type LaunchAppLogsSimParams = z.infer<typeof launchAppLogsSimSchemaObject>;
@@ -46,6 +53,7 @@ export async function launch_app_logs_simLogic(
4653
bundleId: params.bundleId,
4754
captureConsole: true,
4855
...(params.args && params.args.length > 0 ? { args: params.args } : {}),
56+
...(params.env ? { env: params.env } : {}),
4957
} as const;
5058

5159
const { sessionId, error } = await logCaptureFunction(captureParams, executor);

src/mcp/tools/simulator/launch_app_sim.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createSessionAwareTool,
99
getSessionAwareToolSchemaShape,
1010
} from '../../../utils/typed-tool-factory.ts';
11+
import { normalizeSimctlChildEnv } from '../../../utils/environment.ts';
1112

1213
const baseSchemaObject = z.object({
1314
simulatorId: z
@@ -24,6 +25,12 @@ const baseSchemaObject = z.object({
2425
),
2526
bundleId: z.string(),
2627
args: z.array(z.string()).optional(),
28+
env: z
29+
.record(z.string(), z.string())
30+
.optional()
31+
.describe(
32+
'Environment variables to pass to the launched app (SIMCTL_CHILD_ prefix added automatically)',
33+
),
2734
});
2835

2936
const launchAppSimSchema = z.preprocess(
@@ -154,7 +161,8 @@ export async function launch_app_simLogic(
154161
command.push(...params.args);
155162
}
156163

157-
const result = await executor(command, 'Launch App in Simulator', false, undefined);
164+
const execOpts = params.env ? { env: normalizeSimctlChildEnv(params.env) } : undefined;
165+
const result = await executor(command, 'Launch App in Simulator', false, execOpts);
158166

159167
if (!result.success) {
160168
return {

0 commit comments

Comments
 (0)