Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- 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.
- 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`.
- 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)).
- 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`.

Check warning on line 10 in CHANGELOG.md

View workflow job for this annotation

GitHub Actions / warden: xcodebuildmcp-docs-release-review

Breaking removal of `args` input on launch-only tools not recorded under a Breaking section

The PR title declares a breaking tool contract change (`feat(tools)!:`) and the entry itself states launch-only tools `no longer use generic args`, which removes a previously supported input field. The `[Unreleased]` section only contains `Added`/`Fixed`/`Changed`, while the prior `[2.5.0-beta.1]` release uses a `### Breaking` subsection for analogous tool-contract changes. Release notes generated from this section via `scripts/generate-github-release-notes.mjs` will not surface this as a breaking change, leaving users unaware that callers passing `args` to `launch_app_sim`/`launch_app_device`/`launch_mac_app` will break.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breaking removal of args input on launch-only tools not recorded under a Breaking section

The PR title declares a breaking tool contract change (feat(tools)!:) and the entry itself states launch-only tools no longer use generic args, which removes a previously supported input field. The [Unreleased] section only contains Added/Fixed/Changed, while the prior [2.5.0-beta.1] release uses a ### Breaking subsection for analogous tool-contract changes. Release notes generated from this section via scripts/generate-github-release-notes.mjs will not surface this as a breaking change, leaving users unaware that callers passing args to launch_app_sim/launch_app_device/launch_mac_app will break.

Verification

Read CHANGELOG.md lines 1-60 to confirm the [Unreleased] section lacks a ### Breaking subsection and compared against the [2.5.0-beta.1] section which uses ### Breaking for removed tool inputs. Cross-referenced the PR title feat(tools)!: (conventional-commit breaking marker) and the entry text stating launch-only tools no longer use generic args, which is a removal of a previously accepted input.

Identified by Warden xcodebuildmcp-docs-release-review · REY-VM5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix attempt detected (commit 402f423)

The commit added documentation about the breaking change (removal of generic args from launch-only tools) to the Added section at line 10, but failed to create a dedicated ### Breaking subsection in the [Unreleased] section as required to properly surface this as a breaking change in generated release notes.

The original issue appears unresolved. Please review and try again.

Evaluated by Warden


### Fixed

Expand Down
67 changes: 66 additions & 1 deletion src/mcp/tools/device/__tests__/build_run_device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe('build_run_device tool', () => {

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

expect(schemaObj.safeParse({ launchArgs: [123] }).success).toBe(false);

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

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

it('passes launchArgs only to launch command and keeps extraArgs on xcodebuild commands', 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] === 'defaults' || command[0] === '/usr/libexec/PlistBuddy') {
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 runBuildRunDeviceLogic(
{
projectPath: '/tmp/MyWatchApp.xcodeproj',
scheme: 'MyWatchApp',
platform: 'watchOS',
deviceId: 'DEVICE-UDID',
extraArgs: ['-quiet'],
launchArgs: ['--uitesting', '--reset-state'],
},
mockExecutor,
createMockFileSystemExecutor({ existsSync: () => true }),
);

expectPendingBuildRunResponse(result, false);

const xcodebuildCommands = commandCalls.filter((command) => command[0] === 'xcodebuild');
expect(xcodebuildCommands.length).toBeGreaterThan(0);
for (const command of xcodebuildCommands) {
expect(command).toContain('-quiet');
expect(command).not.toContain('--uitesting');
expect(command).not.toContain('--reset-state');
}

const launchCommand = commandCalls.find(
(command) =>
command[0] === 'xcrun' &&
command[1] === 'devicectl' &&
command[2] === 'device' &&
command[3] === 'process' &&
command[4] === 'launch',
);
expect(launchCommand).toBeDefined();
expect(launchCommand).toContain('--uitesting');
expect(launchCommand).toContain('--reset-state');
});

