@mgcrea/mcp-ios-simulator 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Olivier Louvignes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # @mgcrea/mcp-ios-simulator
2
+
3
+ Model Context Protocol server for driving an **iOS Simulator** from a model:
4
+ screenshot the screen, read its accessibility tree, tap, swipe, type, manage the
5
+ app under test — and stage the device itself, which is the part no physical
6
+ device can do.
7
+
8
+ No Apple Developer team. No code signing. No device trust. No tunnel. Nothing to
9
+ unlock. And **screenshots work with nothing installed at all**.
10
+
11
+ ## Features
12
+
13
+ - **See the screen, with zero setup.** `simctl` captures it directly, scaled to
14
+ exactly the device's point size, so a position read off the image is a tap
15
+ coordinate with no conversion. No WebDriverAgent needed for this.
16
+ - **Read the screen.** A flattened, pruned accessibility tree — type, label,
17
+ identifier and the precomputed tap point per element — instead of the tens of
18
+ KB of nested JSON WebDriverAgent actually returns.
19
+ - **Drive the screen.** Tap by coordinate or by accessibility label, swipe, type,
20
+ press Home. Every action returns the resulting screen by default, so a
21
+ mis-aimed tap is visible on the call that made it.
22
+ - **Stage the device.** Dark mode, Dynamic Type size, increased contrast, a
23
+ frozen 9:41 status bar, a simulated location, and app permissions granted or
24
+ denied without a prompt anyone has to tap. **None of this is possible on real
25
+ hardware.**
26
+ - **Push without APNs.** Deliver a real remote notification with no certificate
27
+ and no server.
28
+ - **Manage the app.** Install a build, launch it with fixture arguments,
29
+ terminate it, open a deep link. An app's data container is an ordinary path on
30
+ this Mac, so its database or logs are read directly — there is nothing to copy
31
+ off a device.
32
+
33
+ ## Security
34
+
35
+ **Writes are on by default**, and that is deliberate. The rest of the fleet is
36
+ read-only until a flag is set because a mutating tool acts on someone's real
37
+ account or someone's real phone. A simulator is neither: it holds no person's
38
+ data, and `ios_simulator_erase` puts it back to factory in seconds.
39
+
40
+ What that still costs you, stated honestly:
41
+
42
+ - `install` and `launch` run code on your Mac. A simulated process is a host
43
+ process.
44
+ - The app's data container is a plain host path, readable by anything.
45
+ - `erase` is irreversible — it is the only tool behind an explicit `confirm`.
46
+ - `IOS_SIMULATOR_ALLOW_WRITES=0` restores the device server's posture in one
47
+ variable, and then the fourteen driving tools are **absent** from `tools/list`
48
+ rather than refused, because a refusal still lets a model try, retry and reason
49
+ about a way around it.
50
+
51
+ **Your credentials.** There are none. The server holds no tokens and talks to no
52
+ vendor API. Everything goes through `xcrun simctl` and a loopback HTTP server.
53
+
54
+ **Supply chain.** Three runtime dependencies: `@modelcontextprotocol/server`,
55
+ `zod`, and [`@mgcrea/mcp-ios-core`](../mcp-ios-core) — our own, and itself
56
+ dependent on only the first two. Scaling images uses `sips` and reading property
57
+ lists uses `plutil`, both of which ship with macOS, specifically so neither an
58
+ image library nor a plist parser has to be installed.
59
+
60
+ ## How it reaches the simulator
61
+
62
+ | Lane | Carries | Needs |
63
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
64
+ | `xcrun simctl` | simulators, apps, install, launch, deep links, **the screenshot**, appearance, status bar, location, permissions, push, boot/shutdown/erase | Xcode. Nothing else. |
65
+ | WebDriverAgent over HTTP | the accessibility tree, tap, swipe, type, buttons | a runner started once with `scripts/wda.sh` |
66
+
67
+ The asymmetry with `mcp-ios-device` is worth stating plainly: there, _every_
68
+ pixel comes through WebDriverAgent, so nothing about the screen works until a
69
+ signed runner is installed and trusted on the phone. Here `simctl` captures the
70
+ screen itself, so **fourteen of the nineteen tools work with no runner at all**
71
+ — only `ios_simulator_ui_tree` and the five input tools need one, and building it
72
+ takes about two minutes with no Apple account.
73
+
74
+ `ios_simulator_diagnostics` reports both lanes separately, so "the simulator is
75
+ fine but the runner is not up" is a distinguishable answer rather than a generic
76
+ failure.
77
+
78
+ ## Configure
79
+
80
+ | Variable | Default | What it does |
81
+ | ------------------------------ | --------------------------- | ----------------------------------------------------------------------------- |
82
+ | `IOS_SIMULATOR_ID` | the only booted simulator | UDID, name, or `booted`. Two booted and no value is an error that names them. |
83
+ | `IOS_SIMULATOR_ALLOW_WRITES` | **on** | `0` drops the fourteen driving tools from `tools/list`. |
84
+ | `IOS_SIMULATOR_LAUNCH_ARGS` | none | Launch arguments applied when `launch` passes none of its own. |
85
+ | `IOS_SIMULATOR_WDA_PORT` | `8100` | Must match the runner's `USE_PORT`. One port per simulator. |
86
+ | `IOS_SIMULATOR_WDA_URL` | `http://127.0.0.1:<port>` | Explicit override; rarely needed. |
87
+ | `IOS_SIMULATOR_MAX_TREE_BYTES` | `24000` | Byte cap on a `ui_tree` payload. |
88
+ | `IOS_SIMULATOR_TIMEOUT_MS` | `120000` | Budget for one `simctl` call; a boot is slow. |
89
+ | `IOS_SIMULATOR_WDA_TIMEOUT_MS` | `30000` | Budget for one WebDriverAgent call. |
90
+ | `IOS_SIMULATOR_OUTPUT_DIR` | `$TMPDIR/mcp-ios-simulator` | Screenshots, launch logs, the runner log. |
91
+ | `IOS_SIMULATOR_DEBUG` | off | Log every `simctl` and WebDriverAgent call to stderr. |
92
+
93
+ The same keys in camelCase can go in `~/.config/ios-simulator-mcp/config.json`
94
+ (`IOS_SIMULATOR_CONFIG` to move it). The environment wins **per field**, so a
95
+ one-off `IOS_SIMULATOR_ALLOW_WRITES=0` beats a file that says `true`. Unknown keys
96
+ in the file are an error rather than silently ignored — a typo that looks like
97
+ "that setting had no effect" is the worst way to learn where your configuration
98
+ came from.
99
+
100
+ See [.env.example](.env.example) for the annotated version.
101
+
102
+ ## Quick start
103
+
104
+ Requires macOS with Xcode and a booted simulator.
105
+
106
+ ### A. Everything except the tree and the taps, with no setup
107
+
108
+ ```bash
109
+ npx -y @mgcrea/mcp-ios-simulator
110
+ ```
111
+
112
+ `list`, `list_apps`, `screenshot`, `diagnostics`, `power`, `install`, `launch`,
113
+ `open_url`, `set_environment`, `push` and `erase` all work immediately.
114
+
115
+ ### B. The input lane
116
+
117
+ `ui_tree`, `tap`, `tap_element`, `swipe`, `type` and `press_button` need a
118
+ WebDriverAgent runner. Build and start it — no team id, no signing, no prompt:
119
+
120
+ ```bash
121
+ scripts/wda.sh setup # ~2 minutes
122
+ scripts/wda.sh run # leave this running
123
+ scripts/wda.sh status # is it answering?
124
+ ```
125
+
126
+ Installed from npm, the same script is the `ios-simulator-wda` binary:
127
+
128
+ ```bash
129
+ npx -p @mgcrea/mcp-ios-simulator ios-simulator-wda setup
130
+ npx -p @mgcrea/mcp-ios-simulator ios-simulator-wda run
131
+ ```
132
+
133
+ ### C. Wired into a client
134
+
135
+ See [.mcp.json.example](.mcp.json.example).
136
+
137
+ ## Tools
138
+
139
+ Nineteen. Five are read-only; the other fourteen disappear with
140
+ `IOS_SIMULATOR_ALLOW_WRITES=0`.
141
+
142
+ | Tool | Writes? | What it does |
143
+ | ------------------------------- | ------------- | ------------------------------------------------------------------------ |
144
+ | `ios_simulator_list` | | Every simulator, with state and whether its runtime is installed |
145
+ | `ios_simulator_diagnostics` | | Both lanes, the resolved target, the geometry, and who owns the WDA port |
146
+ | `ios_simulator_list_apps` | | Installed apps, with bundle id and host-path data container |
147
+ | `ios_simulator_screenshot` | | The screen, in point space — **no runner needed** |
148
+ | `ios_simulator_ui_tree` | | Addressable elements with precomputed tap points |
149
+ | `ios_simulator_tap` | ✓ | Tap a point |
150
+ | `ios_simulator_tap_element` | ✓ | Tap by identifier, label or predicate |
151
+ | `ios_simulator_swipe` | ✓ | Drag between two points |
152
+ | `ios_simulator_type` | ✓ | Type into the focused field, or a named one |
153
+ | `ios_simulator_press_button` | ✓ | Home |
154
+ | `ios_simulator_power` | ✓ | Boot or shut down; never implicit |
155
+ | `ios_simulator_erase` | ✓ **confirm** | Wipe to factory — the only irreversible tool |
156
+ | `ios_simulator_install` | ✓ | Install a simulator `.app` |
157
+ | `ios_simulator_launch` | ✓ | Launch, with fixture arguments and captured output |
158
+ | `ios_simulator_terminate` | ✓ | Kill a running app |
159
+ | `ios_simulator_open_url` | ✓ | Deep links and universal links |
160
+ | `ios_simulator_set_environment` | ✓ | Appearance, Dynamic Type, contrast, status bar, location, permissions |
161
+ | `ios_simulator_push` | ✓ | A remote notification, with no APNs certificate |
162
+ | `ios_simulator_restart_wda` | ✓ | Start or restart the runner, detached |
163
+
164
+ Deliberately absent: `create`, `clone`, `delete`, `rename`, `upgrade` and `pair`
165
+ (fleet management, not driving, and `delete all` is a footgun with no upside);
166
+ `get_app_container` (`list_apps` already returns the path); `uninstall`
167
+ (`install` overwrites, and `erase` covers first-run properly); `addmedia`,
168
+ `keychain`, `pbcopy`, `spawn`, `diagnose` and `recordVideo` (real capabilities
169
+ that an agent would use approximately never, and every tool costs listing bytes
170
+ on every connect).
171
+
172
+ ## Traps worth knowing
173
+
174
+ All measured on Xcode 26.6 (17F113).
175
+
176
+ - **`simctl io … screenshot -` does not write to stdout.** The help text says it
177
+ does. It creates a file literally named `-` in the current working directory
178
+ and prints `Wrote screenshot to: …` to stderr, exit 0. This server always
179
+ passes a real path.
180
+ - **`simctl io` on a simulator whose runtime is missing aborts** — SIGABRT, exit
181
+ 134, an uncaught `NSInternalInconsistencyException` and a thirty-line stack
182
+ trace, not an error message. Availability is checked in the resolver, before
183
+ the call. On a typical machine a large fraction of simulators are in this
184
+ state.
185
+ - **`erase`, `delete` and `shutdown` all accept the literal `all`.** A target of
186
+ `"all"` reaching `simctl erase` wipes every simulator on the machine. Refused
187
+ in the resolver and again in the adapter.
188
+ - **`booted` is a coin flip.** simctl's own help: "If multiple devices are booted
189
+ … simctl will choose one of them." Resolved to a concrete UDID here first.
190
+ - **WebDriverAgent scans ports 8100-8199 when `USE_PORT` is unset.** With two
191
+ booted simulators, the second runner comes up healthy on 8101 and a server
192
+ pointed at 8100 drives the _first_ one while every call reports success.
193
+ `scripts/wda.sh` always pins `USE_PORT`, and `diagnostics` reports the port's
194
+ real owner via `SIMULATOR_UDID` from `ps -Eww`.
195
+ - **`listapps` returns an old-style NeXTSTEP plist, not JSON.** `--json` is a flag
196
+ on `list` and nothing else.
197
+ - **A status bar override survives a reboot.** Clear it or it quietly pollutes
198
+ every later screenshot.
199
+ - **`simctl launch` environment variables need a `SIMCTL_CHILD_` prefix** on the
200
+ calling process, not a flag.
201
+ - **`simctl install` wants a `.app` bundle directory** built for the simulator —
202
+ an `.ipa` or a device build fails with "No such file or directory", which reads
203
+ like a path typo.
204
+ - **`simctl boot` gives you no window.** It runs headless, which is what an agent
205
+ wants and confusing the first time; `power` opens Simulator.app by default.
206
+ - **Two runtimes can share one identifier**, so the key of `list devices` is not
207
+ a primary key.
208
+
209
+ ## Develop
210
+
211
+ ```bash
212
+ pnpm install
213
+ pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build
214
+ ```
215
+
216
+ ## License
217
+
218
+ MIT — see [LICENSE](./LICENSE).
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {}
package/dist/cli.js ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import { a as createServer, j as BUILD_INFO, n as loadConfig, t as defaultConfigPath } from "./config-C1TH-p-c.js";
3
+ import { ZodError } from "zod";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
5
+ //#region src/cli.ts
6
+ const stderrLogger = {
7
+ debug: (...args) => {
8
+ if (process.env.IOS_SIMULATOR_DEBUG) console.error("[ios-simulator-mcp]", ...args);
9
+ },
10
+ warn: (...args) => console.error("[ios-simulator-mcp]", ...args),
11
+ error: (...args) => console.error("[ios-simulator-mcp]", ...args)
12
+ };
13
+ /** Show a config mistake as its field messages, not 40 frames of zod internals. */
14
+ const describeFatal = (err) => {
15
+ if (err instanceof ZodError) return err.issues.map((issue) => {
16
+ const path = issue.path.join(".");
17
+ return path ? `${path}: ${issue.message}` : issue.message;
18
+ }).join("\n");
19
+ return err instanceof Error ? err.message : String(err);
20
+ };
21
+ const main = async () => {
22
+ stderrLogger.warn(`${BUILD_INFO.name}@${BUILD_INFO.version} (git ${BUILD_INFO.gitCommit} ${BUILD_INFO.gitCommitDate}, node ${process.version})`);
23
+ const configPath = defaultConfigPath();
24
+ const config = loadConfig(process.env, configPath);
25
+ const { server } = createServer({
26
+ config,
27
+ logger: stderrLogger
28
+ });
29
+ const transport = new StdioServerTransport();
30
+ await server.connect(transport);
31
+ stderrLogger.warn(`ios-simulator-mcp connected (simulator=${config.simulatorId ?? "auto"}, wda=${config.wdaUrl ?? `127.0.0.1:${config.wdaPort}`}, writes=${config.allowWrites ? "enabled" : "DISABLED"})`);
32
+ if (config.allowWrites && config.launchArgs.length > 0) stderrLogger.warn(`default launch arguments: ${config.launchArgs.join(" ")}`);
33
+ const shutdown = (signal) => {
34
+ stderrLogger.warn(`received ${signal}, shutting down`);
35
+ process.exit(0);
36
+ };
37
+ process.on("SIGINT", () => shutdown("SIGINT"));
38
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
39
+ };
40
+ main().catch((err) => {
41
+ console.error(`[ios-simulator-mcp] fatal: ${describeFatal(err)}`);
42
+ process.exit(1);
43
+ });
44
+ //#endregion
45
+ export {};
46
+
47
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { StdioServerTransport } from \"@modelcontextprotocol/server/stdio\";\nimport { ZodError } from \"zod\";\n\nimport { BUILD_INFO } from \"#/build-info\";\nimport { defaultConfigPath, loadConfig } from \"#/config\";\nimport { createServer } from \"#/server\";\n\n// Everything goes to stderr: stdout is the MCP protocol channel, and a stray\n// log line there corrupts the stream.\nconst stderrLogger = {\n debug: (...args: unknown[]) => {\n if (process.env.IOS_SIMULATOR_DEBUG) console.error(\"[ios-simulator-mcp]\", ...args);\n },\n warn: (...args: unknown[]) => console.error(\"[ios-simulator-mcp]\", ...args),\n error: (...args: unknown[]) => console.error(\"[ios-simulator-mcp]\", ...args),\n};\n\n/** Show a config mistake as its field messages, not 40 frames of zod internals. */\nconst describeFatal = (err: unknown): string => {\n if (err instanceof ZodError) {\n return err.issues\n .map((issue) => {\n const path = issue.path.join(\".\");\n return path ? `${path}: ${issue.message}` : issue.message;\n })\n .join(\"\\n\");\n }\n return err instanceof Error ? err.message : String(err);\n};\n\nconst main = async (): Promise<void> => {\n stderrLogger.warn(\n `${BUILD_INFO.name}@${BUILD_INFO.version} (git ${BUILD_INFO.gitCommit} ${BUILD_INFO.gitCommitDate}, node ${process.version})`,\n );\n const configPath = defaultConfigPath();\n const config = loadConfig(process.env, configPath);\n const { server } = createServer({ config, logger: stderrLogger });\n const transport = new StdioServerTransport();\n await server.connect(transport);\n // The banner is not decoration. It prints before anything can fail, and it is\n // the only place the resolved capability state is visible. Here the *unusual*\n // state is writes being off — they default on, because a simulator is\n // disposable — so `writes=DISABLED` is the one worth shouting: it is what\n // explains a tool being missing from tools/list.\n stderrLogger.warn(\n `ios-simulator-mcp connected (simulator=${config.simulatorId ?? \"auto\"}, ` +\n `wda=${config.wdaUrl ?? `127.0.0.1:${config.wdaPort}`}, ` +\n `writes=${config.allowWrites ? \"enabled\" : \"DISABLED\"})`,\n );\n if (config.allowWrites && config.launchArgs.length > 0) {\n stderrLogger.warn(`default launch arguments: ${config.launchArgs.join(\" \")}`);\n }\n\n const shutdown = (signal: string): void => {\n stderrLogger.warn(`received ${signal}, shutting down`);\n process.exit(0);\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n};\n\nmain().catch((err: unknown) => {\n console.error(`[ios-simulator-mcp] fatal: ${describeFatal(err)}`);\n process.exit(1);\n});\n"],"mappings":";;;;;AAUA,MAAM,eAAe;CACnB,QAAQ,GAAG,SAAoB;EAC7B,IAAI,QAAQ,IAAI,qBAAqB,QAAQ,MAAM,uBAAuB,GAAG,IAAI;CACnF;CACA,OAAO,GAAG,SAAoB,QAAQ,MAAM,uBAAuB,GAAG,IAAI;CAC1E,QAAQ,GAAG,SAAoB,QAAQ,MAAM,uBAAuB,GAAG,IAAI;AAC7E;;AAGA,MAAM,iBAAiB,QAAyB;CAC9C,IAAI,eAAe,UACjB,OAAO,IAAI,OACR,KAAK,UAAU;EACd,MAAM,OAAO,MAAM,KAAK,KAAK,GAAG;EAChC,OAAO,OAAO,GAAG,KAAK,IAAI,MAAM,YAAY,MAAM;CACpD,CAAC,CAAC,CACD,KAAK,IAAI;CAEd,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AACxD;AAEA,MAAM,OAAO,YAA2B;CACtC,aAAa,KACX,GAAG,WAAW,KAAK,GAAG,WAAW,QAAQ,QAAQ,WAAW,UAAU,GAAG,WAAW,cAAc,SAAS,QAAQ,QAAQ,EAC7H;CACA,MAAM,aAAa,kBAAkB;CACrC,MAAM,SAAS,WAAW,QAAQ,KAAK,UAAU;CACjD,MAAM,EAAE,WAAW,aAAa;EAAE;EAAQ,QAAQ;CAAa,CAAC;CAChE,MAAM,YAAY,IAAI,qBAAqB;CAC3C,MAAM,OAAO,QAAQ,SAAS;CAM9B,aAAa,KACX,0CAA0C,OAAO,eAAe,OAAO,QAC9D,OAAO,UAAU,aAAa,OAAO,UAAU,WAC5C,OAAO,cAAc,YAAY,WAAW,EAC1D;CACA,IAAI,OAAO,eAAe,OAAO,WAAW,SAAS,GACnD,aAAa,KAAK,6BAA6B,OAAO,WAAW,KAAK,GAAG,GAAG;CAG9E,MAAM,YAAY,WAAyB;EACzC,aAAa,KAAK,YAAY,OAAO,gBAAgB;EACrD,QAAQ,KAAK,CAAC;CAChB;CACA,QAAQ,GAAG,gBAAgB,SAAS,QAAQ,CAAC;CAC7C,QAAQ,GAAG,iBAAiB,SAAS,SAAS,CAAC;AACjD;AAEA,KAAK,CAAC,CAAC,OAAO,QAAiB;CAC7B,QAAQ,MAAM,8BAA8B,cAAc,GAAG,GAAG;CAChE,QAAQ,KAAK,CAAC;AAChB,CAAC"}