@mgcrea/mcp-ios-simulator 0.4.1 → 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-SdCcoXC3.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
@@ -23,8 +23,8 @@ const pkg = readPackageJson();
23
23
  const BUILD_INFO = {
24
24
  name: pkg.name,
25
25
  version: pkg.version,
26
- gitCommit: "6789bb6",
27
- gitCommitDate: "2026-11-04T10:23:19+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.
@@ -771,6 +886,7 @@ var SimulatorClient = class {
771
886
  wdaClients = /* @__PURE__ */ new Map();
772
887
  /** `simctl list` is ~80ms and most tools want it twice; 2s collapses that. */
773
888
  cache;
889
+ runnerCache;
774
890
  profiles = /* @__PURE__ */ new Map();
775
891
  constructor(opts) {
776
892
  this.opts = opts;
@@ -929,15 +1045,10 @@ var SimulatorClient = class {
929
1045
  return this.simctl.screenshotPng(target.id);
930
1046
  }
931
1047
  /**
932
- * Where WebDriverAgent is.
933
- *
934
- * Unlike the device lane there is no tunnel address to derive: a simulator
935
- * shares the host's network stack, so the runner's port *is* a host loopback
936
- * port. That is also the trap — several booted simulators contend for the
937
- * same one, and WebDriverAgent scans 8100-8199 when `USE_PORT` is unset, so a
938
- * second runner comes up healthy on 8101 and a server pointed at 8100 drives
939
- * the first simulator while reporting success. `ios_simulator_diagnostics`
940
- * 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.
941
1052
  */
942
1053
  wdaUrl() {
943
1054
  return this.opts.wdaUrl ?? `http://127.0.0.1:${this.opts.wdaPort}`;
@@ -946,24 +1057,110 @@ var SimulatorClient = class {
946
1057
  get defaultSimulatorId() {
947
1058
  return this.opts.defaultSimulatorId;
948
1059
  }
1060
+ /** Where runner ports are allocated from, and the fallback when no owner is known. */
949
1061
  get wdaPort() {
950
1062
  return this.opts.wdaPort;
951
1063
  }
952
- wda(_target) {
953
- const url = this.wdaUrl();
954
- 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);
955
1137
  if (existing) return existing;
956
1138
  const client = new WdaClient({
957
- baseUrl: async () => url,
1139
+ baseUrl,
958
1140
  timeoutMs: this.opts.wdaTimeoutMs,
959
1141
  remedies: simulatorWdaRemedies(this.opts.allowWrites !== false),
960
1142
  ...this.opts.fetch ? { fetch: this.opts.fetch } : {},
961
1143
  ...this.opts.logger ? { logger: this.opts.logger } : {}
962
1144
  });
963
- this.wdaClients.set(url, client);
1145
+ this.wdaClients.set(key, client);
964
1146
  return client;
965
1147
  }
966
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
+ };
967
1164
  // src/tools/util.ts
968
1165
  /**
969
1166
  * How the shared tools name themselves and each other in this server.
@@ -1182,40 +1379,6 @@ const registerAppTools = (server, client, launchArgs, outputDir) => {
1182
1379
  };
1183
1380
  // src/tools/diagnostics.ts
1184
1381
  /**
1185
- * Which simulator owns the WebDriverAgent port.
1186
- *
1187
- * This check has no device-server equivalent and it is the most valuable one
1188
- * here. A simulator shares the host's network stack, so a runner's port is a
1189
- * *host* port, and WebDriverAgent scans 8100-8199 when `USE_PORT` is unset. Two
1190
- * booted simulators therefore give you a second runner answering healthily on
1191
- * 8101 while a server pointed at 8100 drives the first one and reports success
1192
- * on every call.
1193
- *
1194
- * Every process inside a simulator carries `SIMULATOR_UDID` in its environment,
1195
- * and `ps -Eww` prints it, so the port's real owner is knowable rather than
1196
- * assumed.
1197
- */
1198
- const portOwner = async (client, port) => {
1199
- try {
1200
- const { stdout: pids } = await client.exec("/usr/sbin/lsof", [
1201
- "-nP",
1202
- `-iTCP:${port}`,
1203
- "-sTCP:LISTEN",
1204
- "-t"
1205
- ], 5e3);
1206
- const pid = pids.trim().split("\n")[0];
1207
- if (!pid) return void 0;
1208
- const { stdout: env } = await client.exec("/bin/ps", [
1209
- "-Eww",
1210
- "-p",
1211
- pid
1212
- ], 5e3);
1213
- return /SIMULATOR_UDID=([0-9A-Fa-f-]{36})/.exec(env)?.[1];
1214
- } catch {
1215
- return;
1216
- }
1217
- };
1218
- /**
1219
1382
  * The whole point of this tool is that **it never throws**. Every other tool
1220
1383
  * fails when nothing is booted, the runtime is missing or the runner is not up,
1221
1384
  * and each of those looks much like the others from the outside. This one
@@ -1278,31 +1441,41 @@ const diagnose = async (client, ctx, deviceHint) => {
1278
1441
  if (target) try {
1279
1442
  display = await client.display(target);
1280
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;
1281
1462
  const wda = {
1282
- url: client.wdaUrl(),
1283
- port: client.wdaPort,
1284
- reachable: false
1463
+ url,
1464
+ port,
1465
+ reachable: false,
1466
+ runners
1285
1467
  };
1286
- try {
1287
- wda.state = await client.wda(target ?? {
1288
- id: "",
1289
- name: ""
1290
- }).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();
1291
1473
  wda.reachable = true;
1292
1474
  } catch (err) {
1293
1475
  wda.error = err instanceof Error ? err.message : String(err);
1294
1476
  problems.push("WebDriverAgent is not answering, so the UI tree and the input tools will fail.");
1295
1477
  nextSteps.push((nothingBooted ? "Then, once it is booted: " : "") + startRunnerRemedy(ctx.allowWrites) + " Everything else — including screenshots — works without it.");
1296
1478
  }
1297
- if (wda.reachable) {
1298
- const owner = await portOwner(client, client.wdaPort);
1299
- if (owner) wda.boundTo = owner;
1300
- if (owner && target && owner.toLowerCase() !== target.id.toLowerCase()) {
1301
- const other = simulators.find((sim) => sim.id.toLowerCase() === owner.toLowerCase());
1302
- 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.`);
1303
- 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.");
1304
- }
1305
- }
1306
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.`);
1307
1480
  return {
1308
1481
  ok: problems.length === 0,
@@ -1321,10 +1494,18 @@ const diagnose = async (client, ctx, deviceHint) => {
1321
1494
  nextSteps
1322
1495
  };
1323
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
+ };
1324
1505
  const registerDiagnosticsTools = (server, client, ctx) => {
1325
1506
  server.registerTool("ios_simulator_diagnostics", {
1326
1507
  title: "iOS Simulator: Diagnostics",
1327
- 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.",
1328
1509
  inputSchema: z.object({ device: deviceArg }),
1329
1510
  annotations: { readOnlyHint: true }
1330
1511
  }, async ({ device }) => wrap(async () => diagnose(client, ctx, device)));
@@ -1939,10 +2120,10 @@ const runnerBuilt = async () => {
1939
2120
  const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpawnRunner) => {
1940
2121
  server.registerTool("ios_simulator_restart_wda", {
1941
2122
  title: "iOS Simulator: Restart WebDriverAgent",
1942
- 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.",
1943
2124
  inputSchema: z.object({
1944
2125
  device: deviceArg,
1945
- 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.")
1946
2127
  }),
1947
2128
  annotations: {
1948
2129
  readOnlyHint: false,
@@ -1951,7 +2132,7 @@ const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpa
1951
2132
  }
1952
2133
  }, async ({ device, port }) => wrap(async () => {
1953
2134
  const target = client.requireBooted(await client.resolveTarget(device));
1954
- const usePort = port ?? client.wdaPort;
2135
+ const usePort = port ?? await client.ownRunnerPort(target) ?? await client.freeRunnerPort();
1955
2136
  const { built, products } = await runnerBuilt();
1956
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." });
1957
2138
  const logPath = join(outputDir, "wda.log");
@@ -1965,6 +2146,7 @@ const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpa
1965
2146
  },
1966
2147
  logPath
1967
2148
  });
2149
+ client.forgetRunners();
1968
2150
  if (pid === void 0) throw new IosError$1("Could not start the runner — the process had no pid.", { remedy: START_RUNNER_SCRIPT_REMEDY });
1969
2151
  return {
1970
2152
  started: true,
@@ -2194,6 +2376,6 @@ const loadConfig = (env = process.env, configPath = defaultConfigPath(env)) => {
2194
2376
  openPath: pick(trimmed(env.IOS_SIMULATOR_OPEN_PATH), "openPath")
2195
2377
  });
2196
2378
  };
2197
- 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$1 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 };
2198
2380
 
2199
- //# sourceMappingURL=config-SdCcoXC3.js.map
2381
+ //# sourceMappingURL=config-BA3aCrX9.js.map