it('uses generic destination for build-settings lookup', async () => {
const commandCalls: string[][] = [];
const mockExecutor: CommandExecutor = async (command) => {
Expand Down
35 changes: 34 additions & 1 deletion src/mcp/tools/device/__tests__/launch_app_device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ describe('launch_app_device plugin (device-shared)', () => {
const schemaObj = z.strictObject(schema);
expect(schemaObj.safeParse({}).success).toBe(true);
expect(schemaObj.safeParse({ bundleId: 'io.sentry.app' }).success).toBe(false);
expect(Object.keys(schema).sort()).toEqual(['env']);
expect(schemaObj.safeParse({ launchArgs: ['--uitesting'] }).success).toBe(true);
expect(schemaObj.safeParse({ args: ['--legacy'] }).success).toBe(false);
expect(Object.keys(schema).sort()).toEqual(['env', 'launchArgs']);
});

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

it('should append launchArgs after bundleId when launchArgs is provided', async () => {
const calls: any[] = [];
const mockExecutor = createMockExecutor({
success: true,
output: 'App launched successfully',
process: { pid: 12345 },
});

const trackingExecutor = async (command: string[]) => {
calls.push({ command });
return mockExecutor(command);
};

await runLogic(() =>
launch_app_deviceLogic(
{
deviceId: 'test-device-123',
bundleId: 'io.sentry.app',
launchArgs: ['--uitesting', '--reset-state'],
},
trackingExecutor,
createMockFileSystemExecutor(),
),
);

const cmd = calls[0].command;
const bundleIdIndex = cmd.indexOf('io.sentry.app');
expect(bundleIdIndex).toBeGreaterThan(-1);
expect(cmd.slice(bundleIdIndex + 1)).toEqual(['--uitesting', '--reset-state']);
});

it('should not include --environment-variables when env is not provided', async () => {
const calls: any[] = [];
const mockExecutor = createMockExecutor({
Expand Down
11 changes: 9 additions & 2 deletions src/mcp/tools/device/build_run_device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@ const baseSchemaObject = z.object({
platform: devicePlatformSchema,
configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'),
derivedDataPath: z.string().optional(),
extraArgs: z.array(z.string()).optional(),
extraArgs: z
.array(z.string())
.optional()
.describe('Additional xcodebuild/build-settings arguments (not app launch arguments)'),
launchArgs: z
.array(z.string())
.optional()
.describe('Arguments passed to the launched app process on physical device runtime'),
preferXcodebuild: z.boolean().optional(),
env: z
.record(z.string(), z.string())
Expand Down Expand Up @@ -229,7 +236,7 @@ export function createBuildRunDeviceExecutor(
bundleId,
executor,
fileSystemExecutor,
{ env: params.env },
{ env: params.env, args: params.launchArgs },
);
if (!launchResult.success) {
const errorMessage = launchResult.error ?? 'Failed to launch app';
Expand Down
5 changes: 5 additions & 0 deletions src/mcp/tools/device/launch_app_device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import {
const launchAppDeviceSchema = z.object({
deviceId: z.string().describe('UDID of the device (obtained from list_devices)'),
bundleId: z.string(),
launchArgs: z
.array(z.string())
.optional()
.describe('Arguments passed to the launched app process on physical device runtime'),
env: z
.record(z.string(), z.string())
.optional()
Expand Down Expand Up @@ -88,6 +92,7 @@ export function createLaunchAppDeviceExecutor(
fileSystem,
{
env: params.env,
args: params.launchArgs,
},
);

Expand Down
69 changes: 68 additions & 1 deletion src/mcp/tools/macos/__tests__/build_run_macos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ describe('build_run_macos', () => {

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

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

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

Expand Down Expand Up @@ -398,6 +400,71 @@ describe('build_run_macos', () => {
expect(result.nextStepParams).toBeUndefined();
});

it('should pass launchArgs only to app launch and keep extraArgs on xcodebuild commands', async () => {
let callCount = 0;
const executorCalls: any[] = [];
const mockExecutor = (
command: string[],
description?: string,
logOutput?: boolean,
opts?: { cwd?: string },
detached?: boolean,
) => {
callCount++;
executorCalls.push({ command, description, logOutput, opts });
void detached;

if (callCount === 1) {
return Promise.resolve({
success: true,
output: 'BUILD SUCCEEDED',
error: '',
process: mockProcess,
});
} else if (callCount === 2) {
return Promise.resolve({
success: true,
output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app',
error: '',
process: mockProcess,
});
}
return Promise.resolve({ success: true, output: '', error: '', process: mockProcess });
};

const args = {
projectPath: '/path/to/project.xcodeproj',
scheme: 'MyApp',
configuration: 'Debug',
preferXcodebuild: false,
extraArgs: ['-quiet'],
launchArgs: ['--uitesting', '--reset-state'],
};

await runBuildRunMacOSLogic(args, mockExecutor);

const xcodebuildCommands = executorCalls
.map(({ command }) => command)
.filter((command) => command[0] === 'xcodebuild');
expect(xcodebuildCommands.length).toBeGreaterThan(0);
for (const command of xcodebuildCommands) {
expect(command).toContain('-quiet');
expect(command).not.toContain('--uitesting');
expect(command).not.toContain('--reset-state');
}

const openCommand = executorCalls
.map(({ command }) => command)
.find((command) => command[0] === 'open');
expect(openCommand).toEqual([
'open',
'/path/to/build/MyApp.app',
'--args',
'--uitesting',
'--reset-state',
]);
});

it('should use default configuration when not provided', async () => {
let callCount = 0;
const executorCalls: any[] = [];
Expand Down
21 changes: 14 additions & 7 deletions src/mcp/tools/macos/__tests__/launch_mac_app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,22 @@ describe('launch_mac_app plugin', () => {
expect(
zodSchema.safeParse({
appPath: '/Applications/Calculator.app',
args: ['--debug'],
launchArgs: ['--debug'],
}).success,
).toBe(true);
expect(
zodSchema.safeParse({
appPath: '/path/to/MyApp.app',
args: ['--debug', '--verbose'],
launchArgs: ['--debug', '--verbose'],
}).success,
).toBe(true);
const strictSchema = z.strictObject(schema);
expect(
strictSchema.safeParse({
appPath: '/path/to/MyApp.app',
args: ['--legacy'],
}).success,
).toBe(false);
});

it('should validate schema with invalid inputs', () => {
Expand All @@ -40,7 +47,7 @@ describe('launch_mac_app plugin', () => {
expect(zodSchema.safeParse({ appPath: null }).success).toBe(false);
expect(zodSchema.safeParse({ appPath: 123 }).success).toBe(false);
expect(
zodSchema.safeParse({ appPath: '/path/to/MyApp.app', args: 'not-array' }).success,
zodSchema.safeParse({ appPath: '/path/to/MyApp.app', launchArgs: 'not-array' }).success,
).toBe(false);
});
});
Expand Down Expand Up @@ -93,7 +100,7 @@ describe('launch_mac_app plugin', () => {
expect(calls[0].command).toEqual(['open', '/path/to/MyApp.app']);
});

it('should generate correct command with args parameter', async () => {
it('should generate correct command with launchArgs parameter', async () => {
const calls: any[] = [];
const mockExecutor = async (command: string[]) => {
calls.push({ command });
Expand All @@ -108,7 +115,7 @@ describe('launch_mac_app plugin', () => {
launch_mac_appLogic(
{
appPath: '/path/to/MyApp.app',
args: ['--debug', '--verbose'],
launchArgs: ['--debug', '--verbose'],
},
mockExecutor,
mockFileSystem,
Expand All @@ -124,7 +131,7 @@ describe('launch_mac_app plugin', () => {
]);
});

it('should generate correct command with empty args array', async () => {
it('should generate correct command with empty launchArgs array', async () => {
const calls: any[] = [];
const mockExecutor = async (command: string[]) => {
calls.push({ command });
Expand All @@ -139,7 +146,7 @@ describe('launch_mac_app plugin', () => {
launch_mac_appLogic(
{
appPath: '/path/to/MyApp.app',
args: [],
launchArgs: [],
},
mockExecutor,
mockFileSystem,
Expand Down
11 changes: 9 additions & 2 deletions src/mcp/tools/macos/build_run_macos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,14 @@ const baseSchemaObject = z.object({
.enum(['arm64', 'x86_64'])
.optional()
.describe('Architecture to build for (arm64 or x86_64). For macOS only.'),
extraArgs: z.array(z.string()).optional(),
extraArgs: z
.array(z.string())
.optional()
.describe('Additional xcodebuild/build-settings arguments (not app launch arguments)'),
launchArgs: z
.array(z.string())
.optional()
.describe('Arguments passed to the launched app process on macOS runtime'),
preferXcodebuild: z.boolean().optional(),
});

Expand Down Expand Up @@ -153,7 +160,7 @@ export function createBuildRunMacOSExecutor(
status: 'started',
});

const macLaunchResult = await launchMacApp(appPath, executor);
const macLaunchResult = await launchMacApp(appPath, executor, { args: params.launchArgs });
if (!macLaunchResult.success) {
return createBuildRunDomainResult({
started,
Expand Down
7 changes: 5 additions & 2 deletions src/mcp/tools/macos/launch_mac_app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {

const launchMacAppSchema = z.object({
appPath: z.string(),
args: z.array(z.string()).optional(),
launchArgs: z
.array(z.string())
.optional()
.describe('Arguments passed to the launched app process on macOS runtime'),
});

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

try {
const result = await launchMacApp(params.appPath, executor, { args: params.args });
const result = await launchMacApp(params.appPath, executor, { args: params.launchArgs });

if (!result.success) {
return buildLaunchFailure(
Expand Down
Loading
Loading