Skip to content

Commit 8ffc8e4

Browse files
authored
ci: make MCP registry publish optional; fix video background; pin AXe 1.1.1 (#114)
* Update .gitignore * Add support for video capture! * fix(simulator): background video capture and avoid hangs when AXe already exited\n\n- Spawn AXe with detached exec and pass env correctly\n- Track session.ended on child exit/close\n- Fast-path resolve in stop when process already ended\n- Add 5s safety timeout to prevent indefinite waits
1 parent a83eef5 commit 8ffc8e4

File tree

11 files changed

+766
-30
lines changed

11 files changed

+766
-30
lines changed

.axe-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
1.1.1

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,4 @@ bundled/
107107
/.mcpregistry_github_token
108108
/.mcpregistry_registry_token
109109
/key.pem
110+
.mcpli

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ The XcodeBuildMCP server provides the following tool capabilities:
8686
- **Log Capture**: Capture run-time logs from a simulator
8787
- **UI Automation**: Interact with simulator UI elements
8888
- **Screenshot**: Capture screenshots from a simulator
89+
- **Video Capture**: Start/stop simulator video capture to MP4 (AXe v1.1.0+)
8990

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

120-
### Configure your MCP client
121+
> 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.
122+
123+
Configure your MCP client
121124

122125
#### One click install
123126

docs/TOOLS.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# XcodeBuildMCP Tools Reference
22

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

55
## Workflow Groups
66

@@ -19,7 +19,7 @@ XcodeBuildMCP provides 60 tools organized into 12 workflow groups for comprehens
1919
- `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.
2020
- `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.
2121
### iOS Simulator Development (`simulator`)
22-
**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)
22+
**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)
2323

