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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Changed

- Dictionary-shaped MCP inputs now use client-compatible wire representations ([#491](https://github.com/getsentry/XcodeBuildMCP/issues/491)). The `env` and `testRunnerEnv` inputs on build, launch, test, and session-default tools are arrays of `{ "key": "...", "value": "..." }` entries, while `xcode_ide_call_tool.arguments` is a JSON object string. XcodeBuildMCP converts these values to their existing internal objects only after MCP input validation.

## [2.7.0]

### New! Xcode 27 Device Hub simulator support
Expand Down Expand Up @@ -749,4 +755,3 @@ Please note that the UI automation features are an early preview and currently i
## [v1.0.1] - 2025-04-02
- Initial release of XcodeBuildMCP
- Basic support for building iOS and macOS applications

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"test": "vitest run",
"posttest": "npm run test:warden-watchdog",
"test:warden-watchdog": "node --test scripts/__tests__/warden-watchdog.test.mjs",
"test:schema-fixtures": "vitest run src/snapshot-tests/__tests__/json-fixture-schema.test.ts",
"test:schema-fixtures": "npm run build && vitest run --config vitest.schema.config.ts",
"test:schema-fixtures:update": "UPDATE_SNAPSHOTS=1 npm run test:schema-fixtures",
"test:snapshot": "npm run build && vitest run --config vitest.snapshot.config.ts",
"test:snapshots": "npm run test:snapshot",
"test:snapshot:device": "npm run build && vitest run --config vitest.snapshot.config.ts src/snapshot-tests/__tests__/device.snapshot.test.ts && vitest run --config vitest.snapshot.config.ts src/snapshot-tests/__tests__/cli-json.snapshot.test.ts src/snapshot-tests/__tests__/mcp-json.snapshot.test.ts -t 'device workflow'",
Expand Down
4 changes: 3 additions & 1 deletion src/core/manifest/import-tool-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getPackageRoot } from './load-manifest.ts';

export interface ImportedToolModule {
schema: ToolSchemaShape;
mcpSchema: ToolSchemaShape;
handler: (params: Record<string, unknown>, ctx?: ToolHandlerContext) => Promise<unknown>;
}

Expand All @@ -19,7 +20,7 @@ const moduleCache = new Map<string, ImportedToolModule>();
/**
* Import a tool module by its manifest module path.
*
* Accepts named exports only: `export const schema = ...` and `export const handler = ...`
* Accepts named exports only: `schema`, optional MCP-specific `mcpSchema`, and `handler`.
*
* @param moduleId - Extensionless module path (e.g., 'mcp/tools/simulator/build_sim')
* @returns Imported tool module with schema and handler
Expand Down Expand Up @@ -50,6 +51,7 @@ export async function importToolModule(moduleId: string): Promise<ImportedToolMo

const result: ImportedToolModule = {
schema: mod.schema as ToolSchemaShape,
mcpSchema: (mod.mcpSchema ?? mod.schema) as ToolSchemaShape,
handler: mod.handler as (
params: Record<string, unknown>,
ctx?: ToolHandlerContext,
Expand Down
26 changes: 26 additions & 0 deletions src/mcp/tools/device/build_run_device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ import {
} from '../../../utils/xcodebuild-domain-results.ts';
import { resolveEffectiveDerivedDataPath } from '../../../utils/derived-data-path.ts';
import { createBuildInvocationFragment } from '../../../utils/xcodebuild-pipeline.ts';
import {
createEnvironmentVariableInputSchema,
normalizeEnvironmentVariableArgument,
} from '../../../utils/environment-variable-input.ts';

function createBuildRunDeviceRequest(params: BuildRunDeviceParams): BuildInvocationRequest {
return {
Expand Down Expand Up @@ -78,6 +82,12 @@ const buildRunDeviceSchema = z.preprocess(
withProjectOrWorkspace(baseSchemaObject),
);

const mcpFullSchemaObject = baseSchemaObject.extend({
env: createEnvironmentVariableInputSchema(
'Environment variables to pass to the launched app as key-value entries',
),
});

export type BuildRunDeviceParams = z.infer<typeof buildRunDeviceSchema>;
type BuildRunDeviceResult = BuildRunResultDomainResult;

Expand Down Expand Up @@ -300,6 +310,16 @@ const publicSchemaObject = baseSchemaObject.omit({
preferXcodebuild: true,
} as const);

const mcpPublicSchemaObject = mcpFullSchemaObject.omit({
projectPath: true,
workspacePath: true,
scheme: true,
deviceId: true,
configuration: true,
derivedDataPath: true,
preferXcodebuild: true,
} as const);

export async function build_run_deviceLogic(
params: BuildRunDeviceParams,
executor: CommandExecutor,
Expand Down Expand Up @@ -335,6 +355,11 @@ export const schema = getSessionAwareToolSchemaShape({
legacy: baseSchemaObject,
});

export const mcpSchema = getSessionAwareToolSchemaShape({
sessionAware: mcpPublicSchemaObject,
legacy: mcpFullSchemaObject,
});

export const handler = createSessionAwareTool<BuildRunDeviceParams>({
internalSchema: toInternalSchema<BuildRunDeviceParams>(buildRunDeviceSchema),
logicFunction: (params, executor) =>
Expand All @@ -345,4 +370,5 @@ export const handler = createSessionAwareTool<BuildRunDeviceParams>({
{ oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' },
],
exclusivePairs: [['projectPath', 'workspacePath']],
normalizeExplicitArgs: (args) => normalizeEnvironmentVariableArgument(args, 'env'),
});
21 changes: 21 additions & 0 deletions src/mcp/tools/device/launch_app_device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import {
buildLaunchSuccess,
setLaunchResultStructuredOutput,
} from '../../../utils/app-lifecycle-results.ts';
import {
createEnvironmentVariableInputSchema,
normalizeEnvironmentVariableArgument,
} from '../../../utils/environment-variable-input.ts';

const launchAppDeviceSchema = z.object({
deviceId: z.string().describe('UDID of the device (obtained from list_devices)'),
Expand All @@ -41,11 +45,22 @@ const launchAppDeviceSchema = z.object({
.describe('Environment variables to pass to the launched app (as key-value dictionary)'),
});

const mcpFullSchemaObject = launchAppDeviceSchema.extend({
env: createEnvironmentVariableInputSchema(
'Environment variables to pass to the launched app as key-value entries',
),
});

const publicSchemaObject = launchAppDeviceSchema.omit({
deviceId: true,
bundleId: true,
} as const);

const mcpPublicSchemaObject = mcpFullSchemaObject.omit({
deviceId: true,
bundleId: true,
} as const);

type LaunchAppDeviceParams = z.infer<typeof launchAppDeviceSchema>;
type LaunchAppDeviceResult = LaunchResultDomainResult;

Expand Down Expand Up @@ -118,10 +133,16 @@ export const schema = getSessionAwareToolSchemaShape({
legacy: launchAppDeviceSchema,
});

export const mcpSchema = getSessionAwareToolSchemaShape({
sessionAware: mcpPublicSchemaObject,
legacy: mcpFullSchemaObject,
});

export const handler = createSessionAwareTool<LaunchAppDeviceParams>({
internalSchema: toInternalSchema<LaunchAppDeviceParams>(launchAppDeviceSchema),
logicFunction: (params, executor) =>
launch_app_deviceLogic(params, executor, getDefaultFileSystemExecutor()),
getExecutor: getDefaultCommandExecutor,
requirements: [{ allOf: ['deviceId', 'bundleId'], message: 'Provide deviceId and bundleId' }],
normalizeExplicitArgs: (args) => normalizeEnvironmentVariableArgument(args, 'env'),
});
24 changes: 24 additions & 0 deletions src/mcp/tools/device/test_device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ import type { BuildInvocationRequest } from '../../../types/domain-fragments.ts'
import { resolveEffectiveDerivedDataPath } from '../../../utils/derived-data-path.ts';
import { createBuildInvocationFragment } from '../../../utils/xcodebuild-pipeline.ts';
import { displayPath } from '../../../utils/build-preflight.ts';
import {
normalizeEnvironmentVariableArgument,
testRunnerEnvironmentSchema,
} from '../../../utils/environment-variable-input.ts';

const baseSchemaObject = z.object({
projectPath: z.string().optional().describe('Path to the .xcodeproj file'),
Expand Down Expand Up @@ -73,6 +77,10 @@ const testDeviceSchema = z.preprocess(
withProjectWorkspaceOrTestArtifact(baseSchemaObject),
);

const mcpFullSchemaObject = baseSchemaObject.extend({
testRunnerEnv: testRunnerEnvironmentSchema,
});

export type TestDeviceParams = z.infer<typeof testDeviceSchema>;
type TestDeviceResult = TestResultDomainResult;

Expand All @@ -86,6 +94,16 @@ const publicSchemaObject = baseSchemaObject.omit({
preferXcodebuild: true,
} as const);

const mcpPublicSchemaObject = mcpFullSchemaObject.omit({
projectPath: true,
workspacePath: true,
scheme: true,
deviceId: true,
configuration: true,
derivedDataPath: true,
preferXcodebuild: true,
} as const);

interface PreparedTestDeviceExecution {
configuration?: string;
platform: XcodePlatform;
Expand Down Expand Up @@ -192,11 +210,17 @@ export const schema = getSessionAwareToolSchemaShape({
legacy: baseSchemaObject,
});

export const mcpSchema = getSessionAwareToolSchemaShape({
sessionAware: mcpPublicSchemaObject,
legacy: mcpFullSchemaObject,
});

export const handler = createSessionAwareTool<TestDeviceParams>({
internalSchema: toInternalSchema<TestDeviceParams>(testDeviceSchema),
logicFunction: (params, executor) =>
testDeviceLogic(params, executor, getDefaultFileSystemExecutor()),
getExecutor: getDefaultCommandExecutor,
requirements: [{ allOf: ['deviceId'], message: 'Provide deviceId' }],
exclusivePairs: [...TEST_SOURCE_EXCLUSIVE_GROUPS, ['projectPath', 'workspacePath']],
normalizeExplicitArgs: (args) => normalizeEnvironmentVariableArgument(args, 'testRunnerEnv'),
});
23 changes: 23 additions & 0 deletions src/mcp/tools/macos/test_macos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ import type { BuildInvocationRequest } from '../../../types/domain-fragments.ts'
import { resolveEffectiveDerivedDataPath } from '../../../utils/derived-data-path.ts';
import { createBuildInvocationFragment } from '../../../utils/xcodebuild-pipeline.ts';
import { displayPath } from '../../../utils/build-preflight.ts';
import {
normalizeEnvironmentVariableArgument,
testRunnerEnvironmentSchema,
} from '../../../utils/environment-variable-input.ts';

const baseSchemaObject = z.object({
projectPath: z.string().optional().describe('Path to the .xcodeproj file'),
Expand Down Expand Up @@ -65,6 +69,10 @@ const baseSchemaObject = z.object({
.describe('Show detailed test progress output (MCP defaults to true, CLI defaults to false)'),
});

const mcpFullSchemaObject = baseSchemaObject.extend({
testRunnerEnv: testRunnerEnvironmentSchema,
});

const publicSchemaObject = baseSchemaObject.omit({
projectPath: true,
workspacePath: true,
Expand All @@ -74,6 +82,15 @@ const publicSchemaObject = baseSchemaObject.omit({
preferXcodebuild: true,
} as const);

const mcpPublicSchemaObject = mcpFullSchemaObject.omit({
projectPath: true,
workspacePath: true,
scheme: true,
configuration: true,
derivedDataPath: true,
preferXcodebuild: true,
} as const);

const testMacosSchema = z.preprocess(
nullifyEmptyStrings,
withProjectWorkspaceOrTestArtifact(baseSchemaObject),
Expand Down Expand Up @@ -181,10 +198,16 @@ export const schema = getSessionAwareToolSchemaShape({
legacy: baseSchemaObject,
});

export const mcpSchema = getSessionAwareToolSchemaShape({
sessionAware: mcpPublicSchemaObject,
legacy: mcpFullSchemaObject,
});

export const handler = createSessionAwareTool<TestMacosParams>({
internalSchema: toInternalSchema<TestMacosParams>(testMacosSchema),
logicFunction: (params, executor) =>
testMacosLogic(params, executor, getDefaultFileSystemExecutor()),
getExecutor: getDefaultCommandExecutor,
exclusivePairs: [...TEST_SOURCE_EXCLUSIVE_GROUPS, ['projectPath', 'workspacePath']],
normalizeExplicitArgs: (args) => normalizeEnvironmentVariableArgument(args, 'testRunnerEnv'),
});
22 changes: 21 additions & 1 deletion src/mcp/tools/session-management/session_set_defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import type { CommandExecutor } from '../../../utils/execution/index.ts';
import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
import { formatProfileLabel } from './session-format-helpers.ts';
import { toErrorMessage } from '../../../utils/errors.ts';
import {
createEnvironmentVariableInputSchema,
normalizeEnvironmentVariableArgument,
} from '../../../utils/environment-variable-input.ts';

const schemaObj = sessionDefaultsSchema.extend({
profile: z
Expand All @@ -39,8 +43,23 @@ const schemaObj = sessionDefaultsSchema.extend({
.describe('Persist provided defaults to .xcodebuildmcp/config.yaml'),
});

const mcpSchemaObj = schemaObj.extend({
env: createEnvironmentVariableInputSchema(
'Default environment variables to pass to launched apps as key-value entries',
),
});

type Params = z.input<typeof schemaObj>;

const internalSchema: z.ZodType<Params, unknown> = z.preprocess((input) => {
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
return input;
}

const args = input as Record<string, unknown>;
return normalizeEnvironmentVariableArgument(args, 'env');
}, schemaObj);

type SessionSetDefaultsContext = {
executor: CommandExecutor;
};
Expand Down Expand Up @@ -242,7 +261,8 @@ export async function sessionSetDefaultsLogic(
}

export const schema = schemaObj.shape;
export const mcpSchema = mcpSchemaObj.shape;

export const handler = createTypedToolWithContext(schemaObj, sessionSetDefaultsLogic, () => ({
export const handler = createTypedToolWithContext(internalSchema, sessionSetDefaultsLogic, () => ({
executor: getDefaultCommandExecutor(),
}));
24 changes: 24 additions & 0 deletions src/mcp/tools/simulator/launch_app_sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import {
setLaunchResultStructuredOutput,
type LaunchResultArtifacts,
} from '../../../utils/app-lifecycle-results.ts';
import {
createEnvironmentVariableInputSchema,
normalizeEnvironmentVariableArgument,
} from '../../../utils/environment-variable-input.ts';

const baseSchemaObject = z.object({
simulatorId: z
Expand Down Expand Up @@ -57,6 +61,12 @@ const internalSchemaObject = z.object({
env: z.record(z.string(), z.string()).optional(),
});

const mcpFullSchemaObject = baseSchemaObject.extend({
env: createEnvironmentVariableInputSchema(
'Environment variables to pass to the launched app as key-value entries (SIMCTL_CHILD_ prefix added automatically)',
),
});

export type LaunchAppSimParams = z.infer<typeof internalSchemaObject>;
type ResolvedLaunchAppSimParams = LaunchAppSimParams & { simulatorId: string };
type LaunchAppSimResult = LaunchResultDomainResult;
Expand Down Expand Up @@ -183,11 +193,24 @@ const publicSchemaObject = z.strictObject(
} as const).shape,
);

const mcpPublicSchemaObject = z.strictObject(
mcpFullSchemaObject.omit({
simulatorId: true,
simulatorName: true,
bundleId: true,
} as const).shape,
);

export const schema = getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: baseSchemaObject,
});

export const mcpSchema = getSessionAwareToolSchemaShape({
sessionAware: mcpPublicSchemaObject,
legacy: mcpFullSchemaObject,
});

export const handler = createSessionAwareTool<LaunchAppSimParams>({
internalSchema: toInternalSchema<LaunchAppSimParams>(internalSchemaObject),
logicFunction: launch_app_simLogic,
Expand All @@ -197,4 +220,5 @@ export const handler = createSessionAwareTool<LaunchAppSimParams>({
{ allOf: ['bundleId'], message: 'bundleId is required' },
],
exclusivePairs: [['simulatorId', 'simulatorName']],
normalizeExplicitArgs: (args) => normalizeEnvironmentVariableArgument(args, 'env'),
});
Loading
Loading