@mgcrea/mcp-ios-simulator 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -95,8 +95,8 @@ failure.
95
95
  | `IOS_SIMULATOR_ID` | the only booted simulator | UDID, name, or `booted`. Two booted and no value is an error that names them. |
96
96
  | `IOS_SIMULATOR_ALLOW_WRITES` | **on** | `0` drops the seventeen driving tools from `tools/list`. |
97
97
  | `IOS_SIMULATOR_LAUNCH_ARGS` | none | Launch arguments applied when `launch` passes none of its own. |
98
- | `IOS_SIMULATOR_WDA_PORT` | `8100` | Must match the runner's `USE_PORT`. One port per simulator. |
99
- | `IOS_SIMULATOR_WDA_URL` | `http://127.0.0.1:<port>` | Explicit override; rarely needed. |
98
+ | `IOS_SIMULATOR_WDA_PORT` | `8100` | Where `restart_wda` starts looking for a free port; the fallback runner port. |
99
+ | `IOS_SIMULATOR_WDA_URL` | none | Pins every simulator to one runner URL; rarely needed. |
100
100
  | `IOS_SIMULATOR_MAX_TREE_BYTES` | `24000` | Byte cap on a `ui_tree` payload. |
101
101
  | `IOS_SIMULATOR_TIMEOUT_MS` | `120000` | Budget for one `simctl` call; a boot is slow. |
102
102
  | `IOS_SIMULATOR_WDA_TIMEOUT_MS` | `30000` | Budget for one WebDriverAgent call. |
@@ -137,6 +137,13 @@ scripts/wda.sh run # leave this running
137
137
  scripts/wda.sh status # is it answering?
138
138
  ```
139
139
 
140
+ Several simulators can be driven at once, each with its own runner. The server
141
+ finds a simulator's runner by its port's owner, so no port needs configuring:
142
+ `ios_simulator_restart_wda {"device":"…"}` starts one on the first free port, and
143
+ from a shell `IOS_SIMULATOR_ID=<udid> USE_PORT=8101 scripts/wda.sh run` does the
144
+ same by hand. A call for a simulator whose runner is not up is refused rather
145
+ than sent to another simulator's.
146
+
140
147
  Installed from npm, the same script is the `ios-simulator-wda` binary:
141
148
 
142
149
  ```bash
@@ -223,8 +230,9 @@ Xcode 27.0 (27A266a) with an iOS 27.0 runtime.
223
230
  - **WebDriverAgent scans ports 8100-8199 when `USE_PORT` is unset.** With two
224
231
  booted simulators, the second runner comes up healthy on 8101 and a server
225
232
  pointed at 8100 drives the _first_ one while every call reports success.
226
- `scripts/wda.sh` always pins `USE_PORT`, and `diagnostics` reports the port's
227
- real owner via `SIMULATOR_UDID` from `ps -Eww`.
233
+ `scripts/wda.sh` always pins `USE_PORT`, and every UI call looks up the runner
234
+ it needs by its owner — `SIMULATOR_UDID` from `ps -Eww` on each listener
235
+ `lsof` finds in 8100-8199 — rather than trusting a fixed port.
228
236
  - **`listapps` returns an old-style NeXTSTEP plist, not JSON.** `--json` is a flag
229
237
  on `list` and nothing else.
230
238
  - **A status bar override survives a reboot.** Clear it or it quietly pollutes
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { N as BUILD_INFO, a as createServer, n as loadConfig, t as defaultConfigPath } from "./config-HmqayUrt.js";
2
+ import { P as BUILD_INFO, a as createServer, n as loadConfig, t as defaultConfigPath } from "./config-BA3aCrX9.js";
3
3
  import { ZodError } from "zod";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
