import { readFile, unlink, mkdtemp } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { execAsync } from '../utils/exec.js';
import { isIdbAvailable } from '../utils/environment.js';
// Tool Schemas
export const IOS_SIMULATOR_SCHEMAS = {
capture_simulator_screenshot: {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['png', 'jpeg', 'tiff', 'bmp', 'gif'],
description: 'Image format (default: png)',
default: 'png',
},
mask: {
type: 'string',
enum: ['ignored', 'alpha', 'black'],
description: 'Mask policy for non-rectangular displays (default: black)',
default: 'black',
},
display: {
type: 'string',
enum: ['internal', 'external'],
description: 'Display type (default: internal)',
default: 'internal',
},
},
},
list_simulators: {
type: 'object',
properties: {
bootedOnly: {
type: 'boolean',
description: 'Only show booted simulators (default: true)',
default: true,
},
},
},
get_simulator_info: {
type: 'object',
properties: {},
},
tap_screen: {
type: 'object',
properties: {
x: {
type: 'number',
description: 'X coordinate to tap',
},
y: {
type: 'number',
description: 'Y coordinate to tap',
},
},
required: ['x', 'y'],
},
swipe: {
type: 'object',
properties: {
x1: {
type: 'number',
description: 'Starting X coordinate',
},
y1: {
type: 'number',
description: 'Starting Y coordinate',
},
x2: {
type: 'number',
description: 'Ending X coordinate',
},
y2: {
type: 'number',
description: 'Ending Y coordinate',
},
duration: {
type: 'number',
description: 'Duration of swipe in seconds (default: 0.5)',
default: 0.5,
},
},
required: ['x1', 'y1', 'x2', 'y2'],
},
simulator_type_text: {
type: 'object',
properties: {
text: {
type: 'string',
description: 'Text to type into the simulator',
},
},
required: ['text'],
},
press_button: {
type: 'object',
properties: {
button: {
type: 'string',
enum: ['home', 'lock', 'siri', 'volume_up', 'volume_down'],
description: 'System button to press (home, lock, siri, volume_up, volume_down)',
},
},
required: ['button'],
},
open_url: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL to open in the simulator (e.g., app deep link or web URL)',
},
},
required: ['url'],
},
erase_simulator: {
type: 'object',
properties: {},
},
get_ui_tree: {
type: 'object',
properties: {
format: {
type: 'string',
enum: ['json', 'xml'],
description: 'Format of the UI tree (default: json)',
default: 'json',
},
},
},
};
// Tool definitions for listing
export const IOS_SIMULATOR_TOOLS = [
{
name: 'capture_simulator_screenshot',
description: 'Captures a screenshot of the currently booted iOS simulator. Returns the image as base64-encoded data.',
inputSchema: IOS_SIMULATOR_SCHEMAS.capture_simulator_screenshot,
},
{
name: 'list_simulators',
description: 'Lists all iOS simulators, showing their names, UDIDs, and state (booted/shutdown).',
inputSchema: IOS_SIMULATOR_SCHEMAS.list_simulators,
},
{
name: 'get_simulator_info',
description: 'Gets information about the currently booted iOS simulator.',
inputSchema: IOS_SIMULATOR_SCHEMAS.get_simulator_info,
},
{
name: 'tap_screen',
description: 'Taps the simulator screen at the specified coordinates (x, y).',
inputSchema: IOS_SIMULATOR_SCHEMAS.tap_screen,
},
{
name: 'swipe',
description: 'Performs a swipe gesture on the simulator screen from (x1, y1) to (x2, y2).',
inputSchema: IOS_SIMULATOR_SCHEMAS.swipe,
},
{
name: 'simulator_type_text',
description: 'Types the specified text into the currently focused input field on the iOS simulator.',
inputSchema: IOS_SIMULATOR_SCHEMAS.simulator_type_text,
},
{
name: 'press_button',
description: 'Presses a system button on the simulator (home, lock, siri, volume_up, volume_down). Note: siri and volume buttons require fb-idb.',
inputSchema: IOS_SIMULATOR_SCHEMAS.press_button,
},
{
name: 'open_url',
description: 'Opens a URL in the simulator (supports app deep links and web URLs).',
inputSchema: IOS_SIMULATOR_SCHEMAS.open_url,
},
{
name: 'erase_simulator',
description: 'Erases all content and settings from the booted simulator (destructive operation).',
inputSchema: IOS_SIMULATOR_SCHEMAS.erase_simulator,
},
{
name: 'get_ui_tree',
description: 'Gets the UI accessibility tree of the current screen (requires fb-idb).',
inputSchema: IOS_SIMULATOR_SCHEMAS.get_ui_tree,
},
];
// Helper functions
export async function captureSimulatorScreenshot(
type: string = 'png',
mask: string = 'black',
display: string = 'internal'
): Promise<string> {
try {
// Create temporary directory
const tempDir = await mkdtemp(join(tmpdir(), 'mcp-ios-sim-'));
const screenshotPath = join(tempDir, `screenshot.${type}`);
// Capture screenshot
const cmd = `xcrun simctl io booted screenshot --type=${type} --display=${display} --mask=${mask} "${screenshotPath}"`;
await execAsync(cmd);
// Read the screenshot file and convert to base64
const imageBuffer = await readFile(screenshotPath);
const base64Image = imageBuffer.toString('base64');
// Clean up
await unlink(screenshotPath);
return base64Image;
} catch (error) {
throw new Error(`Failed to capture screenshot: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function listSimulators(bootedOnly: boolean = true): Promise<string> {
try {
const cmd = bootedOnly
? 'xcrun simctl list devices booted'
: 'xcrun simctl list devices';
const { stdout } = await execAsync(cmd);
return stdout;
} catch (error) {
throw new Error(`Failed to list simulators: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function getBootedSimulatorInfo(): Promise<{ name: string; udid: string } | null> {
try {
const { stdout } = await execAsync('xcrun simctl list devices booted --json');
const data = JSON.parse(stdout);
for (const runtime in data.devices) {
const devices = data.devices[runtime];
for (const device of devices) {
if (device.state === 'Booted') {
return { name: device.name, udid: device.udid };
}
}
}
return null;
} catch (error) {
return null;
}
}
export async function tapScreen(x: number, y: number): Promise<string> {
try {
if (isIdbAvailable()) {
// Get booted simulator UDID
const info = await getBootedSimulatorInfo();
if (!info) {
throw new Error('No simulator is currently booted');
}
// Use idb for more reliable tap gestures
const cmd = `idb ui tap ${x} ${y} --udid ${info.udid}`;
await execAsync(cmd);
return `Tapped at coordinates (${x}, ${y}) using idb`;
} else {
// Fallback to AppleScript
await execAsync(`osascript -e 'tell application "Simulator" to activate'`);
await new Promise(resolve => setTimeout(resolve, 100));
const script = `tell application "System Events" to click at {${x}, ${y}}`;
await execAsync(`osascript -e '${script}'`);
return `Tapped at coordinates (${x}, ${y}) using AppleScript (consider installing fb-idb for better reliability: brew tap facebook/fb && brew install idb-companion)`;
}
} catch (error) {
throw new Error(`Failed to tap screen: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function swipe(x1: number, y1: number, x2: number, y2: number, duration: number = 0.5): Promise<string> {
try {
if (isIdbAvailable()) {
// Get booted simulator UDID
const info = await getBootedSimulatorInfo();
if (!info) {
throw new Error('No simulator is currently booted');
}
// Use idb for swipe gestures
// idb ui swipe takes: x_start y_start x_end y_end --duration DURATION --udid UDID
const cmd = `idb ui swipe ${x1} ${y1} ${x2} ${y2} --duration ${duration} --udid ${info.udid}`;
await execAsync(cmd);
return `Swiped from (${x1}, ${y1}) to (${x2}, ${y2}) over ${duration}s using idb`;
} else {
// Fallback message
return `Swipe gesture requires fb-idb. Install with: brew tap facebook/fb && brew install idb-companion`;
}
} catch (error) {
throw new Error(`Failed to swipe: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function simulatorTypeText(text: string): Promise<string> {
try {
if (isIdbAvailable()) {
// Get booted simulator UDID
const info = await getBootedSimulatorInfo();
if (!info) {
throw new Error('No simulator is currently booted');
}
// Use idb for text input
const escapedText = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const cmd = `idb ui text "${escapedText}" --udid ${info.udid}`;
await execAsync(cmd);
return `Typed text: "${text}" using idb`;
}
// Fallback: attempt simctl (may not be supported)
const escapedText = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const cmd = `xcrun simctl io booted keyboardInput "${escapedText}"`;
await execAsync(cmd);
return `Typed text: "${text}"`;
} catch (error) {
throw new Error(`Failed to type text: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function pressButton(button: string): Promise<string> {
try {
if (isIdbAvailable()) {
// Get booted simulator UDID
const info = await getBootedSimulatorInfo();
if (!info) {
throw new Error('No simulator is currently booted');
}
// Use idb for button presses
const buttonMap: Record<string, string> = {
'home': 'HOME',
'lock': 'LOCK',
'siri': 'SIRI',
'volume_up': 'VOLUME_UP',
'volume_down': 'VOLUME_DOWN',
};
const idbButton = buttonMap[button];
if (idbButton) {
const cmd = `idb ui button ${idbButton} --udid ${info.udid}`;
await execAsync(cmd);
return `Pressed ${button} button using idb`;
}
}
// Fallback: use AppleScript to send key commands
if (button === 'home') {
// Cmd+Shift+H is the shortcut for home button in Simulator
await execAsync(`osascript -e 'tell application "Simulator" to activate'`);
await new Promise(resolve => setTimeout(resolve, 100));
await execAsync(`osascript -e 'tell application "System Events" to keystroke "h" using {command down, shift down}'`);
return `Pressed ${button} button using keyboard shortcut (Cmd+Shift+H)`;
} else if (button === 'lock') {
// Cmd+L locks the device
await execAsync(`osascript -e 'tell application "Simulator" to activate'`);
await new Promise(resolve => setTimeout(resolve, 100));
await execAsync(`osascript -e 'tell application "System Events" to keystroke "l" using {command down}'`);
return `Pressed ${button} button using keyboard shortcut (Cmd+L)`;
}
return `Button ${button} requires fb-idb. Install with: brew tap facebook/fb && brew install idb-companion`;
} catch (error) {
throw new Error(`Failed to press button: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function openUrl(url: string): Promise<string> {
try {
const cmd = `xcrun simctl openurl booted "${url}"`;
await execAsync(cmd);
return `Opened URL: ${url}`;
} catch (error) {
throw new Error(`Failed to open URL: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function getUITree(format: string = 'json'): Promise<string> {
try {
if (isIdbAvailable()) {
// Get booted simulator UDID
const info = await getBootedSimulatorInfo();
if (!info) {
throw new Error('No simulator is currently booted');
}
const cmd = `idb ui describe-all --json --udid ${info.udid}`;
const { stdout } = await execAsync(cmd);
return stdout;
} else {
return `UI tree inspection requires fb-idb. Install with: brew tap facebook/fb && brew install idb-companion`;
}
} catch (error) {
throw new Error(`Failed to get UI tree: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Tool handler
export async function handleIOSSimulatorTool(name: string, args: any): Promise<any> {
switch (name) {
case 'capture_simulator_screenshot': {
const { type = 'png', mask = 'black', display = 'internal' } = args as {
type?: string;
mask?: string;
display?: string;
};
const base64Image = await captureSimulatorScreenshot(type, mask, display);
const simulatorInfo = await getBootedSimulatorInfo();
return {
content: [
{
type: 'text',
text: simulatorInfo
? `Screenshot captured from ${simulatorInfo.name} (${simulatorInfo.udid})`
: 'Screenshot captured from booted simulator',
},
{
type: 'image',
data: base64Image,
mimeType: type === 'png' ? 'image/png' : `image/${type}`,
},
],
};
}
case 'list_simulators': {
const { bootedOnly = true } = args as { bootedOnly?: boolean };
const simulatorList = await listSimulators(bootedOnly);
return {
content: [
{
type: 'text',
text: simulatorList,
},
],
};
}
case 'get_simulator_info': {
const info = await getBootedSimulatorInfo();
if (!info) {
return {
content: [
{
type: 'text',
text: 'No simulator is currently booted.',
},
],
};
}
return {
content: [
{
type: 'text',
text: `Booted Simulator:\nName: ${info.name}\nUDID: ${info.udid}`,
},
],
};
}
case 'tap_screen': {
const { x, y } = args as { x: number; y: number };
const result = await tapScreen(x, y);
return {
content: [
{
type: 'text',
text: result,
},
],
};
}
case 'swipe': {
const { x1, y1, x2, y2, duration = 0.5 } = args as {
x1: number;
y1: number;
x2: number;
y2: number;
duration?: number;
};
const result = await swipe(x1, y1, x2, y2, duration);
return {
content: [
{
type: 'text',
text: result,
},
],
};
}
case 'simulator_type_text': {
const { text } = args as { text: string };
const result = await simulatorTypeText(text);
return {
content: [
{
type: 'text',
text: result,
},
],
};
}
case 'press_button': {
const { button } = args as { button: string };
const result = await pressButton(button);
return {
content: [
{
type: 'text',
text: result,
},
],
};
}
case 'open_url': {
const { url } = args as { url: string };
const result = await openUrl(url);
return {
content: [
{
type: 'text',
text: result,
},
],
};
}
case 'erase_simulator': {
const cmd = 'xcrun simctl erase booted';
await execAsync(cmd);
const result = 'Simulator erased successfully. All content and settings removed.';
return {
content: [
{
type: 'text',
text: result,
},
],
};
}
case 'get_ui_tree': {
const { format = 'json' } = args as { format?: string };
const result = await getUITree(format);
return {
content: [
{
type: 'text',
text: result,
},
],
};
}
default:
return null;
}
}
// Check if a tool name belongs to iOS simulator tools
export function isIOSSimulatorTool(name: string): boolean {
return IOS_SIMULATOR_TOOLS.some(tool => tool.name === name);
}