Use a physical iPhone
Simulators need only Xcode. A real device needs signing, Developer Mode, and,
on iOS 17+, a tunnel. Run uv run ios-mcp doctor at any point; it checks each
of these and returns a remedy.
1. Install go-ios
Section titled “1. Install go-ios”npm install -g go-iosios versiongo-ios handles device communication and launches WebDriverAgent through
testmanagerd without needing Xcode, which is also what allows a Linux host.
2. Prepare the device
Section titled “2. Prepare the device”- Connect over USB and tap Trust.
- Enable Settings > Privacy & Security > Developer Mode and reboot.
- Confirm it is visible:
ios list.
3. The tunnel (iOS 17 and later)
Section titled “3. The tunnel (iOS 17 and later)”iOS 17 moved device communication from TCP to QUIC + RemoteXPC, so a tunnel must exist before anything can reach a cabled device. Nothing to do: when a cabled device has none, the server starts go-ios’s userspace tunnel, which needs no sudo, and stops it when the session ends.
To run one yourself instead, for other go-ios commands or for several sessions to share:
ios tunnel start --userspace # no sudoThe kernel tunnel, sudo ios tunnel start (or ./scripts/start_tunnel.sh,
which carries a launchd plist for running it at boot), still works; set
IOS_MCP_GOIOS__TUNNEL_MODE=kernel to have the server start that one. A
phone on Wi-Fi needs no tunnel at all, since it is driven through xcodebuild.
4. Build and sign WebDriverAgent
Section titled “4. Build and sign WebDriverAgent”This step is three passes, and the order matters. WebDriverAgent is not checked in, so on a fresh clone the project you need to open does not exist yet. Fetch it first:
./scripts/prepare_wda.sh device # clones WebDriverAgent, then tries to signExpect that first run to stop at signing. That is what the rest of this step fixes; run it again at the end.
Sign in to Xcode first (Settings > Accounts). Then open the WebDriverAgent
project once and pick your team under Signing & Capabilities on both the
WebDriverAgentLib and WebDriverAgentRunner targets:
open vendor/wda/WebDriverAgent/WebDriverAgent.xcodeprojThat GUI step is not optional. Signing in to Accounts alone creates no
certificate; Apple issues one only when a project first asks for a team, and
xcodebuild cannot do it because it does not share Xcode’s authenticated
session (it fails with No Account for Team).
Free Apple IDs need their own bundle id. Apple refuses
com.facebook.WebDriverAgentRunner because someone else already registered
it, reporting it as “cannot be registered to your development team”. Change
the Runner target’s Bundle Identifier to something unique such as
com.yourname.WebDriverAgentRunner.
Authorize codesign once, or every build stops on a keychain prompt per framework, eight times over:
security set-key-partition-list -S apple-tool:,apple:,codesign: -s \ ~/Library/Keychains/login.keychain-dbThen build and install, this time for real:
./scripts/prepare_wda.sh device # finds your team and deviceios install --path vendor/wda/WebDriverAgentRunner-Runner.appFinally, trust the developer on the phone: Settings > General > VPN &
Device Management > your Apple ID > Trust. iOS refuses to launch a
free-account app until you do, and the host only sees an opaque
deviceprocesscontrolservice error.
Tell the server which runner to launch:
export IOS_MCP_WDA__BUNDLE_ID=com.yourname.WebDriverAgentRunner.xctrunnerThings that look like bugs but are not
Section titled “Things that look like bugs but are not”- The build must target the device by id, not
generic/platform=iOS. With a generic destination Apple never registers the phone, issues no profile, and the build fails claiming “your team has no devices”. - Do not strip the embedded
XC*.frameworkcopies. Removing anything from a signed bundle invalidates its signature, and installation then fails with a bareApplicationVerificationFailed. - Signing expires after 7 days on a free Apple ID. Re-run
prepare_wda.sh deviceand reinstall.ios_doctorreports the expiry date, and once it has lapsed it says so and drops the device from “Ready to automate”, since a runner whose profile has expired will not install. The simulator is unaffected and keeps working, because nothing about a simulator involves signing. A paid Developer Program membership gets a year.
5. Run
Section titled “5. Run”uv run ios-mcp doctor # should report "Ready to automate: real device"uv run ios-mcp devicesThe device is never the default target. ios_open_session prefers a simulator
even when a phone is connected, so acting on real hardware is always something
the caller asked for by name or UDID.
Over Wi-Fi, without a cable
Section titled “Over Wi-Fi, without a cable”Once a device has been paired over USB, it can be driven with no cable at all. The server picks the route itself:
| USB | Wi-Fi | |
|---|---|---|
| Discovery | go-ios | CoreDevice (devicectl) |
| Runner launch | ios runwda |
xcodebuild test-without-building |
| Reaching WebDriverAgent | port forward | straight to the phone’s own address |
| Needs a RemoteXPC tunnel | yes | no |
| Needs Xcode | no | yes |
Nothing to configure: if go-ios can see the device, USB is used; otherwise the
network route is taken automatically. ios-mcp devices says which, and
ios-mcp doctor reports e.g. 1 device(s): 1 over the network.
Two things make this work. go-ios talks to usbmuxd and cannot see an unplugged device at all, so discovery has to go through CoreDevice, which also browses Bonjour. And WebDriverAgent listens on the device itself, so once it is running any host on the same network can reach it — the port forward exists only to carry traffic over USB, and there is no USB here.
The runner announces the address it bound (ServerURLHere->http://10.0.0.195:8100),
which the adapter reads from the xcodebuild log. That is more reliable than
guessing the device’s IP, since it is the interface WDA actually chose.
Wi-Fi is not slower in practice: a snapshot measured 2.6s over the network against 3.7s over USB, because the cost is the accessibility traversal on the device, not the transport.
Pointing at a runner you manage yourself
Section titled “Pointing at a runner you manage yourself”export IOS_MCP_WDA__BASE_URL=http://10.0.0.195:8100The server then connects to that WebDriverAgent instead of launching one, and never tears it down. Useful for a device farm, a phone on another network, or a runner you started by hand for debugging.
What does not work on a physical device
Section titled “What does not work on a physical device”| Capability | Why |
|---|---|
ios_set_permission |
simctl privacy is Simulator-only. Drive Settings, or answer the permission alert with ios_handle_alert. |
| appearance, location, status-bar overrides | Simulator-only. |
ios://capabilities reports this per session, so an agent can check rather
than discover it by failing.
When it breaks
Section titled “When it breaks”| Symptom | Cause |
|---|---|
tunnel_down |
No tunnel, and none could be started. Unlock the phone, check the cable, or run ios tunnel start --userspace. |
device_locked |
The phone slept. The session wakes it automatically, but cannot type a passcode. Set Auto-Lock to Never for long runs. |
signing_invalid, or WDA stops working after a week |
Free profile expired. Re-run prepare_wda.sh device and reinstall. |
device_not_ready |
Phone untrusted, Developer Mode off, or the runner not trusted under VPN & Device Management. A profile reissued after it expired asks for Trust again. |
Launch fails with deviceprocesscontrolservice code 2 |
The developer certificate is not trusted on the phone. |
ApplicationVerificationFailed on install |
The bundle was modified after signing. |
| The first run after a crash times out waiting for WebDriverAgent | A runner from the previous run is still holding the device. Runners stop with the process that started them, even when it is killed, so this now means one started some other way. uv run ios-mcp reset lists it and -y stops it. |
A snapshot costs roughly 3.7s on a physical iPhone against well under a second
on a simulator, so each action lands around 8-12s. That is the accessibility
round trip, not traversal depth: raw tree size stops growing past
snapshot.max_depth = 30 while wall time stays flat to depth 60.
One case is worth knowing about because it looks like a hang. The first
snapshot after an app is backgrounded blocks for 61 seconds: XCTest keeps
waiting on the app that went away. No WebDriverAgent setting avoids it, so
home() activates SpringBoard immediately afterwards, which drops the same
snapshot to about 5s. If you background an app by some other route and the
next call stalls, that is why.