The MCP server · simgadget-mcp

Give every agent its own iPhone.

One server. Many agents. Many simulators. Nobody steps on anybody.

$npx -y simgadget-mcp
01 Sessions

Unlimited agents, unlimited simulators, no collisions.

Every tool takes an id naming your session, and each session owns a simulator. Because the state lives in the server rather than the client, a simulator survives its agent disconnecting — call start_simulator again with the same id and you resume where you left off.

Agent A id: "qa1" Agent B id: "qa2" Agent C id: "dev" MCP over HTTP · 127.0.0.1:8008 simgadget-mcp one process · one session registry gRPC to idb_companion · xcrun simctl iPhone 16 Pro owned by "qa1" iPad Air owned by "qa2" iPhone 16 Pro owned by "dev"
The advantage of http mcp With a http mcp you can connect to the mcp from inside a container, meaning agents can run in secure environments while still driving iOS simulators, from their own containers, or even remotely -- run your agents in the cloud, connect to your mac mini at home (please use a VPN or tailscale!)
02 Setup, in full

There is no step three.

No pipx install fb-idb. No brew install idb-companion. No Xcode command-line archaeology.

1. Start the server.

$npx -y simgadget-mcp

2. Point your agent at it.

$claude mcp add --transport http simgadget http://127.0.0.1:8008/mcp

Or, for a config-file client such as Cursor (~/.cursor/mcp.json):

mcp.json
{
  "mcpServers": {
    "simgadget": {
      "type": "http",
      "url": "http://127.0.0.1:8008/mcp"
    }
  }
}

SKILLS.md?

Not needed, the mcp has built-in documentation that it is all the agent needs to successfully navigate the Simulator.

Agent running in a container?

The simulators live on the host, so run the server there and point the container at host.docker.internal — which is allowlisted out of the box, along with Podman's equivalent.

from inside the container
{ "type": "http",
  "url": "http://host.docker.internal:8008/mcp" }

Safe by default

  • The HTTP transport binds to 127.0.0.1.
  • It checks Host headers against an allowlist, so a web page you happen to visit can't drive your simulators — and tells you exactly what it rejected and how to permit it.
  • Owned simulators are cleaned up when the server exits, so a day's work doesn't leak twenty simulators.
03 In practice

What you say, and what it runs.

You describe the outcome. The agent picks the tools. Scroll the transcript.

Launch the simulator and install my app.

Running commands

start_simulator { id: "qa1", device_type: "iPhone 16 Pro" }
install_app     { id: "qa1", path: "./build/MyApp.app" }
launch_app      { id: "qa1", bundle_id: "com.example.myapp" }

Enter a fake username and password, then tap login.

Running commands

ui_describe_all { id: "qa1" }  // what are the fields called?

ui_tap  { id: "qa1", label: "Email" }
ui_type { id: "qa1", text: "test@example.com" }
ui_tap  { id: "qa1", label: "Password" }
ui_type { id: "qa1", text: "hunter2" }
ui_tap  { id: "qa1", label: "Log In" }

Turn Sound on in settings — and make sure it actually took.

Running commands

ui_tap { id: "qa1", label: "Sound" }
‹ Toggled Sound off -> on.  // read back, not assumed

Now try the stepper at the bottom of the list.

Running commands

ui_tap { id: "qa1", label: "Stepper" }
‹ Refused: "Stepper" resolved at (188, 604) but the touch
  would land on "Search" (SearchField). It is covered, off
  screen, or scrolled out of view.

ui_swipe { id: "qa1", from: { x: 200, y: 600 }, to: { x: 200, y: 300 } }
ui_tap   { id: "qa1", label: "Stepper" }
‹ Tapped "Stepper" (Stepper) at (188, 304).

Does the settings screen look right in landscape?

Running commands

rotate  { id: "qa1", orientation: "landscape_left" }
‹ Rotated to landscape_left; the interface adopted landscape_left.

ui_view { id: "qa1" }  // screenshot back to the model, right way up

Record a clip of the whole signup flow.

Running commands

record_video   { id: "qa1", path: "./signup.mov" }
// …drives the flow…
stop_recording { id: "qa1" }
‹ Saved ./signup.mov
04 Designed for a model to use

An agent's biggest costs are tokens and wrong turns.

Every tool is shaped against both.

Tap by name

