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 .axe-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.1.1
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,4 @@ bundled/
/.mcpregistry_github_token
/.mcpregistry_registry_token
/key.pem
.mcpli
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ The XcodeBuildMCP server provides the following tool capabilities:
- **Log Capture**: Capture run-time logs from a simulator
- **UI Automation**: Interact with simulator UI elements
- **Screenshot**: Capture screenshots from a simulator
- **Video Capture**: Start/stop simulator video capture to MP4 (AXe v1.1.0+)

### Device management
- **Device Discovery**: List connected physical Apple devices over USB or Wi-Fi
Expand Down Expand Up @@ -117,7 +118,9 @@ For clients that support MCP resources XcodeBuildMCP provides efficient URI-base
- Xcode 16.x or later
- Node 18.x or later

### Configure your MCP client
> Video capture requires the bundled AXe binary (v1.1.0+). Run `npm run bundle:axe` once locally before using `record_sim_video`. This is not required for unit tests.

Configure your MCP client

#### One click install

Expand Down
11 changes: 6 additions & 5 deletions docs/TOOLS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# XcodeBuildMCP Tools Reference

XcodeBuildMCP provides 60 tools organized into 12 workflow groups for comprehensive Apple development workflows.
XcodeBuildMCP provides 61 tools organized into 12 workflow groups for comprehensive Apple development workflows.

## Workflow Groups

Expand All @@ -19,7 +19,7 @@ XcodeBuildMCP provides 60 tools organized into 12 workflow groups for comprehens
- `stop_app_device` - Stops an app running on a physical Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Requires deviceId and processId.
- `test_device` - Runs tests for an Apple project or workspace on a physical device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) using xcodebuild test and parses xcresult output. Provide exactly one of projectPath or workspacePath.
### iOS Simulator Development (`simulator`)
**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators. (11 tools)
**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators. (12 tools)

- `boot_sim` - Boots an iOS simulator. After booting, use open_sim() to make the simulator visible.
- `build_run_sim` - Builds and runs an app from a project or workspace on a specific simulator by UUID or name. Provide exactly one of projectPath or workspacePath, and exactly one of simulatorId or simulatorName.
Expand All @@ -30,6 +30,7 @@ XcodeBuildMCP provides 60 tools organized into 12 workflow groups for comprehens
- `launch_app_sim` - Launches an app in an iOS simulator by UUID or name. If simulator window isn't visible, use open_sim() first. or launch_app_sim({ simulatorName: 'iPhone 16', bundleId: 'com.example.MyApp' })
- `list_sims` - Lists available iOS simulators with their UUIDs.
- `open_sim` - Opens the iOS Simulator app.
- `record_sim_video` - Starts or stops video capture for an iOS simulator using AXe. Provide exactly one of start=true or stop=true. On stop, outputFile is required. fps defaults to 30.
- `stop_app_sim` - Stops an app running in an iOS simulator by UUID or name. or stop_app_sim({ simulatorName: "iPhone 16", bundleId: "com.example.MyApp" })
- `test_sim` - Runs tests on a simulator by UUID or name using xcodebuild test and parses xcresult output. Works with both Xcode projects (.xcodeproj) and workspaces (.xcworkspace).
### Log Capture & Management (`logging`)
Expand Down Expand Up @@ -68,7 +69,7 @@ XcodeBuildMCP provides 60 tools organized into 12 workflow groups for comprehens
### Simulator Management (`simulator-management`)
**Purpose**: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. (5 tools)

- `erase_sims` - Erases simulator content and settings. Provide exactly one of: simulatorUuid or all=true. Optional: shutdownFirst to shut down before erasing.
- `erase_sims` - Erases simulator content and settings. Provide exactly one of: simulatorUdid or all=true. Optional: shutdownFirst to shut down before erasing.
- `reset_sim_location` - Resets the simulator's location to default.
- `set_sim_appearance` - Sets the appearance mode (dark/light) of an iOS simulator.
- `set_sim_location` - Sets a custom GPS location for the simulator.
Expand Down Expand Up @@ -103,9 +104,9 @@ XcodeBuildMCP provides 60 tools organized into 12 workflow groups for comprehens

## Summary Statistics

- **Total Tools**: 60 canonical tools + 22 re-exports = 82 total
- **Total Tools**: 61 canonical tools + 22 re-exports = 83 total
- **Workflow Groups**: 12

---

*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2025-09-21*
*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2025-09-22*
63 changes: 39 additions & 24 deletions scripts/bundle-axe.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ AXE_TEMP_DIR="/tmp/axe-download-$$"

echo "🔨 Preparing AXe artifacts for bundling..."

