iOS Simulator MCP
by joshuayoes
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { exec, spawn } from "child_process";
import { promisify } from "util";
import { z } from "zod";
import path from "path";
import os from "os";
const execAsync = promisify(exec);
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));
}
async function getBootedDevice() {
const { stdout, stderr } = await execAsync("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;
}
server.tool(
"get_booted_sim_id",
"Get the ID of the currently booted iOS simulator",
async () => {
try {
const { id, name } = await getBootedDevice();
return {
content: [
{
type: "text",
text: `Booted Simulator: "${name}". UUID: "${id}"`,
},
],
};
} catch (error: any) {
return {
content: [
{ type: "text", text: `Error: ${error.message || String(error)}` },
],
};
}
}
);
server.tool(
"ui_describe_all",
"Describes accessibility information for the entire screen in the iOS Simulator",
{
udid: z
.string()
.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 execAsync(
`idb ui describe-all --udid ${actualUdid} --json --nested`
);
return {
content: [{ type: "text", text: stdout }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error describing all of the ui: ${toError(error).message}`,
},
],
};
}
}
);
server.tool(
"ui_tap",
"Tap on the screen in the iOS Simulator",
{
duration: z.string().optional().describe("Press duration"),
udid: z
.string()
.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 durationArg = duration ? `--duration ${duration}` : "";
const { stderr } = await execAsync(
`idb ui tap --udid ${actualUdid} ${durationArg} ${x} ${y} --json`
);
if (stderr) throw new Error(stderr);
return {
content: [{ type: "text", text: "Tapped successfully" }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error tapping on the screen: ${toError(error).message}`,
},
],
};
}
}
);
server.tool(
"ui_type",
"Input text into the iOS Simulator",
{
udid: z
.string()
.optional()
.describe("Udid of target, can also be set with the IDB_UDID env var"),
text: z.string().describe("Text to input"),
},
async ({ udid, text }) => {
try {
const actualUdid = await getBootedDeviceId(udid);
const { stderr } = await execAsync(
`idb ui text ${text} --udid ${actualUdid}`
);
if (stderr) throw new Error(stderr);
return {
content: [{ type: "text", text: "Typed successfully" }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error typing text into the iOS Simulator: ${
toError(error).message
}`,
},
],
};
}
}
);
server.tool(
"ui_swipe",
"Swipe on the screen in the iOS Simulator",
{
udid: z
.string()
.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 ({ udid, x_start, y_start, x_end, y_end, delta }) => {
try {
const actualUdid = await getBootedDeviceId(udid);
const deltaArg = delta ? `--delta ${delta}` : "";
const { stderr } = await execAsync(
`idb ui swipe --udid ${actualUdid} ${deltaArg} ${x_start} ${y_start} ${x_end} ${y_end} --json`
);
if (stderr) throw new Error(stderr);
return {
content: [{ type: "text", text: "Swiped successfully" }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error swiping on the screen: ${toError(error).message}`,
},
],
};
}
}
);
server.tool(
"ui_describe_point",
"Returns the accessibility element at given co-ordinates on the iOS Simulator's screen",
{
udid: z
.string()
.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 execAsync(
`idb ui describe-point --udid ${actualUdid} ${x} ${y} --json`
);
if (stderr) throw new Error(stderr);
return {
content: [{ type: "text", text: stdout }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error describing point (${x}, ${y}): ${
toError(error).message
}`,
},
],
};
}
}
);
function ensureAbsolutePath(filePath: string): string {
if (path.isAbsolute(filePath)) {
return filePath;
}
// Handle ~/something paths
if (filePath.startsWith("~/")) {
return path.join(os.homedir(), filePath.slice(2));
}
// For relative paths, use ~/Downloads as default directory
return path.join(os.homedir(), "Downloads", filePath);
}
server.tool(
"screenshot",
"Takes a screenshot of the iOS Simulator",
{
udid: z
.string()
.optional()
.describe("Udid of target, can also be set with the IDB_UDID env var"),
output_path: z
.string()
.describe(
"File path where the screenshot will be saved (if relative, ~/Downloads will be used as base directory)"
),
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);
let command = `xcrun simctl io ${actualUdid} screenshot ${absolutePath}`;
if (type) command += ` --type=${type}`;
if (display) command += ` --display=${display}`;
if (mask) command += ` --mask=${mask}`;
// command is weird, it responds with stderr on success and stdout is blank
const { stderr: stdout } = await execAsync(command);
// throw if we don't get the expected success message
if (stdout && !stdout.includes("Wrote screenshot to")) {
throw new Error(stdout);
}
return {
content: [
{
type: "text",
text: stdout,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error taking screenshot: ${toError(error).message}`,
},
],
};
}
}
);
server.tool(
"record_video",
"Records a video of the iOS Simulator using simctl directly",
{
output_path: z
.string()
.optional()
.describe(
`Optional output path (defaults to ~/Downloads/simulator_recording_$DATE.mp4)`
),
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);
// Build command arguments array
const args = ["simctl", "io", "booted", "recordVideo"];
if (codec) args.push(`--codec=${codec}`);
if (display) args.push(`--display=${display}`);
if (mask) args.push(`--mask=${mask}`);
if (force) args.push("--force");
args.push(outputFile);
// Start the recording process
const recordingProcess = spawn("xcrun", args);
// 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 {
content: [
{
type: "text",
text: `Recording started. The video will be saved to: ${outputFile}\nTo stop recording, use the stop_recording command.`,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error starting recording: ${toError(error).message}`,
},
],
};
}
}
);
server.tool(
"stop_recording",
"Stops the simulator video recording using killall",
{},
async () => {
try {
await execAsync('pkill -SIGINT -f "simctl.*recordVideo"');
// Wait a moment for the video to finalize
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
content: [
{
type: "text",
text: "Recording stopped successfully.",
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error stopping recording: ${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();
});