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
2 changes: 1 addition & 1 deletion .axe-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.4.0
1.5.2
10 changes: 5 additions & 5 deletions docs/TOOLS-CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This document lists CLI tool names as exposed by `xcodebuildmcp <workflow> <tool>`.

XcodeBuildMCP provides 75 canonical tools organized into 14 workflow groups.
XcodeBuildMCP provides 76 canonical tools organized into 14 workflow groups.

## Workflow Groups

Expand All @@ -22,7 +22,7 @@ XcodeBuildMCP provides 75 canonical tools organized into 14 workflow groups.


### iOS Device Development (`device`)
**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (16 tools)
**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (17 tools)

- `build` - Build for device.
- `build-and-run` - Build, install, and launch on physical device. Preferred single-step run tool when defaults are set.
Expand Down Expand Up @@ -200,10 +200,10 @@ XcodeBuildMCP provides 75 canonical tools organized into 14 workflow groups.

## Summary Statistics

- **Canonical Tools**: 75
- **Total Tools**: 107
- **Canonical Tools**: 76
- **Total Tools**: 108
- **Workflow Groups**: 14

---

*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-03T09:47:33.422Z UTC*
*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-07T20:50:55.840Z UTC*
10 changes: 5 additions & 5 deletions docs/TOOLS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# XcodeBuildMCP MCP Tools Reference

This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 81 canonical tools organized into 16 workflow groups for comprehensive Apple development workflows.
This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 82 canonical tools organized into 16 workflow groups for comprehensive Apple development workflows.

## Workflow Groups

Expand All @@ -20,7 +20,7 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov


### iOS Device Development (`device`)
**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (16 tools)
**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (17 tools)

- `build_device` - Build for device.
- `build_run_device` - Build, install, and launch on physical device. Preferred single-step run tool when defaults are set.
Expand Down Expand Up @@ -216,10 +216,10 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov

## Summary Statistics

- **Canonical Tools**: 81
- **Total Tools**: 113
- **Canonical Tools**: 82
- **Total Tools**: 114
- **Workflow Groups**: 16

---

*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-03T09:47:33.422Z UTC*
*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-07T20:50:55.840Z UTC*
12 changes: 6 additions & 6 deletions scripts/bundle-axe.sh
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,13 @@ else
cd "$AXE_TEMP_DIR"

# Download and extract the release
if [ "$(uname -s)" != "Darwin" ]; then
echo "📥 Non-macOS detected; downloading pre-signed legacy archive ($AXE_LEGACY_URL)..."
curl -fL -o "axe-release.tar.gz" "$AXE_LEGACY_URL"
AXE_ARCHIVE_FLAVOR="legacy-signed"
elif curl -fL -o "axe-release.tar.gz" "$AXE_UNIVERSAL_URL"; then
if curl -fL -o "axe-release.tar.gz" "$AXE_UNIVERSAL_URL"; then
AXE_ARCHIVE_FLAVOR="universal"
echo "✅ Downloaded AXe universal archive"
if [ "$(uname -s)" != "Darwin" ]; then
echo "✅ Downloaded AXe universal archive for non-macOS bundling"
else
echo "✅ Downloaded AXe universal archive"
fi
else
echo "⚠️ AXe universal archive unavailable, falling back to legacy archive"
curl -fL -o "axe-release.tar.gz" "$AXE_LEGACY_URL"
Expand Down
284 changes: 284 additions & 0 deletions src/cli/__tests__/register-tool-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
import yargs from 'yargs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as z from 'zod';
import type { ToolCatalog, ToolDefinition } from '../../runtime/types.ts';
import { DefaultToolInvoker } from '../../runtime/tool-invoker.ts';
import { createTextContent } from '../../types/common.ts';
import type { ResolvedRuntimeConfig } from '../../utils/config-store.ts';
import { registerToolCommands } from '../register-tool-commands.ts';

function createTool(overrides: Partial<ToolDefinition> = {}): ToolDefinition {
return {
cliName: 'run-tool',
mcpName: 'run_tool',
workflow: 'simulator',
description: 'Run test tool',
annotations: { readOnlyHint: true },
cliSchema: {
workspacePath: z.string().describe('Workspace path'),
scheme: z.string().optional(),
},
mcpSchema: {
workspacePath: z.string().describe('Workspace path'),
scheme: z.string().optional(),
},
stateful: false,
handler: vi.fn(async () => ({
content: [createTextContent('ok')],
isError: false,
})),
...overrides,
};
}

function createCatalog(tools: ToolDefinition[]): ToolCatalog {
return {
tools,
getByCliName: (name) => tools.find((tool) => tool.cliName === name) ?? null,
getByMcpName: (name) => tools.find((tool) => tool.mcpName === name) ?? null,
getByToolId: (toolId) => tools.find((tool) => tool.id === toolId) ?? null,
resolve: (input) => {
const tool = tools.find((candidate) => candidate.cliName === input);
return tool ? { tool } : { notFound: true };
},
};
}

const baseRuntimeConfig: ResolvedRuntimeConfig = {
enabledWorkflows: [],
customWorkflows: {},
debug: false,
sentryDisabled: false,
experimentalWorkflowDiscovery: false,
disableSessionDefaults: true,
disableXcodeAutoSync: false,
uiDebuggerGuardMode: 'error',
incrementalBuildsEnabled: false,
dapRequestTimeoutMs: 30_000,
dapLogEvents: false,
launchJsonWaitMs: 8_000,
debuggerBackend: 'dap',
sessionDefaults: {
workspacePath: 'App.xcworkspace',
},
sessionDefaultsProfiles: {
ios: {
workspacePath: 'Profile.xcworkspace',
},
},
activeSessionDefaultsProfile: 'ios',
};

