#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { execFile, spawn } from "child_process";
import { promisify } from "util";
import { z } from "zod";
import path from "path";
import os from "os";
import fs from "fs";
const execFileAsync = promisify(execFile);
/**
* Strict UDID/UUID pattern: 8-4-4-4-12 hexadecimal characters (e.g. 37A360EC-75F9-4AEC-8EFA-10F4A58D8CCA)
*/
const UDID_REGEX =
/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
const TMP_ROOT_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "ios-simulator-mcp-")
);
/**
* Runs a command with arguments and returns the stdout and stderr
* @param cmd - The command to run
* @param args - The arguments to pass to the command
* @returns The stdout and stderr of the command
*/
async function run(
cmd: string,
args: string[]
): Promise<{ stdout: string; stderr: string }> {
const { stdout, stderr } = await execFileAsync(cmd, args, { shell: false });
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
};
}
/**
* Gets the IDB command path from environment variable or defaults to "idb"
* @returns The path to the IDB executable
* @throws Error if custom path is specified but doesn't exist
*/
function getIdbPath(): string {
const customPath = process.env.IOS_SIMULATOR_MCP_IDB_PATH;
if (customPath) {
// Expand tilde if present
const expandedPath = customPath.startsWith("~/")
? path.join(os.homedir(), customPath.slice(2))
: customPath;
// Check if the path exists
if (!fs.existsSync(expandedPath)) {
throw new Error(
`Custom IDB path specified in IOS_SIMULATOR_MCP_IDB_PATH does not exist: ${expandedPath}`
);
}
return expandedPath;
}
return "idb";
}
/**
* Runs the idb command with the given arguments
* @param args - arguments to pass to the idb command
* @returns The stdout and stderr of the command
* @see https://fbidb.io/docs/commands for documentation of available idb commands
*/
async function idb(...args: string[]) {
return run(getIdbPath(), args);
}
// Read filtered tools from environment variable
const FILTERED_TOOLS =
process.env.IOS_SIMULATOR_MCP_FILTERED_TOOLS?.split(",").map((tool) =>
tool.trim()
) || [];
// Function to check if a tool is filtered
function isToolFiltered(toolName: string): boolean {
return FILTERED_TOOLS.includes(toolName);
}
const server = new McpServer({
name: "ios-simulator",
version: require("../package.json").version,
});
function toError(input: unknown): Error {
if (input instanceof Error) return input;
if (
typeof input === "object" &&
input &&
"message" in input &&
typeof input.message === "string"
)
return new Error(input.message);
return new Error(JSON.stringify(input));
}
function troubleshootingLink(): string {
return "[Troubleshooting Guide](https://github.com/joshuayoes/ios-simulator-mcp/blob/main/TROUBLESHOOTING.md) | [Plain Text Guide for LLMs](https://raw.githubusercontent.com/joshuayoes/ios-simulator-mcp/refs/heads/main/TROUBLESHOOTING.md)";
}
function errorWithTroubleshooting(message: string): string {
return `${message}\n\nFor help, see the ${troubleshootingLink()}`;
}
async function getBootedDevice() {
const { stdout, stderr } = await run("xcrun", ["simctl", "list", "devices"]);
if (stderr) throw new Error(stderr);
// Parse the output to find booted device
const lines = stdout.split("\n");
for (const line of lines) {
if (line.includes("Booted")) {
// Extract the UUID - it's inside parentheses
const match = line.match(/\(([-0-9A-F]+)\)/);
if (match) {
const deviceId = match[1];
const deviceName = line.split("(")[0].trim();
return {
name: deviceName,
id: deviceId,
};
}
}
}
throw Error("No booted simulator found");
}
async function getBootedDeviceId(
deviceId: string | undefined
): Promise<string> {
// If deviceId not provided, get the currently booted simulator
let actualDeviceId = deviceId;
if (!actualDeviceId) {
const { id } = await getBootedDevice();
actualDeviceId = id;
}
if (!actualDeviceId) {
throw new Error("No booted simulator found and no deviceId provided");
}
return actualDeviceId;
}
// Register tools only if they're not filtered
if (!isToolFiltered("get_booted_sim_id")) {
server.tool(
"get_booted_sim_id",
"Get the ID of the currently booted iOS simulator",
async () => {
try {
const { id, name } = await getBootedDevice();
return {
isError: false,
content: [
{
type: "text",
text: `Booted Simulator: "${name}". UUID: "${id}"`,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: errorWithTroubleshooting(
`Error: ${toError(error).message}`
),
},
],
};
}
}
);
}
if (!isToolFiltered("open_simulator")) {
server.tool(
"open_simulator",
"Opens the iOS Simulator application",
async () => {
try {
await run("open", ["-a", "Simulator.app"]);
return {
isError: false,
content: [
{
type: "text",
text: "Simulator.app opened successfully",
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: errorWithTroubleshooting(
`Error opening Simulator.app: ${toError(error).message}`
),
},
],
};
}
}
);
}
if (!isToolFiltered("ui_describe_all")) {
server.tool(
"ui_describe_all",
"Describes accessibility information for the entire screen in the iOS Simulator",
{
udid: z
.string()
.regex(UDID_REGEX)
.optional()
.describe("Udid of target, can also be set with the IDB_UDID env var"),
},
async ({ udid }) => {
try {
const actualUdid = await getBootedDeviceId(udid);
const { stdout } = await idb(
"ui",
"describe-all",
"--udid",
actualUdid,
"--json",
"--nested"
);
return {
isError: false,
content: [{ type: "text", text: stdout }],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: errorWithTroubleshooting(
`Error describing all of the ui: ${toError(error).message}`
),
},
],
};
}
}
);
}
if (!isToolFiltered("ui_tap")) {
server.tool(
"ui_tap",
"Tap on the screen in the iOS Simulator",
{
duration: z
.string()
.regex(/^\d+(\.\d+)?$/)
.optional()
.describe("Press duration"),
udid: z
.string()
.regex(UDID_REGEX)
.optional()
.describe("Udid of target, can also be set with the IDB_UDID env var"),
x: z.number().describe("The x-coordinate"),
y: z.number().describe("The x-coordinate"),
},
async ({ duration, udid, x, y }) => {
try {
const actualUdid = await getBootedDeviceId(udid);
const { stderr } = await idb(
"ui",
"tap",
"--udid",
actualUdid,
...(duration ? ["--duration", duration] : []),
"--json",
// When passing user-provided values to a command, it's crucial to use `--`
// to separate the command's options from positional arguments.
// This prevents the shell from misinterpreting the arguments as options.
"--",
String(x),
String(y)
);
if (stderr) throw new Error(stderr);
return {
isError: false,
content: [{ type: "text", text: "Tapped successfully" }],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: errorWithTroubleshooting(
`Error tapping on the screen: ${toError(error).message}`
),
},
],
};
}
}
);
}
if (!isToolFiltered("ui_type")) {
server.tool(
"ui_type",
"Input text into the iOS Simulator",
{
udid: z
.string()
.regex(UDID_REGEX)
.optional()
.describe("Udid of target, can also be set with the IDB_UDID env var"),
text: z
.string()
.max(500)
.regex(/^[\x20-\x7E]+$/)
.describe("Text to input"),
},
async ({ udid, text }) => {
try {
const actualUdid = await getBootedDeviceId(udid);
const { stderr } = await idb(
"ui",
"text",
"--udid",
actualUdid,
// When passing user-provided values to a command, it's crucial to use `--`
// to separate the command's options from positional arguments.
// This prevents the shell from misinterpreting the arguments as options.
"--",
text
);
if (stderr) throw new Error(stderr);
return {
isError: false,
content: [{ type: "text", text: "Typed successfully" }],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: errorWithTroubleshooting(
`Error typing text into the iOS Simulator: ${
toError(error).message
}`
),
},
],
};
}
}
);
}
if (!isToolFiltered("ui_swipe")) {
server.tool(
"ui_swipe",
"Swipe on the screen in the iOS Simulator",
{
duration: z
.string()
.regex(/^\d+(\.\d+)?$/)
.optional()
.describe("Swipe duration in seconds (e.g., 0.1)"),
udid: z
.string()
.regex(UDID_REGEX)
.optional()
.describe("Udid of target, can also be set with the IDB_UDID env var"),
x_start: z.number().describe("The starting x-coordinate"),
y_start: z.number().describe("The starting y-coordinate"),
x_end: z.number().describe("The ending x-coordinate"),
y_end: z.number().describe("The ending y-coordinate"),
delta: z
.number()
.optional()
.describe("The size of each step in the swipe (default is 1)")
.default(1),
},
async ({ duration, udid, x_start, y_start, x_end, y_end, delta }) => {
try {
const actualUdid = await getBootedDeviceId(udid);
const { stderr } = await idb(
"ui",
"swipe",
"--udid",
actualUdid,
...(duration ? ["--duration", duration] : []),
...(delta ? ["--delta", String(delta)] : []),
"--json",
// When passing user-provided values to a command, it's crucial to use `--`
// to separate the command's options from positional arguments.
// This prevents the shell from misinterpreting the arguments as options.
"--",
String(x_start),
String(y_start),
String(x_end),
String(y_end)
);
if (stderr) throw new Error(stderr);
return {
isError: false,
content: [{ type: "text", text: "Swiped successfully" }],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: errorWithTroubleshooting(
`Error swiping on the screen: ${toError(error).message}`
),
},
],
};
}
}
);
}
if (!isToolFiltered("ui_describe_point")) {
server.tool(
"ui_describe_point",
"Returns the accessibility element at given co-ordinates on the iOS Simulator's screen",
{
udid: z
.string()
.regex(UDID_REGEX)
.optional()
.describe("Udid of target, can also be set with the IDB_UDID env var"),
x: z.number().describe("The x-coordinate"),
y: z.number().describe("The y-coordinate"),
},
async ({ udid, x, y }) => {
try {
const actualUdid = await getBootedDeviceId(udid);
const { stdout, stderr } = await idb(
"ui",
"describe-point",
"--udid",
actualUdid,
"--json",
// When passing user-provided values to a command, it's crucial to use `--`
// to separate the command's options from positional arguments.
// This prevents the shell from misinterpreting the arguments as options.
"--",
String(x),
String(y)
);
if (stderr) throw new Error(stderr);
return {
isError: false,
content: [{ type: "text", text: stdout }],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: errorWithTroubleshooting(
`Error describing point (${x}, ${y}): ${toError(error).message}`
),
},
],
};
}
}
);
}
if (!isToolFiltered("ui_view")) {
server.tool(
"ui_view",
"Get the image content of a compressed screenshot of the current simulator view",
{
udid: z
.string()
.regex(UDID_REGEX)
.optional()
.describe("Udid of target, can also be set with the IDB_UDID env var"),
},
async ({ udid }) => {
try {
const actualUdid = await getBootedDeviceId(udid);
// Get screen dimensions in points from ui_describe_all
const { stdout: uiDescribeOutput } = await idb(
"ui",
"describe-all",
"--udid",
actualUdid,
"--json",
"--nested"
);
const uiData = JSON.parse(uiDescribeOutput);
const screenFrame = uiData[0]?.frame;
if (!screenFrame) {
throw new Error("Could not determine screen dimensions");
}
const pointWidth = screenFrame.width;
const pointHeight = screenFrame.height;
// Generate unique file names with timestamp
const ts = Date.now();
const rawPng = path.join(TMP_ROOT_DIR, `ui-view-${ts}-raw.png`);
const compressedJpg = path.join(
TMP_ROOT_DIR,
`ui-view-${ts}-compressed.jpg`
);
// Capture screenshot as PNG
await run("xcrun", [
"simctl",
"io",
actualUdid,
"screenshot",
"--type=png",
"--",
rawPng,
]);
// Resize to match point dimensions and compress to JPEG using sips
await run("sips", [
"-z",
String(pointHeight), // height in points
String(pointWidth), // width in points
"-s",
"format",
"jpeg",
"-s",
"formatOptions",
"80", // 80% quality
rawPng,
"--out",
compressedJpg,
]);
// Read and encode the compressed image
const imageData = fs.readFileSync(compressedJpg);
const base64Data = imageData.toString("base64");
return {
isError: false,
content: [
{
type: "image",
data: base64Data,
mimeType: "image/jpeg",
},
{
type: "text",
text: "Screenshot captured",
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: errorWithTroubleshooting(
`Error capturing screenshot: ${toError(error).message}`
),
},
],
};
}
}
);
}
function ensureAbsolutePath(filePath: string): string {
if (path.isAbsolute(filePath)) {
return filePath;
}
// Handle ~/something paths in the provided filePath
if (filePath.startsWith("~/")) {
return path.join(os.homedir(), filePath.slice(2));
}
// Determine the default directory from env var or fallback to ~/Downloads
let defaultDir = path.join(os.homedir(), "Downloads");
const customDefaultDir = process.env.IOS_SIMULATOR_MCP_DEFAULT_OUTPUT_DIR;
if (customDefaultDir) {
// also expand tilde for the custom directory path
if (customDefaultDir.startsWith("~/")) {
defaultDir = path.join(os.homedir(), customDefaultDir.slice(2));
} else {
defaultDir = customDefaultDir;
}
}
// Join the relative filePath with the resolved default directory
return path.join(defaultDir, filePath);
}
if (!isToolFiltered("screenshot")) {
server.tool(
"screenshot",
"Takes a screenshot of the iOS Simulator",
{
udid: z
.string()
.regex(UDID_REGEX)
.optional()
.describe("Udid of target, can also be set with the IDB_UDID env var"),
output_path: z
.string()
.max(1024)
.describe(
"File path where the screenshot will be saved. If relative, it uses the directory specified by the `IOS_SIMULATOR_MCP_DEFAULT_OUTPUT_DIR` env var, or `~/Downloads` if not set."
),
type: z
.enum(["png", "tiff", "bmp", "gif", "jpeg"])
.optional()
.describe(
"Image format (png, tiff, bmp, gif, or jpeg). Default is png."
),
display: z
.enum(["internal", "external"])
.optional()
.describe(
"Display to capture (internal or external). Default depends on device type."
),
mask: z
.enum(["ignored", "alpha", "black"])
.optional()
.describe(
"For non-rectangular displays, handle the mask by policy (ignored, alpha, or black)"
),
},
async ({ udid, output_path, type, display, mask }) => {
try {
const actualUdid = await getBootedDeviceId(udid);
const absolutePath = ensureAbsolutePath(output_path);
// command is weird, it responds with stderr on success and stdout is blank
const { stderr: stdout } = await run("xcrun", [
"simctl",
"io",
actualUdid,
"screenshot",
...(type ? [`--type=${type}`] : []),
...(display ? [`--display=${display}`] : []),
...(mask ? [`--mask=${mask}`] : []),
// When passing user-provided values to a command, it's crucial to use `--`
// to separate the command's options from positional arguments.
// This prevents the shell from misinterpreting the arguments as options.
"--",
absolutePath,
]);
// throw if we don't get the expected success message
if (stdout && !stdout.includes("Wrote screenshot to")) {
throw new Error(stdout);
}
return {
isError: false,
content: [
{
type: "text",
text: stdout,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: errorWithTroubleshooting(
`Error taking screenshot: ${toError(error).message}`
),
},
],
};
}
}
);
}
if (!isToolFiltered("record_video")) {
server.tool(
"record_video",
"Records a video of the iOS Simulator using simctl directly",
{
output_path: z
.string()
.max(1024)
.optional()
.describe(
`Optional output path. If not provided, a default name will be used. The file will be saved in the directory specified by \`IOS_SIMULATOR_MCP_DEFAULT_OUTPUT_DIR\` or in \`~/Downloads\` if the environment variable is not set.`
),
codec: z
.enum(["h264", "hevc"])
.optional()
.describe(
'Specifies the codec type: "h264" or "hevc". Default is "hevc".'
),
display: z
.enum(["internal", "external"])
.optional()
.describe(
'Display to capture: "internal" or "external". Default depends on device type.'
),
mask: z
.enum(["ignored", "alpha", "black"])
.optional()
.describe(
'For non-rectangular displays, handle the mask by policy: "ignored", "alpha", or "black".'
),
force: z
.boolean()
.optional()
.describe(
"Force the output file to be written to, even if the file already exists."
),
},
async ({ output_path, codec, display, mask, force }) => {
try {
const defaultFileName = `simulator_recording_${Date.now()}.mp4`;
const outputFile = ensureAbsolutePath(output_path ?? defaultFileName);
// Start the recording process
const recordingProcess = spawn("xcrun", [
"simctl",
"io",
"booted",
"recordVideo",
...(codec ? [`--codec=${codec}`] : []),
...(display ? [`--display=${display}`] : []),
...(mask ? [`--mask=${mask}`] : []),
...(force ? ["--force"] : []),
// When passing user-provided values to a command, it's crucial to use `--`
// to separate the command's options from positional arguments.
// This prevents the shell from misinterpreting the arguments as options.
"--",
outputFile,
]);
// Wait for recording to start
await new Promise((resolve, reject) => {
let errorOutput = "";
recordingProcess.stderr.on("data", (data) => {
const message = data.toString();
if (message.includes("Recording started")) {
resolve(true);
} else {
errorOutput += message;
}
});
// Set timeout for start verification
setTimeout(() => {
if (recordingProcess.killed) {
reject(new Error("Recording process terminated unexpectedly"));
} else {
resolve(true);
}
}, 3000);
});
return {
isError: false,
content: [
{
type: "text",
text: `Recording started. The video will be saved to: ${outputFile}\nTo stop recording, use the stop_recording command.`,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: errorWithTroubleshooting(
`Error starting recording: ${toError(error).message}`
),
},
],
};
}
}
);
}
if (!isToolFiltered("stop_recording")) {
server.tool(
"stop_recording",
"Stops the simulator video recording using killall",
{},
async () => {
try {
await run("pkill", ["-SIGINT", "-f", "simctl.*recordVideo"]);
// Wait a moment for the video to finalize
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
isError: false,
content: [
{
type: "text",
text: "Recording stopped successfully.",
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: errorWithTroubleshooting(
`Error stopping recording: ${toError(error).message}`
),
},
],
};
}
}
);
}
if (!isToolFiltered("install_app")) {
server.tool(
"install_app",
"Installs an app bundle (.app or .ipa) on the iOS Simulator",
{
udid: z
.string()
.regex(UDID_REGEX)
.optional()
.describe("Udid of target, can also be set with the IDB_UDID env var"),
app_path: z
.string()
.max(1024)
.describe(
"Path to the app bundle (.app directory or .ipa file) to install"
),
},
async ({ udid, app_path }) => {
try {
const actualUdid = await getBootedDeviceId(udid);
const absolutePath = path.isAbsolute(app_path)
? app_path
: path.resolve(app_path);
// Check if the app bundle exists
if (!fs.existsSync(absolutePath)) {
throw new Error(`App bundle not found at: ${absolutePath}`);
}
// run() will throw if the command fails (non-zero exit code)
await run("xcrun", ["simctl", "install", actualUdid, absolutePath]);
return {
isError: false,
content: [
{
type: "text",
text: `App installed successfully from: ${absolutePath}`,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: errorWithTroubleshooting(
`Error installing app: ${toError(error).message}`
),
},
],
};
}
}
);
}
if (!isToolFiltered("launch_app")) {
server.tool(
"launch_app",
"Launches an app on the iOS Simulator by bundle identifier",
{
udid: z
.string()
.regex(UDID_REGEX)
.optional()
.describe("Udid of target, can also be set with the IDB_UDID env var"),
bundle_id: z
.string()
.max(256)
.describe(
"Bundle identifier of the app to launch (e.g., com.apple.mobilesafari)"
),
terminate_running: z
.boolean()
.optional()
.describe(
"Terminate the app if it is already running before launching"
),
},
async ({ udid, bundle_id, terminate_running }) => {
try {
const actualUdid = await getBootedDeviceId(udid);
// run() will throw if the command fails (non-zero exit code)
const { stdout } = await run("xcrun", [
"simctl",
"launch",
...(terminate_running ? ["--terminate-running-process"] : []),
actualUdid,
bundle_id,
]);
// Extract PID from output if available
// simctl launch outputs the PID as the first token in stdout
const pidMatch = stdout.match(/^(\d+)/);
const pid = pidMatch ? pidMatch[1] : null;
return {
isError: false,
content: [
{
type: "text",
text: pid
? `App ${bundle_id} launched successfully with PID: ${pid}`
: `App ${bundle_id} launched successfully`,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: errorWithTroubleshooting(
`Error launching app: ${toError(error).message}`
),
},
],
};
}
}
);
}
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
runServer().catch(console.error);
process.stdin.on("close", () => {
console.log("iOS Simulator MCP Server closed");
server.close();
try {
fs.rmSync(TMP_ROOT_DIR, { recursive: true, force: true });
} catch (error) {
// Ignore cleanup errors
}
});