2424
- `boot_sim` - Boots an iOS simulator. After booting, use open_sim() to make the simulator visible.
2525
- `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.
@@ -30,6 +30,7 @@ XcodeBuildMCP provides 60 tools organized into 12 workflow groups for comprehens
3030
- `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' })
3131
- `list_sims` - Lists available iOS simulators with their UUIDs.
3232
- `open_sim` - Opens the iOS Simulator app.
33+
- `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.
3334
- `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" })
3435
- `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).
3536
### Log Capture & Management (`logging`)
@@ -68,7 +69,7 @@ XcodeBuildMCP provides 60 tools organized into 12 workflow groups for comprehens
6869
### Simulator Management (`simulator-management`)
6970
**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)
7071

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

104105
## Summary Statistics
105106

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

109110
---
110111

111-
*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2025-09-21*
112+
*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2025-09-22*

scripts/bundle-axe.sh

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ AXE_TEMP_DIR="/tmp/axe-download-$$"
1313

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

16+
# Single source of truth for AXe version (overridable)
17+
# 1) Use $AXE_VERSION if provided in env
18+
# 2) Else, use repo-level pin from .axe-version if present
19+
# 3) Else, fall back to default below
20+
DEFAULT_AXE_VERSION="1.1.1"
21+
VERSION_FILE="$PROJECT_ROOT/.axe-version"
22+
if [ -n "${AXE_VERSION}" ]; then
23+
PINNED_AXE_VERSION="${AXE_VERSION}"
24+
elif [ -f "$VERSION_FILE" ]; then
25+
PINNED_AXE_VERSION="$(cat "$VERSION_FILE" | tr -d ' \n\r')"
26+
else
27+
PINNED_AXE_VERSION="$DEFAULT_AXE_VERSION"
28+
fi
29+
echo "📌 Using AXe version: $PINNED_AXE_VERSION"
30+
1631
# Clean up any existing bundled directory
1732
if [ -d "$BUNDLED_DIR" ]; then
1833
echo "🧹 Cleaning existing bundled directory..."
@@ -22,41 +37,41 @@ fi
2237
# Create bundled directory
2338
mkdir -p "$BUNDLED_DIR"
2439

25-
# Use local AXe build if available, otherwise download from GitHub releases
26-
if [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then
40+
# Use local AXe build if available (unless AXE_FORCE_REMOTE=1), otherwise download from GitHub releases
41+
if [ -z "${AXE_FORCE_REMOTE}" ] && [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then
2742
echo "🏠 Using local AXe source at $AXE_LOCAL_DIR"
2843
cd "$AXE_LOCAL_DIR"
29-
44+
3045
# Build AXe in release configuration
3146
echo "🔨 Building AXe in release configuration..."
3247
swift build --configuration release
33-
48+
3449
# Check if build succeeded
3550
if [ ! -f ".build/release/axe" ]; then
3651
echo "❌ AXe build failed - binary not found"
3752
exit 1
3853
fi
39-
54+
4055
echo "✅ AXe build completed successfully"
41-
56+
4257
# Copy binary to bundled directory
4358
echo "📦 Copying AXe binary..."
4459
cp ".build/release/axe" "$BUNDLED_DIR/"
45-
60+
4661
# Fix rpath to find frameworks in Frameworks/ subdirectory
4762
echo "🔧 Configuring AXe binary rpath for bundled frameworks..."
4863
install_name_tool -add_rpath "@executable_path/Frameworks" "$BUNDLED_DIR/axe"
49-
64+
5065
# Create Frameworks directory and copy frameworks
5166
echo "📦 Copying frameworks..."
5267
mkdir -p "$BUNDLED_DIR/Frameworks"
53-
68+
5469
# Copy frameworks with better error handling
5570
for framework in .build/release/*.framework; do
5671
if [ -d "$framework" ]; then
5772
echo "📦 Copying framework: $(basename "$framework")"
5873
cp -r "$framework" "$BUNDLED_DIR/Frameworks/"
59-
74+
6075
# Only copy nested frameworks if they exist
6176
if [ -d "$framework/Frameworks" ]; then
6277
echo "📦 Found nested frameworks in $(basename "$framework")"
@@ -66,30 +81,30 @@ if [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then
6681
done
6782
else
6883
echo "📥 Downloading latest AXe release from GitHub..."
69-
70-
# Get latest release download URL
71-
LATEST_RELEASE_URL="https://github.com/cameroncooke/AXe/releases/download/v1.0.0/AXe-macOS-v1.0.0.tar.gz"
72-
84+
85+
# Construct release download URL from pinned version
86+
AXE_RELEASE_URL="https://github.com/cameroncooke/AXe/releases/download/v${PINNED_AXE_VERSION}/AXe-macOS-v${PINNED_AXE_VERSION}.tar.gz"
87+
7388
# Create temp directory
7489
mkdir -p "$AXE_TEMP_DIR"
7590
cd "$AXE_TEMP_DIR"
76-
91+
7792
# Download and extract the release
78-
echo "📥 Downloading AXe release archive..."
79-
curl -L -o "axe-release.tar.gz" "$LATEST_RELEASE_URL"
80-
93+
echo "📥 Downloading AXe release archive ($AXE_RELEASE_URL)..."
94+
curl -L -o "axe-release.tar.gz" "$AXE_RELEASE_URL"
95+
8196
echo "📦 Extracting AXe release archive..."
8297
tar -xzf "axe-release.tar.gz"
83-
98+
8499
# Find the extracted directory (might be named differently)
85100
EXTRACTED_DIR=$(find . -type d -name "*AXe*" -o -name "*axe*" | head -1)
86101
if [ -z "$EXTRACTED_DIR" ]; then
87102
# If no AXe directory found, assume files are in current directory
88103
EXTRACTED_DIR="."
89104
fi
90-
105+
91106
cd "$EXTRACTED_DIR"
92-
107+
93108
# Copy binary
94109
if [ -f "axe" ]; then
95110
echo "📦 Copying AXe binary..."
@@ -104,11 +119,11 @@ else
104119
ls -la
105120
exit 1
106121
fi
107-
122+
108123
# Copy frameworks if they exist
109124
echo "📦 Copying frameworks..."
110125
mkdir -p "$BUNDLED_DIR/Frameworks"
111-
126+
112127
if [ -d "Frameworks" ]; then
113128
cp -r Frameworks/* "$BUNDLED_DIR/Frameworks/"
114129
elif [ -d "lib" ]; then
@@ -153,4 +168,4 @@ BUNDLE_SIZE=$(du -sh "$BUNDLED_DIR" | cut -f1)
153168
echo "📊 Final bundle size: $BUNDLE_SIZE"
154169

155170
echo "🎉 AXe bundling completed successfully!"
156-
echo "📁 Bundled artifacts location: $BUNDLED_DIR"
171+
echo "📁 Bundled artifacts location: $BUNDLED_DIR"
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
import { z } from 'zod';
3+
4+
// Import the tool and logic
5+
import tool, { record_sim_videoLogic } from '../record_sim_video.ts';
6+
import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts';
7+
8+
const DUMMY_EXECUTOR: any = (async () => ({ success: true })) as any; // CommandExecutor stub
9+
const VALID_UUID = '00000000-0000-0000-0000-000000000000';
10+
11+
afterEach(() => {
12+
vi.restoreAllMocks();
13+
});
14+
15+
describe('record_sim_video tool - validation', () => {
16+
it('errors when start and stop are both true (mutually exclusive)', async () => {
17+
const res = await tool.handler({
18+
simulatorUuid: VALID_UUID,
19+
start: true,
20+
stop: true,
21+
} as any);
22+
23+
expect(res.isError).toBe(true);
24+
const text = (res.content?.[0] as any)?.text ?? '';
25+
expect(text.toLowerCase()).toContain('mutually exclusive');
26+
});
27+
28+
it('errors when stop=true but outputFile is missing', async () => {
29+
const res = await tool.handler({
30+
simulatorUuid: VALID_UUID,
31+
stop: true,
32+
} as any);
33+
34+
expect(res.isError).toBe(true);
35+
const text = (res.content?.[0] as any)?.text ?? '';
36+
expect(text.toLowerCase()).toContain('outputfile is required');
37+
});
38+
});
39+
40+
describe('record_sim_video logic - start behavior', () => {
41+
it('starts with default fps (30) and warns when outputFile is provided on start (ignored)', async () => {
42+
const video: any = {
43+
startSimulatorVideoCapture: async () => ({
44+
started: true,
45+
sessionId: 'sess-123',
46+
}),
47+
stopSimulatorVideoCapture: async () => ({
48+
stopped: false,
49+
}),
50+
};
51+
52+
// DI for AXe helpers: available and version OK
53+
const axe = {
54+
areAxeToolsAvailable: () => true,
55+
isAxeAtLeastVersion: async () => true,
56+
createAxeNotAvailableResponse: () => ({
57+
content: [{ type: 'text', text: 'AXe not available' }],
58+
isError: true,
59+
}),
60+
};
61+
62+
const fs = createMockFileSystemExecutor();
63+
64+
const res = await record_sim_videoLogic(
65+
{
66+
simulatorUuid: VALID_UUID,
67+
start: true,
68+
// fps omitted to hit default 30
69+
outputFile: '/tmp/ignored.mp4', // should be ignored with a note
70+
} as any,
71+
DUMMY_EXECUTOR,
72+
axe,
73+
video,
74+
fs,
75+
);
76+
77+
expect(res.isError).toBe(false);
78+
const texts = (res.content ?? []).map((c: any) => c.text).join('\n');
79+
80+
expect(texts).toContain('🎥');
81+
expect(texts).toMatch(/30\s*fps/i);
82+
expect(texts.toLowerCase()).toContain('outputfile is ignored');
83+
expect(texts).toContain('Next Steps');
84+
expect(texts).toContain('stop: true');
85+
expect(texts).toContain('outputFile');
86+
});
87+
});
88+
89+
describe('record_sim_video logic - end-to-end stop with rename', () => {
90+
it('stops, parses stdout path, and renames to outputFile', async () => {
91+
const video: any = {
92+
startSimulatorVideoCapture: async () => ({
93+
started: true,
94+
sessionId: 'sess-abc',
95+
}),
96+
stopSimulatorVideoCapture: async () => ({
97+
stopped: true,
98+
parsedPath: '/tmp/recorded.mp4',
99+
stdout: 'Saved to /tmp/recorded.mp4',
100+
}),
101+
};
102+
103+
const fs = createMockFileSystemExecutor();
104+
105+
const axe = {
106+
areAxeToolsAvailable: () => true,
107+
isAxeAtLeastVersion: async () => true,
108+
createAxeNotAvailableResponse: () => ({
109+
content: [{ type: 'text', text: 'AXe not available' }],
110+
isError: true,
111+
}),
112+
};
113+
114+
// Start (not strictly required for stop path, but included to mimic flow)
115+
const startRes = await record_sim_videoLogic(
116+
{
117+
simulatorUuid: VALID_UUID,
118+
start: true,
119+
} as any,
120+
DUMMY_EXECUTOR,
121+
axe,
122+
video,
123+
fs,
124+
);
125+
expect(startRes.isError).toBe(false);
126+
127+
// Stop and rename
128+
const outputFile = '/var/videos/final.mp4';
129+
const stopRes = await record_sim_videoLogic(
130+
{
131+
simulatorUuid: VALID_UUID,
132+
stop: true,
133+
outputFile,
134+
} as any,
135+
DUMMY_EXECUTOR,
136+
axe,
137+
video,
138+
fs,
139+
);
140+
141+
expect(stopRes.isError).toBe(false);
142+
const texts = (stopRes.content ?? []).map((c: any) => c.text).join('\n');
143+
expect(texts).toContain('Original file: /tmp/recorded.mp4');
144+
expect(texts).toContain(`Saved to: ${outputFile}`);
145+
146+
// _meta should include final saved path
147+
expect((stopRes as any)._meta?.outputFile).toBe(outputFile);
148+
});
149+
});
150+
151+
describe('record_sim_video logic - version gate', () => {
152+
it('errors when AXe version is below 1.1.0', async () => {
153+
const axe = {
154+
areAxeToolsAvailable: () => true,
155+
isAxeAtLeastVersion: async () => false,
156+
createAxeNotAvailableResponse: () => ({
157+
content: [{ type: 'text', text: 'AXe not available' }],
158+
isError: true,
159+
}),
160+
};
161+
162+
const video: any = {
163+
startSimulatorVideoCapture: async () => ({
164+
started: true,
165+
sessionId: 'sess-xyz',
166+
}),
167+
stopSimulatorVideoCapture: async () => ({
168+
stopped: true,
169+
}),
170+
};
171+
172+
const fs = createMockFileSystemExecutor();
173+
174+
const res = await record_sim_videoLogic(
175+
{
176+
simulatorUuid: VALID_UUID,
177+
start: true,
178+
} as any,
179+
DUMMY_EXECUTOR,
180+
axe,
181+
video,
182+
fs,
183+
);
184+
185+
expect(res.isError).toBe(true);
186+
const text = (res.content?.[0] as any)?.text ?? '';
187+
expect(text).toContain('AXe v1.1.0');
188+
});
189+
});

0 commit comments

Comments
 (0)