function createApp(catalog: ToolCatalog, runtimeConfig: ResolvedRuntimeConfig = baseRuntimeConfig) {
const app = yargs()
.scriptName('xcodebuildmcp')
.exitProcess(false)
.fail((message, error) => {
throw error ?? new Error(message);
});

registerToolCommands(app, catalog, {
workspaceRoot: '/repo',
runtimeConfig,
cliExposedWorkflowIds: ['simulator'],
workflowNames: ['simulator'],
});

return app;
}

describe('registerToolCommands', () => {
const originalArgv = process.argv;

afterEach(() => {
vi.restoreAllMocks();
process.exitCode = undefined;
process.argv = originalArgv;
});

it('hydrates required args from the active defaults profile', async () => {
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
content: [createTextContent('ok')],
isError: false,
});
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

const tool = createTool();
const app = createApp(createCatalog([tool]));

await expect(app.parseAsync(['simulator', 'run-tool'])).resolves.toBeDefined();

expect(invokeDirect).toHaveBeenCalledTimes(1);
expect(invokeDirect).toHaveBeenCalledWith(
tool,
{
workspacePath: 'Profile.xcworkspace',
},
expect.objectContaining({
runtime: 'cli',
workspaceRoot: '/repo',
}),
);

stdoutWrite.mockRestore();
});

it('hydrates required args from the explicit --profile override', async () => {
process.argv = ['node', 'xcodebuildmcp', 'simulator', 'run-tool', '--profile', 'qa'];

const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
content: [createTextContent('ok')],
isError: false,
});
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

const tool = createTool();
const app = createApp(createCatalog([tool]), {
...baseRuntimeConfig,
sessionDefaultsProfiles: {
...baseRuntimeConfig.sessionDefaultsProfiles,
qa: {
workspacePath: 'QA.xcworkspace',
},
},
});

await expect(
app.parseAsync(['simulator', 'run-tool', '--profile', 'qa']),
).resolves.toBeDefined();

expect(invokeDirect).toHaveBeenCalledWith(
tool,
{
workspacePath: 'QA.xcworkspace',
},
expect.any(Object),
);

stdoutWrite.mockRestore();
});

it('keeps the normal missing-argument error when no hydrated default exists', async () => {
const tool = createTool();
const app = createApp(createCatalog([tool]), {
...baseRuntimeConfig,
sessionDefaults: undefined,
sessionDefaultsProfiles: undefined,
activeSessionDefaultsProfile: undefined,
});

let error: Error | undefined;
try {
await app.parseAsync(['simulator', 'run-tool']);
} catch (thrown) {
error = thrown as Error;
}

expect(error?.message).toContain('Missing required argument: workspace-path');
expect(error?.message).not.toMatch(/session defaults/i);
});

it('hydrates args before daemon-routed invocation', async () => {
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
content: [createTextContent('ok')],
isError: false,
});
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

const tool = createTool({ stateful: true });
const app = createApp(createCatalog([tool]));

await expect(app.parseAsync(['simulator', 'run-tool'])).resolves.toBeDefined();

expect(invokeDirect).toHaveBeenCalledWith(
tool,
{
workspacePath: 'Profile.xcworkspace',
},
expect.any(Object),
);

stdoutWrite.mockRestore();
});

it('lets explicit args override conflicting defaults before invocation', async () => {
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
content: [createTextContent('ok')],
isError: false,
});
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

const tool = createTool({
cliSchema: {
projectPath: z.string().describe('Project path'),
workspacePath: z.string().optional(),
},
mcpSchema: {
projectPath: z.string().describe('Project path'),
workspacePath: z.string().optional(),
},
});
const app = createApp(createCatalog([tool]));

await expect(
app.parseAsync(['simulator', 'run-tool', '--project-path', 'App.xcodeproj']),
).resolves.toBeDefined();

expect(invokeDirect).toHaveBeenCalledWith(
tool,
{
projectPath: 'App.xcodeproj',
},
expect.any(Object),
);

stdoutWrite.mockRestore();
});

it('errors clearly when --profile references an unknown profile', async () => {
const stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});

const tool = createTool();
const app = createApp(createCatalog([tool]));

await expect(
app.parseAsync(['simulator', 'run-tool', '--profile', 'missing']),
).resolves.toBeDefined();

expect(consoleError).toHaveBeenCalledWith("Error: Unknown defaults profile 'missing'");
expect(process.exitCode).toBe(1);

stderrWrite.mockRestore();
});

it('lets --json override configured defaults', async () => {
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
content: [createTextContent('ok')],
isError: false,
});
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

const tool = createTool();
const app = createApp(createCatalog([tool]));

await expect(
app.parseAsync([
'simulator',
'run-tool',
'--json',
JSON.stringify({ workspacePath: 'Json.xcworkspace' }),
]),
).resolves.toBeDefined();

expect(invokeDirect).toHaveBeenCalledWith(
tool,
{
workspacePath: 'Json.xcworkspace',
},
expect.any(Object),
);

stdoutWrite.mockRestore();
});
});
28 changes: 28 additions & 0 deletions src/cli/__tests__/schema-to-yargs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import * as z from 'zod';
import { schemaToYargsOptions } from '../schema-to-yargs.ts';

describe('schemaToYargsOptions', () => {
it('keeps required flags required when no hydrated default exists', () => {
const options = schemaToYargsOptions({
workspacePath: z.string().describe('Workspace path'),
});

expect(options.get('workspace-path')?.demandOption).toBe(true);
});

it('drops required flag demand when a hydrated default exists', () => {
const options = schemaToYargsOptions(
{
workspacePath: z.string().describe('Workspace path'),
},
{
hydratedDefaults: {
workspacePath: 'App.xcworkspace',
},
},
);

expect(options.get('workspace-path')?.demandOption).toBe(false);
});
});
Loading
Loading