5
5
  // src/cli.ts
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
- import { CommandError, CommandTimeoutError, DEFAULT_CRASH_KINDS, IosError, IosError as IosError$1, ToolchainError, WdaClient, WdaError, WdaUnavailableError, assertNoShellMetachars, crashKind, crashProcessName, createArgs, createExec, readLogFile, registerInputTools, registerScreenTools, summarizeCrashReport, wrap } from "@mgcrea/mcp-ios-core";
4
+ import { CommandError, CommandError as CommandError$1, CommandTimeoutError, DEFAULT_CRASH_KINDS, IosError, IosError as IosError$1, ToolchainError, WdaClient, WdaError, WdaUnavailableError, assertNoShellMetachars, crashKind, crashProcessName, createArgs, createExec, readLogFile, registerInputTools, registerScreenTools, summarizeCrashReport, wrap } from "@mgcrea/mcp-ios-core";
5
5
  import { mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
6
6
  import { homedir, tmpdir } from "node:os";
7
7
  import { z } from "zod";
@@ -23,8 +23,8 @@ const pkg = readPackageJson();
23
23
  const BUILD_INFO = {
24
24
  name: pkg.name,
25
25
  version: pkg.version,
26
- gitCommit: "5584c7c",
27
- gitCommitDate: "2026-10-29T22:11:35+01:00"
26
+ gitCommit: "07a8b77",
27
+ gitCommitDate: "2026-11-05T00:19:14+01:00"
28
28
  };
29
29
  // src/client/errors.ts
30
30
  /** Raising the budget, or noticing the simulator died mid-call. */
@@ -55,6 +55,22 @@ const START_RUNNER_TOOL_REMEDY = "Start it with ios_simulator_restart_wda, which
55
55
  const startRunnerRemedy = (allowWrites) => allowWrites ? START_RUNNER_TOOL_REMEDY : START_RUNNER_SCRIPT_REMEDY;
56
56
  /** What to do when WebDriverAgent does not answer at all. */
57
57
  const wdaUnavailableRemedy = (allowWrites) => `${startRunnerRemedy(allowWrites)} Everything except ios_simulator_ui_tree and the input tools works without it — screenshots go through simctl. If something else already forwards the port, set IOS_SIMULATOR_WDA_URL.`;
58
+ /**
59
+ * The target has no runner of its own, and the port this server would fall
60
+ * back to is another simulator's.
61
+ *
62
+ * Refused rather than forwarded, because the failure it prevents does not look
63
+ * like one: the other runner answers healthily, a tap lands on someone else's
64
+ * screen, and the UI tree that comes back describes an app the caller never
65
+ * launched.
66
+ */
67
+ var RunnerBelongsElsewhereError = class extends IosError {
68
+ name = "RunnerBelongsElsewhereError";
69
+ constructor(opts) {
70
+ const { target, owner, port, freePort, allowWrites } = opts;
71
+ super(`No WebDriverAgent runner belongs to ${target.name}. Port ${port} is ${owner}'s runner, so a tap or UI tree read sent there would go to that simulator's screen instead.`, { remedy: allowWrites ? `Start one for this simulator with ios_simulator_restart_wda {"device":"${target.id}"} — it picks a free port (${freePort} right now) and this server finds it by itself. Give it about fifteen seconds.` : `Start one for this simulator on a free port: IOS_SIMULATOR_ID=${target.id} USE_PORT=${freePort} scripts/wda.sh run. This server finds it by itself once it answers.` });
72
+ }
73
+ };
58
74
  /** No simulator matched, or several did and none was named. */
59
75
  var SimulatorNotFoundError = class extends IosError {
60
76
  name = "SimulatorNotFoundError";
@@ -249,6 +265,105 @@ var LaunchCaptures = class {
249
265
  };
250
266
  /** True when `path` exists on this Mac. */
251
267
  const exists = async (path) => stat(path).then(() => true, () => false);
268
+ // src/client/runners.ts
269
+ /** WebDriverAgent's own scan range when `USE_PORT` is unset (FBConfiguration.m). */
270
+ const WDA_DEFAULT_PORT = 8100;
271
+ /**
272
+ * The ranges worth scanning: WebDriverAgent's default one, and the one this
273
+ * server allocates from when IOS_SIMULATOR_WDA_PORT moves it elsewhere.
274
+ */
275
+ const runnerRanges = (basePort) => {
276
+ const ranges = [[WDA_DEFAULT_PORT, 8199]];
277
+ if (basePort < 8100 || basePort >= 8200) ranges.push([basePort, Math.min(65535, basePort + 100 - 1)]);
278
+ return ranges;
279
+ };
280
+ /**
281
+ * `lsof -F pn` output: a `p<pid>` line opens each process, then one `n<addr>`
282
+ * line per socket. A runner listening on both stacks shows up twice.
283
+ */
284
+ const parseLsof = (stdout) => {
285
+ const seen = /* @__PURE__ */ new Set();
286
+ const out = [];
287
+ let pid;
288
+ for (const line of stdout.split("\n")) if (line.startsWith("p")) {
289
+ const value = Number(line.slice(1));
290
+ pid = Number.isInteger(value) ? value : void 0;
291
+ } else if (line.startsWith("n") && pid !== void 0) {
292
+ const port = Number(/:(\d+)$/.exec(line)?.[1]);
293
+ if (!Number.isInteger(port)) continue;
294
+ const key = `${pid}:${port}`;
295
+ if (seen.has(key)) continue;
296
+ seen.add(key);
297
+ out.push({
298
+ pid,
299
+ port
300
+ });
301
+ }
302
+ return out;
303
+ };
304
+ /**
305
+ * `ps -Eww -o pid=,command=` output, one process per line with its environment
306
+ * appended. Every process inside a simulator carries `SIMULATOR_UDID`.
307
+ */
308
+ const parsePsUdids = (stdout) => {
309
+ const out = /* @__PURE__ */ new Map();
310
+ for (const line of stdout.split("\n")) {
311
+ const pid = Number(/^\s*(\d+)\s/.exec(line)?.[1]);
312
+ const udid = /SIMULATOR_UDID=([0-9A-Fa-f-]{36})/.exec(line)?.[1];
313
+ if (Number.isInteger(pid) && udid) out.set(pid, udid);
314
+ }
315
+ return out;
316
+ };
317
+ /**
318
+ * Every listener in the runner ranges, each attributed to its simulator.
319
+ *
320
+ * A simulator shares the host's network stack, so a runner's port is a host
321
+ * port and nothing in WebDriverAgent's `/status` names the simulator behind it
322
+ * — the process environment is the only reliable owner. Failure is an empty
323
+ * list rather than an error: lsof exits 1 when nothing matches, and not knowing
324
+ * who owns a port must degrade to the old fixed-port behaviour, not break
325
+ * every UI call.
326
+ */
327
+ const scanRunners = async (exec, basePort, timeoutMs = 5e3) => {
328
+ let sockets;
329
+ try {
330
+ const args = [
331
+ "-nP",
332
+ "-sTCP:LISTEN",
333
+ "-F",
334
+ "pn"
335
+ ];
336
+ for (const [from, to] of runnerRanges(basePort)) args.push(`-iTCP:${from}-${to}`);
337
+ sockets = parseLsof((await exec("/usr/sbin/lsof", args, timeoutMs)).stdout);
338
+ } catch {
339
+ return [];
340
+ }
341
+ if (sockets.length === 0) return [];
342
+ let udids = /* @__PURE__ */ new Map();
343
+ try {
344
+ const pids = [...new Set(sockets.map((s) => s.pid))].join(",");
345
+ udids = parsePsUdids((await exec("/bin/ps", [
346
+ "-Eww",
347
+ "-o",
348
+ "pid=,command=",
349
+ "-p",
350
+ pids
351
+ ], timeoutMs)).stdout);
352
+ } catch {}
353
+ return sockets.map((s) => {
354
+ const udid = udids.get(s.pid);
355
+ return udid ? {
356
+ ...s,
357
+ udid
358
+ } : s;
359
+ }).toSorted((a, b) => a.port - b.port);
360
+ };
361
+ /** The first port from `basePort` up that nothing listens on. */
362
+ const freePort = (listeners, basePort) => {
363
+ const taken = new Set(listeners.map((l) => l.port));
364
+ for (let port = basePort; port < basePort + 100 && port <= 65535; port++) if (!taken.has(port)) return port;
365
+ return basePort;
366
+ };
252
367
  // src/client/shape.ts
253
368
  /**
254
369
  * Flatten the runtime-keyed map `list devices` returns.
@@ -349,6 +464,12 @@ const remedyFor = (stderr) => {
349
464
  const assertNotBulkTarget = (udid) => {
350
465
  if (udid === "all" || udid === "unavailable" || udid === "booted") throw new IosError$1(`Refusing to pass "${udid}" to simctl as a device.`, { remedy: "simctl reads these as *every* matching simulator, so `erase all` would wipe the machine. Resolve to one concrete UDID with ios_simulator_list first." });
351
466
  };
467
+ /**
468
+ * `io … screenshot` refusing to pick between a multi-display device's screens.
469
+ * Matched on simctl's own words rather than exit 22 alone, which is just
470
+ * EINVAL and covers any malformed argument.
471
+ */
472
+ const isAmbiguousDisplay = (err) => err instanceof CommandError && /Cannot choose between \d+ displays/.test(`${String(err.details ?? "")}\n${err.message}`);
352
473
  var Simctl = class {
353
474
  opts;
354
475
  exec;
@@ -593,19 +714,42 @@ var Simctl = class {
593
714
  *
594
715
  * A successful capture also writes a `Note: No display specified…` line to
595
716
  * stderr, so non-empty stderr is not a failure here.
717
+ *
718
+ * A device with two displays — the iPhone Duo, iOS 27.1 runtime — makes the
719
+ * bare call fail outright: exit 22, "Cannot choose between 2 displays.
720
+ * Specify one with --display". Only then is it retried on `--display=primary`,
721
+ * so a single-display simulator and Xcode 26 never see the flag. Primary
722
+ * rather than whichever is lit, because it is the display WebDriverAgent
723
+ * addresses when no `currentDisplayId` is set: a capture of the other one
724
+ * would put every tap read off it in the wrong coordinate space. On an open
725
+ * Duo that display is black (appium/appium-xcuitest-driver#3012); driving
726
+ * the inner one means selecting it in WDA too, which nothing here does yet.
727
+ * The `internal` alias the help text offers fails on the Duo with exit 22.
596
728
  */
597
729
  async screenshotPng(udid) {
598
730
  assertNotBulkTarget(udid);
599
731
  const dir = await mkdtemp(join(tmpdir(), "simshot-"));
600
732
  const path = join(dir, "shot.png");
601
733
  try {
602
- await this.run([
603
- "io",
604
- udid,
605
- "screenshot",
606
- "--type=png",
607
- path
608
- ]);
734
+ try {
735
+ await this.run([
736
+ "io",
737
+ udid,
738
+ "screenshot",
739
+ "--type=png",
740
+ path
741
+ ]);
742
+ } catch (err) {
743
+ if (!isAmbiguousDisplay(err)) throw err;
744
+ await this.run([
745
+ "io",
746
+ udid,
747
+ "screenshot",
748
+ "--type=png",
749
+ "--display=primary",
750
+ path
751
+ ]);
752
+ }
609
753
  return (await readFile(path)).toString("base64");
610
754
  } finally {
611
755
  await rm(dir, {
@@ -742,6 +886,7 @@ var SimulatorClient = class {
742
886
  wdaClients = /* @__PURE__ */ new Map();
743
887
  /** `simctl list` is ~80ms and most tools want it twice; 2s collapses that. */
744
888
  cache;
889
+ runnerCache;
745
890
  profiles = /* @__PURE__ */ new Map();
746
891
  constructor(opts) {
747
892
  this.opts = opts;
@@ -900,15 +1045,10 @@ var SimulatorClient = class {
900
1045
  return this.simctl.screenshotPng(target.id);
901
1046
  }
902
1047
  /**
903
- * Where WebDriverAgent is.
904
- *
905
- * Unlike the device lane there is no tunnel address to derive: a simulator
906
- * shares the host's network stack, so the runner's port *is* a host loopback
907
- * port. That is also the trap — several booted simulators contend for the
908
- * same one, and WebDriverAgent scans 8100-8199 when `USE_PORT` is unset, so a
909
- * second runner comes up healthy on 8101 and a server pointed at 8100 drives
910
- * the first simulator while reporting success. `ios_simulator_diagnostics`
911
- * reports which simulator actually owns the port.
1048
+ * Where WebDriverAgent is *configured* to be: IOS_SIMULATOR_WDA_URL, or the
1049
+ * base port on loopback. Which runner a call actually reaches is decided per
1050
+ * simulator by `runnerUrl`; this is only the fallback when nothing better is
1051
+ * known.
912
1052
  */
913
1053
  wdaUrl() {
914
1054
  return this.opts.wdaUrl ?? `http://127.0.0.1:${this.opts.wdaPort}`;
@@ -917,24 +1057,110 @@ var SimulatorClient = class {
917
1057
  get defaultSimulatorId() {
918
1058
  return this.opts.defaultSimulatorId;
919
1059
  }
1060
+ /** Where runner ports are allocated from, and the fallback when no owner is known. */
920
1061
  get wdaPort() {
921
1062
  return this.opts.wdaPort;
922
1063
  }
923
- wda(_target) {
924
- const url = this.wdaUrl();
925
- const existing = this.wdaClients.get(url);
1064
+ /**
1065
+ * Every listener in the runner ranges, attributed to its simulator. Cached
1066
+ * for 2s: every UI call asks, and lsof plus ps costs tens of milliseconds.
1067
+ */
1068
+ async listRunners(opts = {}) {
1069
+ const cached = this.runnerCache;
1070
+ if (!opts.fresh && cached && Date.now() - cached.at < 2e3) return cached.listeners;
1071
+ const listeners = await scanRunners(this.exec, this.opts.wdaPort);
1072
+ this.runnerCache = {
1073
+ at: Date.now(),
1074
+ listeners
1075
+ };
1076
+ return listeners;
1077
+ }
1078
+ /** Called once a runner is started, so the next call looks again. */
1079
+ forgetRunners() {
1080
+ this.runnerCache = void 0;
1081
+ }
1082
+ /** The first port nothing listens on, from the base port up. */
1083
+ async freeRunnerPort() {
1084
+ return freePort(await this.listRunners({ fresh: true }), this.opts.wdaPort);
1085
+ }
1086
+ /** The port of this simulator's own runner, when it has one. */
1087
+ async ownRunnerPort(target) {
1088
+ const id = target.id.toLowerCase();
1089
+ return (await this.listRunners({ fresh: true })).find((l) => l.udid?.toLowerCase() === id)?.port;
1090
+ }
1091
+ /**
1092
+ * Which runner a call for this simulator goes to.
1093
+ *
1094
+ * A simulator shares the host's network stack, so every runner is a host
1095
+ * loopback port, and one server process — Bastion runs one for every session
1096
+ * on the Mac — sees all of them. A fixed port therefore meant one simulator
1097
+ * per process, and worse, a second simulator's calls went to the first one's
1098
+ * runner while every tool reported success.
1099
+ *
1100
+ * So the owner is looked up rather than assumed: the simulator's own runner
1101
+ * wherever it listens; failing that the configured URL, *unless* that is
1102
+ * known to be another simulator's, which is refused. Not knowing the owner
1103
+ * (lsof saw nothing) keeps the configured URL, which is the behaviour from
1104
+ * before ownership was checked.
1105
+ */
1106
+ async runnerUrl(target) {
1107
+ const listeners = await this.listRunners();
1108
+ const id = target.id.toLowerCase();
1109
+ const own = listeners.find((l) => l.udid?.toLowerCase() === id);
1110
+ if (!this.opts.wdaUrl && own) return `http://127.0.0.1:${own.port}`;
1111
+ const configured = this.wdaUrl();
1112
+ const port = loopbackPort(configured);
1113
+ const owner = port === void 0 ? void 0 : listeners.find((l) => l.port === port && l.udid)?.udid;
1114
+ if (owner && owner.toLowerCase() !== id) throw new RunnerBelongsElsewhereError({
1115
+ target,
1116
+ owner: (await this.listSimulators()).find((sim) => sim.id.toLowerCase() === owner.toLowerCase())?.name ?? owner,
1117
+ port,
1118
+ freePort: freePort(listeners, this.opts.wdaPort),
1119
+ allowWrites: this.opts.allowWrites !== false
1120
+ });
1121
+ return configured;
1122
+ }
1123
+ /**
1124
+ * The WebDriverAgent client for one simulator, one per UDID so each keeps its
1125
+ * own session. The URL is resolved on every request, so a runner restarted on
1126
+ * another port is followed without a server restart.
1127
+ */
1128
+ wda(target) {
1129
+ return this.wdaClient(target.id, () => this.runnerUrl(target));
1130
+ }
1131
+ /** The configured URL, unchecked — for diagnostics with no single target. */
1132
+ wdaConfigured() {
1133
+ return this.wdaClient("", async () => this.wdaUrl());
1134
+ }
1135
+ wdaClient(key, baseUrl) {
1136
+ const existing = this.wdaClients.get(key);
926
1137
  if (existing) return existing;
927
1138
  const client = new WdaClient({
928
- baseUrl: async () => url,
1139
+ baseUrl,
929
1140
  timeoutMs: this.opts.wdaTimeoutMs,
930
1141
  remedies: simulatorWdaRemedies(this.opts.allowWrites !== false),
931
1142
  ...this.opts.fetch ? { fetch: this.opts.fetch } : {},
932
1143
  ...this.opts.logger ? { logger: this.opts.logger } : {}
933
1144
  });
934
- this.wdaClients.set(url, client);
1145
+ this.wdaClients.set(key, client);
935
1146
  return client;
936
1147
  }
937
1148
  };
1149
+ /** The port of a loopback URL, or undefined for anything else. */
1150
+ const loopbackPort = (url) => {
1151
+ try {
1152
+ const parsed = new URL(url);
1153
+ if (![
1154
+ "127.0.0.1",
1155
+ "localhost",
1156
+ "[::1]"
1157
+ ].includes(parsed.hostname)) return void 0;
1158
+ const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
1159
+ return Number.isInteger(port) ? port : void 0;
1160
+ } catch {
1161
+ return;
1162
+ }
1163
+ };
938
1164
  // src/tools/util.ts
939
1165
  /**
940
1166
  * How the shared tools name themselves and each other in this server.
@@ -1153,40 +1379,6 @@ const registerAppTools = (server, client, launchArgs, outputDir) => {
1153
1379
  };
1154
1380
  // src/tools/diagnostics.ts
1155
1381
  /**
1156
- * Which simulator owns the WebDriverAgent port.
1157
- *
1158
- * This check has no device-server equivalent and it is the most valuable one
1159
- * here. A simulator shares the host's network stack, so a runner's port is a
1160
- * *host* port, and WebDriverAgent scans 8100-8199 when `USE_PORT` is unset. Two
1161
- * booted simulators therefore give you a second runner answering healthily on
1162
- * 8101 while a server pointed at 8100 drives the first one and reports success
1163
- * on every call.
1164
- *
1165
- * Every process inside a simulator carries `SIMULATOR_UDID` in its environment,
1166
- * and `ps -Eww` prints it, so the port's real owner is knowable rather than
1167
- * assumed.
1168
- */
1169
- const portOwner = async (client, port) => {
1170
- try {
1171
- const { stdout: pids } = await client.exec("/usr/sbin/lsof", [
1172
- "-nP",
1173
- `-iTCP:${port}`,
1174
- "-sTCP:LISTEN",
1175
- "-t"
1176
- ], 5e3);
1177
- const pid = pids.trim().split("\n")[0];
1178
- if (!pid) return void 0;
1179
- const { stdout: env } = await client.exec("/bin/ps", [
1180
- "-Eww",
1181
- "-p",
1182
- pid
1183
- ], 5e3);
1184
- return /SIMULATOR_UDID=([0-9A-Fa-f-]{36})/.exec(env)?.[1];
1185
- } catch {
1186
- return;
1187
- }
1188
- };
1189
- /**
1190
1382
  * The whole point of this tool is that **it never throws**. Every other tool
1191
1383
  * fails when nothing is booted, the runtime is missing or the runner is not up,
1192
1384
  * and each of those looks much like the others from the outside. This one
@@ -1249,31 +1441,41 @@ const diagnose = async (client, ctx, deviceHint) => {
1249
1441
  if (target) try {
1250
1442
  display = await client.display(target);
1251
1443
  } catch {}
1444
+ const listeners = await client.listRunners({ fresh: true });
1445
+ const names = new Map(simulators.map((sim) => [sim.id.toLowerCase(), sim.name]));
1446
+ const runners = listeners.flatMap((l) => l.udid ? [{
1447
+ udid: l.udid,
1448
+ name: names.get(l.udid.toLowerCase()) ?? l.udid,
1449
+ port: l.port
1450
+ }] : []);
1451
+ let url = client.wdaUrl();
1452
+ let refused = false;
1453
+ if (target) try {
1454
+ url = await client.runnerUrl(target);
1455
+ } catch (err) {
1456
+ refused = true;
1457
+ problems.push(err instanceof Error ? err.message : String(err));
1458
+ const remedy = err.remedy;
1459
+ if (remedy) nextSteps.push(remedy);
1460
+ }
1461
+ const port = portOf(url) ?? client.wdaPort;
1252
1462
  const wda = {
1253
- url: client.wdaUrl(),
1254
- port: client.wdaPort,
1255
- reachable: false
1463
+ url,
1464
+ port,
1465
+ reachable: false,
1466
+ runners
1256
1467
  };
1257
- try {
1258
- wda.state = await client.wda(target ?? {
1259
- id: "",
1260
- name: ""
1261
- }).status();
1468
+ const owner = listeners.find((l) => l.port === port && l.udid)?.udid;
1469
+ if (owner) wda.boundTo = owner;
1470
+ if (refused) wda.error = "Not probed: the runner on this port belongs to another simulator.";
1471
+ else try {
1472
+ wda.state = await (target ? client.wda(target) : client.wdaConfigured()).status();
1262
1473
  wda.reachable = true;
1263
1474
  } catch (err) {
1264
1475
  wda.error = err instanceof Error ? err.message : String(err);
1265
1476
  problems.push("WebDriverAgent is not answering, so the UI tree and the input tools will fail.");
1266
1477
  nextSteps.push((nothingBooted ? "Then, once it is booted: " : "") + startRunnerRemedy(ctx.allowWrites) + " Everything else — including screenshots — works without it.");
1267
1478
  }
1268
- if (wda.reachable) {
1269
- const owner = await portOwner(client, client.wdaPort);
1270
- if (owner) wda.boundTo = owner;
1271
- if (owner && target && owner.toLowerCase() !== target.id.toLowerCase()) {
1272
- const other = simulators.find((sim) => sim.id.toLowerCase() === owner.toLowerCase());
1273
- problems.push(`Port ${client.wdaPort} is owned by ${other?.name ?? owner}, not ${target.name}. Every tap and every UI tree read would go to the wrong screen.`);
1274
- nextSteps.push("WebDriverAgent scans 8100-8199 when USE_PORT is unset, so a second simulator's runner takes the next free port and nothing looks wrong. Give this one its own port with IOS_SIMULATOR_WDA_PORT and start its runner with a matching USE_PORT.");
1275
- }
1276
- }
1277
1479
  if (unavailable.length > 0) nextSteps.push(`${unavailable.length} simulators have no installed runtime and cannot be used at all. \`xcrun simctl delete unavailable\` clears them.`);
1278
1480
  return {
1279
1481
  ok: problems.length === 0,
@@ -1292,10 +1494,18 @@ const diagnose = async (client, ctx, deviceHint) => {
1292
1494
  nextSteps
1293
1495
  };
1294
1496
  };
1497
+ const portOf = (url) => {
1498
+ try {
1499
+ const port = Number(new URL(url).port);
1500
+ return port > 0 ? port : void 0;
1501
+ } catch {
1502
+ return;
1503
+ }
1504
+ };
1295
1505
  const registerDiagnosticsTools = (server, client, ctx) => {
1296
1506
  server.registerTool("ios_simulator_diagnostics", {
1297
1507
  title: "iOS Simulator: Diagnostics",
1298
- description: "Report both lanes separately and never fail: which simulators exist and which are usable, which one this server would drive, its screen geometry, and whether WebDriverAgent is answering — including *which* simulator owns its port, which is the one way to catch a second runner quietly taking your taps. Start here when anything else misbehaves.",
1508
+ description: "Report both lanes separately and never fail: which simulators exist and which are usable, which one this server would drive, its screen geometry, and whether WebDriverAgent is answering — including every simulator's runner and its port, and which one the target's taps would reach. Start here when anything else misbehaves.",
1299
1509
  inputSchema: z.object({ device: deviceArg }),
1300
1510
  annotations: { readOnlyHint: true }
1301
1511
  }, async ({ device }) => wrap(async () => diagnose(client, ctx, device)));
@@ -1910,10 +2120,10 @@ const runnerBuilt = async () => {
1910
2120
  const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpawnRunner) => {
1911
2121
  server.registerTool("ios_simulator_restart_wda", {
1912
2122
  title: "iOS Simulator: Restart WebDriverAgent",
1913
- description: "Start the WebDriverAgent runner, or restart it after it has died. Only the UI tree and the input tools need it — screenshots and everything simctl does keep working without it. The runner is started detached so it outlives this conversation, and its output goes to a log file rather than to you. It does not wait: give it fifteen seconds or so, then check ios_simulator_diagnostics.",
2123
+ description: "Start the WebDriverAgent runner, or restart it after it has died. Only the UI tree and the input tools need it — screenshots and everything simctl does keep working without it. Each simulator gets its own runner on its own port, so several can be driven at once. The runner is started detached so it outlives this conversation, and its output goes to a log file rather than to you. It does not wait: give it fifteen seconds or so, then check ios_simulator_diagnostics.",
1914
2124
  inputSchema: z.object({
1915
2125
  device: deviceArg,
1916
- port: z.number().int().min(1).max(65535).optional().describe("Port to bind, passed to the runner as USE_PORT. Defaults to IOS_SIMULATOR_WDA_PORT. Two simulators need two ports: they share this Mac's loopback, and a runner with no USE_PORT scans 8100-8199 and silently takes the next free one.")
2126
+ port: z.number().int().min(1).max(65535).optional().describe("Port to bind, passed to the runner as USE_PORT. Leave it out: the simulator's current runner port is reused, or else the first free one from IOS_SIMULATOR_WDA_PORT up, and the other tools find the runner wherever it listens.")
1917
2127
  }),
1918
2128
  annotations: {
1919
2129
  readOnlyHint: false,
@@ -1922,7 +2132,7 @@ const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpa
1922
2132
  }
1923
2133
  }, async ({ device, port }) => wrap(async () => {
1924
2134
  const target = client.requireBooted(await client.resolveTarget(device));
1925
- const usePort = port ?? client.wdaPort;
2135
+ const usePort = port ?? await client.ownRunnerPort(target) ?? await client.freeRunnerPort();
1926
2136
  const { built, products } = await runnerBuilt();
1927
2137
  if (!built) throw new IosError$1(`No WebDriverAgent build in ${products}, so there is nothing to start.`, { remedy: "Build it once with `scripts/wda.sh setup` (or `npx -p @mgcrea/mcp-ios-simulator ios-simulator-wda setup` from an npm install), then call ios_simulator_restart_wda again." });
1928
2138
  const logPath = join(outputDir, "wda.log");
@@ -1936,6 +2146,7 @@ const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpa
1936
2146
  },
1937
2147
  logPath
1938
2148
  });
2149
+ client.forgetRunners();
1939
2150
  if (pid === void 0) throw new IosError$1("Could not start the runner — the process had no pid.", { remedy: START_RUNNER_SCRIPT_REMEDY });
1940
2151
  return {
1941
2152
  started: true,
@@ -2165,6 +2376,6 @@ const loadConfig = (env = process.env, configPath = defaultConfigPath(env)) => {
2165
2376
  openPath: pick(trimmed(env.IOS_SIMULATOR_OPEN_PATH), "openPath")
2166
2377
  });
2167
2378
  };
2168
- export { WritesDisabledError as A, SimulatorNotBootedError as C, ToolchainError as D, TOOLCHAIN_REMEDY as E, wdaUnavailableRemedy as M, BUILD_INFO as N, WdaError as O, START_RUNNER_TOOL_REMEDY as S, TIMEOUT_REMEDY as T, CommandTimeoutError as _, createServer as a, RuntimeUnavailableError as b, assertNotBulkTarget as c, summarizeDevices as d, toListRow as f, CommandError as g, toDisplayInfo as h, SERVER_VERSION as i, startRunnerRemedy as j, WdaUnavailableError as k, parseSimctlError as l, readScreenProfile as m, loadConfig as n, SimulatorClient as o, pngDimensions as p, SERVER_NAME as r, Simctl as s, defaultConfigPath as t, summarizeApps as u, EMPTY_SCREENSHOT_REMEDY as v, SimulatorNotFoundError as w, START_RUNNER_SCRIPT_REMEDY as x, IosError$1 as y };
2379
+ export { WdaUnavailableError as A, START_RUNNER_TOOL_REMEDY as C, TOOLCHAIN_REMEDY as D, TIMEOUT_REMEDY as E, startRunnerRemedy as M, wdaUnavailableRemedy as N, ToolchainError as O, BUILD_INFO as P, START_RUNNER_SCRIPT_REMEDY as S, SimulatorNotFoundError as T, CommandTimeoutError as _, createServer as a, RunnerBelongsElsewhereError as b, assertNotBulkTarget as c, summarizeDevices as d, toListRow as f, CommandError$1 as g, toDisplayInfo as h, SERVER_VERSION as i, WritesDisabledError as j, WdaError as k, parseSimctlError as l, readScreenProfile as m, loadConfig as n, SimulatorClient as o, pngDimensions as p, SERVER_NAME as r, Simctl as s, defaultConfigPath as t, summarizeApps as u, EMPTY_SCREENSHOT_REMEDY as v, SimulatorNotBootedError as w, RuntimeUnavailableError as x, IosError$1 as y };
2169
2380
 
2170
- //# sourceMappingURL=config-HmqayUrt.js.map
2381
+ //# sourceMappingURL=config-BA3aCrX9.js.map