Skip to content
Draft
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 @@ -18,6 +18,7 @@
- Fixed `suppressWarnings` being ignored in settled build, build-run, and test output. The flag was honored only while streaming, so warnings still reached the final MCP tool response ([#447](https://github.com/getsentry/XcodeBuildMCP/issues/447)).
- Fixed iOS scaffold orientation and device-family settings, LLDB command isolation and argument escaping, run-destination parsing without an active scheme, concurrent working-directory mutations, blocking physical-device name lookup, and unverified `xcodemake` downloads ([#459](https://github.com/getsentry/XcodeBuildMCP/issues/459)).
- Fixed simulator UI launching and keyboard controls to prefer Xcode 27's Device Hub when available, with Simulator.app as the legacy fallback.
- Fixed `stop_mac_app` app-name targeting so it no longer terminates unrelated processes whose command lines contain the app name, and reject unsafe process IDs before execution ([#306](https://github.com/getsentry/XcodeBuildMCP/issues/306)).

## [2.6.2]

Expand Down
106 changes: 79 additions & 27 deletions src/mcp/tools/macos/__tests__/stop_mac_app.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { schema, handler, stop_mac_appLogic } from '../stop_mac_app.ts';
import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts';

describe('stop_mac_app plugin', () => {
describe('Export Field Validation (Literal)', () => {
Expand All @@ -17,28 +18,46 @@ describe('stop_mac_app plugin', () => {

// Test invalid inputs
expect(schema.appName.safeParse(null).success).toBe(false);
expect(schema.appName.safeParse('').success).toBe(false);
expect(schema.processId.safeParse('not-number').success).toBe(false);
expect(schema.processId.safeParse(null).success).toBe(false);
expect(schema.processId.safeParse(0).success).toBe(false);
expect(schema.processId.safeParse(-1).success).toBe(false);
expect(schema.processId.safeParse(1.5).success).toBe(false);
expect(schema.processId.safeParse(Number.NaN).success).toBe(false);
expect(schema.processId.safeParse(Number.MAX_SAFE_INTEGER + 1).success).toBe(false);
});
});

describe('Input Validation', () => {
it('should return exact validation error for missing parameters', async () => {
const mockExecutor = async () => ({ success: true, output: '', process: {} as any });
const result = await runLogic(() => stop_mac_appLogic({}, mockExecutor));
const result = await runLogic(() => stop_mac_appLogic({}, createNoopExecutor()));

expect(result.isError).toBe(true);
expect(allText(result)).toContain('appName or processId');
});

it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
'should reject unsafe process ID %s at the execution boundary',
async (processId) => {
const calls: string[][] = [];
const executor = createMockExecutor({ onExecute: (command) => calls.push(command) });

const result = await runLogic(() => stop_mac_appLogic({ processId }, executor));

expect(result.isError).toBe(true);
expect(allText(result)).toContain('processId must be a positive safe integer');
expect(calls).toHaveLength(0);
},
);
});

describe('Command Generation', () => {
it('should generate correct command for process ID', async () => {
const calls: any[] = [];
const mockExecutor = async (command: string[]) => {
calls.push({ command });
return { success: true, output: '', process: {} as any };
};
const calls: string[][] = [];
const mockExecutor = createMockExecutor({
onExecute: (command) => calls.push(command),
});

await runLogic(() =>
stop_mac_appLogic(
Expand All @@ -50,35 +69,70 @@ describe('stop_mac_app plugin', () => {
);

expect(calls).toHaveLength(1);
expect(calls[0].command).toEqual(['kill', '1234']);
expect(calls[0]).toEqual(['kill', '1234']);
});

it('should generate correct command for app name', async () => {
const calls: any[] = [];
const mockExecutor = async (command: string[]) => {
calls.push({ command });
return { success: true, output: '', process: {} as any };
};
it('should avoid matching app names in unrelated process arguments', async () => {
const calls: string[][] = [];
const mockExecutor = createMockExecutor({
onExecute: (command) => calls.push(command),
});

await runLogic(() =>
stop_mac_appLogic(
{
appName: 'Calculator',
appName: 'Brimday',
},
mockExecutor,
),
);

expect(calls).toHaveLength(1);
expect(calls[0].command).toEqual(['pkill', '-f', 'Calculator']);
expect(calls[0]).toEqual(['pkill', '-x', '--', 'Brimday']);
expect(calls[0]).not.toContain('-f');
});

it('should preserve long app executable names', async () => {
const calls: string[][] = [];
const mockExecutor = createMockExecutor({
onExecute: (command) => calls.push(command),
});

await runLogic(() =>
stop_mac_appLogic(
{
appName: 'ThisIsAVeryLongApplicationName',
},
mockExecutor,
),
);

expect(calls[0]).toEqual(['pkill', '-x', '--', 'ThisIsAVeryLongApplicationName']);
});

it('should treat app names as literal process names', async () => {
const calls: string[][] = [];
const mockExecutor = createMockExecutor({
onExecute: (command) => calls.push(command),
});

await runLogic(() =>
stop_mac_appLogic(
{
appName: '-Example.*[Test]',
},
mockExecutor,
),
);

expect(calls[0]).toEqual(['pkill', '-x', '--', '-Example\\.\\*\\[Test\\]']);
});

it('should prioritize processId over appName', async () => {
const calls: any[] = [];
const mockExecutor = async (command: string[]) => {
calls.push({ command });
return { success: true, output: '', process: {} as any };
};
const calls: string[][] = [];
const mockExecutor = createMockExecutor({
onExecute: (command) => calls.push(command),
});

await runLogic(() =>
stop_mac_appLogic(
Expand All @@ -91,13 +145,13 @@ describe('stop_mac_app plugin', () => {
);

expect(calls).toHaveLength(1);
expect(calls[0].command).toEqual(['kill', '1234']);
expect(calls[0]).toEqual(['kill', '1234']);
});
});

describe('Response Processing', () => {
it('should return exact successful stop response by app name', async () => {
const mockExecutor = async () => ({ success: true, output: '', process: {} as any });
const mockExecutor = createMockExecutor({});

const result = await runLogic(() =>
stop_mac_appLogic(
Expand All @@ -112,7 +166,7 @@ describe('stop_mac_app plugin', () => {
});

it('should return exact successful stop response with both parameters (processId takes precedence)', async () => {
const mockExecutor = async () => ({ success: true, output: '', process: {} as any });
const mockExecutor = createMockExecutor({});

const result = await runLogic(() =>
stop_mac_appLogic(
Expand All @@ -128,9 +182,7 @@ describe('stop_mac_app plugin', () => {
});

it('should handle execution errors', async () => {
const mockExecutor = async () => {
throw new Error('Process not found');
};
const mockExecutor = createMockExecutor(new Error('Process not found'));

const result = await runLogic(() =>
stop_mac_appLogic(
Expand Down
24 changes: 20 additions & 4 deletions src/mcp/tools/macos/stop_mac_app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,22 @@ import {
} from '../../../utils/app-lifecycle-results.ts';

const stopMacAppSchema = z.object({
appName: z.string().optional(),
processId: z.number().optional(),
appName: z.string().min(1).optional(),
processId: z
.number()
.int()
.positive()
.refine(Number.isSafeInteger, 'processId must be a positive safe integer.')
.optional(),
});
Comment thread
cameroncooke marked this conversation as resolved.

type StopMacAppParams = z.infer<typeof stopMacAppSchema>;
type StopMacAppResult = StopResultDomainResult;

function escapeExtendedRegularExpression(value: string): string {
return value.replace(/[\\.^$*+?()[\]{}|]/g, '\\$&');
}

export async function stop_mac_appLogic(
params: StopMacAppParams,
executor: CommandExecutor,
Expand Down Expand Up @@ -60,14 +69,21 @@ export function createStopMacAppExecutor(
return buildStopFailure(artifacts, 'Either appName or processId must be provided.');
}

const target = params.processId ? `PID ${params.processId}` : params.appName!;
if (
params.processId !== undefined &&
(!Number.isSafeInteger(params.processId) || params.processId <= 0)
) {
return buildStopFailure(artifacts, 'processId must be a positive safe integer.');
}

const target = params.processId !== undefined ? `PID ${params.processId}` : params.appName!;
log('info', `Stopping macOS app: ${target}`);

try {
const command =
params.processId !== undefined
? ['kill', String(params.processId)]
: ['pkill', '-f', params.appName!];
: ['pkill', '-x', '--', escapeExtendedRegularExpression(params.appName!)];
const result = await executor(command, 'Stop macOS App');

if (!result.success) {
Expand Down
Loading