ui_tap { label: "Sign Up" } — the simulator resolves the element and operates it. About 340 bytes, versus 7–10 KB for a screen tree. The model never sees a coordinate.

Refusals are the useful answer

A control that is covered, disabled or scrolled out of view is refused, naming what's in the way, rather than silently missed. That turns a whole class of "the test passed but nothing happened" into an error the agent can react to.

Every reply names what it did

Substring matching means the first hit isn't always the one you meant. Naming the element it acted on puts that where the model notices immediately.

Misses are ordinary answers

ui_find reports "not found" as a result, not an error, so an agent can branch instead of spiralling.

start_simulator doesn't return until the simulator answers

And if it runs out of budget it says so and hands back the UDID, rather than being killed mid-wait by the client's timeout and telling you nothing.

The server tells the agent how to use it

Tool descriptions and server instructions are written for a model — including when not to reach for the expensive tool.

what the model seesverbatim
 ui_tap { id: "qa1", label: "Toolbar Button" }
 Tapped "Toolbar Button" (Button) at (102, 822).

 ui_tap { id: "qa1", label: "Sound" }
 Toggled Sound off -> on.

 ui_tap { id: "qa1", label: "Stepper" }
 Refused: "Stepper" resolved at (188, 604) but
  the touch would land on "Search" (SearchField).
  It is covered, off screen, or scrolled out of view.

 ui_find { id: "qa1", label: "Checkout" }
 No element found matching "Checkout".
Pro tip: you don't need a frontier model for this. Cheap fast models are perfectly good at navigating an app and comparing screenshots — the tools do the hard part. Haiku is nearly fast enough to record demo videos in real time.
05 The tools

MCP Tools

ToolDoes
start_simulatorCreate, boot and open a simulator for this session
destroy_simulatorShut it down and delete it — or just detach, if it was attached
attach_simulatorAdopt an already-booted simulator by UDID
ui_tapOperate an element by name, or tap at coordinates
ui_findResolve one element by label, visible text or identifier
ui_describe_allThe whole screen's accessibility tree, pruned to what you can act on
ui_describe_pointWhat's at these coordinates
ui_typeType text
ui_swipeSwipe
ui_viewCompressed screenshot, inline
rotateRotate, then report what the interface actually adopted
detect_rotationRe-probe orientation and fix the coordinate mapping
screenshotSave a screenshot to a file
record_video / stop_recordingRecord the screen
install_app / launch_appInstall an app bundle, and launch it
06 Configuration

Command-line flags

Precedence is flag, then environment variable, then default.

$npx -y simgadget-mcp --port 8008 --verbose
FlagDefaultEnvironment variableDescription
--port <n>8008SIMGADGET_HTTP_PORTListen port in HTTP mode
--host <addr>127.0.0.1SIMGADGET_HTTP_HOSTBind address
--httpdefaultSIMGADGET_TRANSPORT=httpServe over HTTP
--stdioSIMGADGET_TRANSPORT=stdioServe over stdio instead. One client per process; no shared sessions
--transport <name>httpSIMGADGET_TRANSPORTLong form of the two above
--verbose, -voffSIMGADGET_VERBOSELog activity to stderr

Environment variables

These have no flag. The last two are read by the library, and behave identically whether you use it directly or through this server.

VariableDefaultDescription
SIMGADGET_ALLOWED_HOSTSloopback + container host aliasesExtra host:port values accepted in the HTTP Host header, comma separated
SIMGADGET_CLEANUP_ON_EXITtrueDelete simulators this server created when it exits
SIMGADGET_DEFAULT_OUTPUT_DIRWhere screenshots and recordings land when a tool is given no path
SIMGADGET_FILTERED_TOOLSComma-separated tool names to hide from clients
SIMGADGET_COMPANION_PATHpinned buildUse this idb_companion binary instead of the pinned one
SIMGADGET_COMPANION_CACHE~/Library/Caches/simgadgetWhere the downloaded companion is cached

Migrating from ios-multi-simulator-mcp: the old IOS_SIMULATOR_MCP_* variable names are still read for two releases, with one deprecation line on stderr naming the replacement.

One command, and your agent has a phone.

Then start_simulator { id: "qa1" } and it's driving.

$npx -y simgadget-mcp
$claude mcp add --transport http simgadget http://127.0.0.1:8008/mcp