# Single source of truth for AXe version (overridable)
# 1) Use $AXE_VERSION if provided in env
# 2) Else, use repo-level pin from .axe-version if present
# 3) Else, fall back to default below
DEFAULT_AXE_VERSION="1.1.1"
VERSION_FILE="$PROJECT_ROOT/.axe-version"
if [ -n "${AXE_VERSION}" ]; then
PINNED_AXE_VERSION="${AXE_VERSION}"
elif [ -f "$VERSION_FILE" ]; then
PINNED_AXE_VERSION="$(cat "$VERSION_FILE" | tr -d ' \n\r')"
else
PINNED_AXE_VERSION="$DEFAULT_AXE_VERSION"
fi
echo "📌 Using AXe version: $PINNED_AXE_VERSION"

# Clean up any existing bundled directory
if [ -d "$BUNDLED_DIR" ]; then
echo "🧹 Cleaning existing bundled directory..."
Expand All @@ -22,41 +37,41 @@ fi
# Create bundled directory
mkdir -p "$BUNDLED_DIR"

# Use local AXe build if available, otherwise download from GitHub releases
if [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then
# Use local AXe build if available (unless AXE_FORCE_REMOTE=1), otherwise download from GitHub releases
if [ -z "${AXE_FORCE_REMOTE}" ] && [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then
echo "🏠 Using local AXe source at $AXE_LOCAL_DIR"
cd "$AXE_LOCAL_DIR"

# Build AXe in release configuration
echo "🔨 Building AXe in release configuration..."
swift build --configuration release

# Check if build succeeded
if [ ! -f ".build/release/axe" ]; then
echo "❌ AXe build failed - binary not found"
exit 1
fi

echo "✅ AXe build completed successfully"

# Copy binary to bundled directory
echo "📦 Copying AXe binary..."
cp ".build/release/axe" "$BUNDLED_DIR/"

# Fix rpath to find frameworks in Frameworks/ subdirectory
echo "🔧 Configuring AXe binary rpath for bundled frameworks..."
install_name_tool -add_rpath "@executable_path/Frameworks" "$BUNDLED_DIR/axe"

# Create Frameworks directory and copy frameworks
echo "📦 Copying frameworks..."
mkdir -p "$BUNDLED_DIR/Frameworks"

# Copy frameworks with better error handling
for framework in .build/release/*.framework; do
if [ -d "$framework" ]; then
echo "📦 Copying framework: $(basename "$framework")"
cp -r "$framework" "$BUNDLED_DIR/Frameworks/"

# Only copy nested frameworks if they exist
if [ -d "$framework/Frameworks" ]; then
echo "📦 Found nested frameworks in $(basename "$framework")"
Expand All @@ -66,30 +81,30 @@ if [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then
done
else
echo "📥 Downloading latest AXe release from GitHub..."
# Get latest release download URL
LATEST_RELEASE_URL="https://github.com/cameroncooke/AXe/releases/download/v1.0.0/AXe-macOS-v1.0.0.tar.gz"

# Construct release download URL from pinned version
AXE_RELEASE_URL="https://github.com/cameroncooke/AXe/releases/download/v${PINNED_AXE_VERSION}/AXe-macOS-v${PINNED_AXE_VERSION}.tar.gz"

# Create temp directory
mkdir -p "$AXE_TEMP_DIR"
cd "$AXE_TEMP_DIR"

# Download and extract the release
echo "📥 Downloading AXe release archive..."
curl -L -o "axe-release.tar.gz" "$LATEST_RELEASE_URL"
echo "📥 Downloading AXe release archive ($AXE_RELEASE_URL)..."
curl -L -o "axe-release.tar.gz" "$AXE_RELEASE_URL"

echo "📦 Extracting AXe release archive..."
tar -xzf "axe-release.tar.gz"

# Find the extracted directory (might be named differently)
EXTRACTED_DIR=$(find . -type d -name "*AXe*" -o -name "*axe*" | head -1)
if [ -z "$EXTRACTED_DIR" ]; then
# If no AXe directory found, assume files are in current directory
EXTRACTED_DIR="."
fi

cd "$EXTRACTED_DIR"
Comment on lines 99 to 106
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

find expression may select non-directories; group predicates

Without grouping, -name "*axe*" can match files of any type. Group and constrain both to directories.

-EXTRACTED_DIR=$(find . -type d -name "*AXe*" -o -name "*axe*" | head -1)
+EXTRACTED_DIR=$(find . \( -type d -name "*AXe*" -o -type d -name "*axe*" \) | head -1)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Find the extracted directory (might be named differently)
EXTRACTED_DIR=$(find . -type d -name "*AXe*" -o -name "*axe*" | head -1)
if [ -z "$EXTRACTED_DIR" ]; then
# If no AXe directory found, assume files are in current directory
EXTRACTED_DIR="."
fi
cd "$EXTRACTED_DIR"
# Find the extracted directory (might be named differently)
EXTRACTED_DIR=$(find . \( -type d -name "*AXe*" -o -type d -name "*axe*" \) | head -1)
if [ -z "$EXTRACTED_DIR" ]; then
# If no AXe directory found, assume files are in current directory
EXTRACTED_DIR="."
fi
cd "$EXTRACTED_DIR"
🤖 Prompt for AI Agents
In scripts/bundle-axe.sh around lines 99 to 106, the find expression can match
non-directories because the name tests are not grouped and -type d may not apply
as intended; update the EXTRACTED_DIR assignment to use a find that constrains
results to directories and groups the name predicates (use escaped parentheses
and -o) and prefer a case-insensitive name test (iname); keep the fallback to
"." and the subsequent cd unchanged.


# Copy binary
if [ -f "axe" ]; then
echo "📦 Copying AXe binary..."
Expand All @@ -104,11 +119,11 @@ else
ls -la
exit 1
fi

# Copy frameworks if they exist
echo "📦 Copying frameworks..."
mkdir -p "$BUNDLED_DIR/Frameworks"

if [ -d "Frameworks" ]; then
cp -r Frameworks/* "$BUNDLED_DIR/Frameworks/"
elif [ -d "lib" ]; then
Expand Down Expand Up @@ -153,4 +168,4 @@ BUNDLE_SIZE=$(du -sh "$BUNDLED_DIR" | cut -f1)
echo "📊 Final bundle size: $BUNDLE_SIZE"

echo "🎉 AXe bundling completed successfully!"
echo "📁 Bundled artifacts location: $BUNDLED_DIR"
echo "📁 Bundled artifacts location: $BUNDLED_DIR"
189 changes: 189 additions & 0 deletions src/mcp/tools/simulator/__tests__/record_sim_video.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Violation of Vitest mocking constraint in coding guidelines.

The coding guidelines explicitly state that files matching **/*.{test,spec}.ts should not use Vitest mocking APIs (vi.mock, vi.fn, vi.spyOn, etc.), but this test file imports vi from Vitest.

Apply this diff to remove the Vitest mocking import:

-import { describe, it, expect, vi, afterEach } from 'vitest';
+import { describe, it, expect, afterEach } from 'vitest';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, afterEach } from 'vitest';
🤖 Prompt for AI Agents
In src/mcp/tools/simulator/__tests__/record_sim_video.test.ts around line 1, the
test imports vi from Vitest which violates the guideline banning Vitest mocking
APIs in files matching **/*.{test,spec}.ts; remove vi from the import list
(import { describe, it, expect, afterEach } from 'vitest') and then remove or
refactor any uses of vi in this file — replace vi.mock/vi.fn/vi.spyOn calls with
non-Vitest alternatives or manual stubs/mocks appropriate for the test, ensuring
no Vitest mocking APIs remain.

import { z } from 'zod';

// Import the tool and logic
import tool, { record_sim_videoLogic } from '../record_sim_video.ts';
import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts';

const DUMMY_EXECUTOR: any = (async () => ({ success: true })) as any; // CommandExecutor stub
const VALID_UUID = '00000000-0000-0000-0000-000000000000';

afterEach(() => {
vi.restoreAllMocks();
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Unnecessary Mock Restoration in Dependency Injection Test

The test uses vi.restoreAllMocks() but doesn't create any mocks with the banned Vitest mocking functions (vi.mock, vi.fn, vi.spyOn). This call is unnecessary since the test uses dependency injection with plain mock objects instead of Vitest mocks, and indicates a misunderstanding of the project's testing approach which favors dependency injection over Vitest mocking.

Fix in Cursor Fix in Web

});
Comment on lines +11 to +13
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Remove mocking-related cleanup since no mocking is used.

With the removal of vi import and adherence to the coding guidelines that prohibit Vitest mocking APIs, the afterEach cleanup that calls vi.restoreAllMocks() is unnecessary.

Apply this diff to remove the unnecessary cleanup:

-afterEach(() => {
-  vi.restoreAllMocks();
-});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
afterEach(() => {
vi.restoreAllMocks();
});
🤖 Prompt for AI Agents
In src/mcp/tools/simulator/__tests__/record_sim_video.test.ts around lines 11 to
13, remove the unnecessary afterEach cleanup that calls vi.restoreAllMocks()
because the test no longer imports or uses Vitest mocking APIs; delete the
entire afterEach(() => { vi.restoreAllMocks(); }); block so there is no leftover
mocking-related cleanup.


describe('record_sim_video tool - validation', () => {
it('errors when start and stop are both true (mutually exclusive)', async () => {
const res = await tool.handler({
simulatorUuid: VALID_UUID,
start: true,
stop: true,
} as any);

expect(res.isError).toBe(true);
const text = (res.content?.[0] as any)?.text ?? '';
expect(text.toLowerCase()).toContain('mutually exclusive');
});

it('errors when stop=true but outputFile is missing', async () => {
const res = await tool.handler({
simulatorUuid: VALID_UUID,
stop: true,
} as any);

expect(res.isError).toBe(true);
const text = (res.content?.[0] as any)?.text ?? '';
expect(text.toLowerCase()).toContain('outputfile is required');
});
});

describe('record_sim_video logic - start behavior', () => {
it('starts with default fps (30) and warns when outputFile is provided on start (ignored)', async () => {
const video: any = {
startSimulatorVideoCapture: async () => ({
started: true,
sessionId: 'sess-123',
}),
stopSimulatorVideoCapture: async () => ({
stopped: false,
}),
};

// DI for AXe helpers: available and version OK
const axe = {
areAxeToolsAvailable: () => true,
isAxeAtLeastVersion: async () => true,
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text', text: 'AXe not available' }],
isError: true,
}),
};

const fs = createMockFileSystemExecutor();

const res = await record_sim_videoLogic(
{
simulatorUuid: VALID_UUID,
start: true,
// fps omitted to hit default 30
outputFile: '/tmp/ignored.mp4', // should be ignored with a note
} as any,
DUMMY_EXECUTOR,
axe,
video,
fs,
);

expect(res.isError).toBe(false);
const texts = (res.content ?? []).map((c: any) => c.text).join('\n');

expect(texts).toContain('🎥');
expect(texts).toMatch(/30\s*fps/i);
expect(texts.toLowerCase()).toContain('outputfile is ignored');
expect(texts).toContain('Next Steps');
expect(texts).toContain('stop: true');
expect(texts).toContain('outputFile');
});
});

describe('record_sim_video logic - end-to-end stop with rename', () => {
it('stops, parses stdout path, and renames to outputFile', async () => {
const video: any = {
startSimulatorVideoCapture: async () => ({
started: true,
sessionId: 'sess-abc',
}),
stopSimulatorVideoCapture: async () => ({
stopped: true,
parsedPath: '/tmp/recorded.mp4',
stdout: 'Saved to /tmp/recorded.mp4',
}),
};

const fs = createMockFileSystemExecutor();

const axe = {
areAxeToolsAvailable: () => true,
isAxeAtLeastVersion: async () => true,
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text', text: 'AXe not available' }],
isError: true,
}),
};

// Start (not strictly required for stop path, but included to mimic flow)
const startRes = await record_sim_videoLogic(
{
simulatorUuid: VALID_UUID,
start: true,
} as any,
DUMMY_EXECUTOR,
axe,
video,
fs,
);
expect(startRes.isError).toBe(false);

// Stop and rename
const outputFile = '/var/videos/final.mp4';
const stopRes = await record_sim_videoLogic(
{
simulatorUuid: VALID_UUID,
stop: true,
outputFile,
} as any,
DUMMY_EXECUTOR,
axe,
video,
fs,
);

expect(stopRes.isError).toBe(false);
const texts = (stopRes.content ?? []).map((c: any) => c.text).join('\n');
expect(texts).toContain('Original file: /tmp/recorded.mp4');
expect(texts).toContain(`Saved to: ${outputFile}`);

// _meta should include final saved path
expect((stopRes as any)._meta?.outputFile).toBe(outputFile);
});
});

describe('record_sim_video logic - version gate', () => {
it('errors when AXe version is below 1.1.0', async () => {
const axe = {
areAxeToolsAvailable: () => true,
isAxeAtLeastVersion: async () => false,
createAxeNotAvailableResponse: () => ({
content: [{ type: 'text', text: 'AXe not available' }],
isError: true,
}),
};

const video: any = {
startSimulatorVideoCapture: async () => ({
started: true,
sessionId: 'sess-xyz',
}),
stopSimulatorVideoCapture: async () => ({
stopped: true,
}),
};

const fs = createMockFileSystemExecutor();

const res = await record_sim_videoLogic(
{
simulatorUuid: VALID_UUID,
start: true,
} as any,
DUMMY_EXECUTOR,
axe,
video,
fs,
);

expect(res.isError).toBe(true);
const text = (res.content?.[0] as any)?.text ?? '';
expect(text).toContain('AXe v1.1.0');
});
});
Loading
Loading