@mgcrea/mcp-ios-simulator 0.2.2 → 0.4.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.
@@ -1,13 +1,13 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
- import { readFileSync } from "node:fs";
3
- import { CommandError, CommandTimeoutError, IosError, IosError as IosError$1, ToolchainError, WdaClient, WdaError, WdaUnavailableError, assertNoShellMetachars, createArgs, createExec, registerInputTools, registerScreenTools, wrap } from "@mgcrea/mcp-ios-core";
4
- import { dirname, join } from "node:path";
5
- import { mkdir, mkdtemp, open, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { existsSync, readFileSync } from "node:fs";
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";
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";
8
8
  import { spawn } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
10
- //#region src/build-info.ts
10
+ // src/build-info.ts
11
11
  const readPackageJson = () => {
12
12
  try {
13
13
  const pkgUrl = new URL("../package.json", import.meta.url);
@@ -23,11 +23,10 @@ const pkg = readPackageJson();
23
23
  const BUILD_INFO = {
24
24
  name: pkg.name,
25
25
  version: pkg.version,
26
- gitCommit: "171096c",
27
- gitCommitDate: "2026-09-26T17:16:08+02:00"
26
+ gitCommit: "5584c7c",
27
+ gitCommitDate: "2026-10-29T22:11:35+01:00"
28
28
  };
29
- //#endregion
30
- //#region src/client/errors.ts
29
+ // src/client/errors.ts
31
30
  /** Raising the budget, or noticing the simulator died mid-call. */
32
31
  const TIMEOUT_REMEDY = "Raise IOS_SIMULATOR_TIMEOUT_MS if this is a slow operation (a boot or a large `install` genuinely takes tens of seconds), or check the simulator is still booted.";
33
32
  const TOOLCHAIN_REMEDY = "Install Xcode and run `xcode-select --install`, or point IOS_SIMULATOR_XCRUN_PATH at a different xcrun.";
@@ -99,8 +98,7 @@ var WritesDisabledError = class extends IosError {
99
98
  };
100
99
  /** A shut-down simulator captures nothing, and that is the usual cause here. */
101
100
  const EMPTY_SCREENSHOT_REMEDY = "The simulator produced an empty capture. Check it is actually booted with ios_simulator_diagnostics, and that its runtime is installed.";
102
- //#endregion
103
- //#region src/client/display.ts
101
+ // src/client/display.ts
104
102
  /**
105
103
  * Screen geometry for a simulator, which has no `devicectl device info displays`.
106
104
  *
@@ -191,10 +189,11 @@ const pngDimensions = (png) => {
191
189
  * screen, so mapping it would be a lie.
192
190
  */
193
191
  const toDisplayInfo = (profile, capture) => {
194
- const scale = profile.mainScreenScale ?? 1;
192
+ const scale = profile.mainScreenScale;
195
193
  const pixelWidth = capture?.width ?? profile.mainScreenWidth;
196
194
  const pixelHeight = capture?.height ?? profile.mainScreenHeight;
197
195
  if (!pixelWidth || !pixelHeight) throw new IosError$1("Could not determine the simulator's screen geometry.", { remedy: "The device type's profile.plist could not be read and no screenshot was available. Run ios_simulator_diagnostics — an unavailable runtime is the usual cause." });
196
+ if (!scale) throw new IosError$1("Could not determine the simulator's screen scale.", { remedy: "The device type's profile.plist could not be read, so points cannot be told from pixels. Run ios_simulator_diagnostics — an unavailable runtime is the usual cause." });
198
197
  const landscape = profile.mainScreenWidth !== void 0 && profile.mainScreenHeight !== void 0 ? pixelWidth > pixelHeight && profile.mainScreenWidth < profile.mainScreenHeight : pixelWidth > pixelHeight;
199
198
  return {
200
199
  pixelWidth,
@@ -205,8 +204,52 @@ const toDisplayInfo = (profile, capture) => {
205
204
  orientation: capture ? landscape ? "landscape" : "portrait" : "unknown"
206
205
  };
207
206
  };
208
- //#endregion
209
- //#region src/client/shape.ts
207
+ // src/client/logs.ts
208
+ const defaultProcessAlive = (pid) => {
209
+ try {
210
+ process.kill(pid, 0);
211
+ return true;
212
+ } catch (err) {
213
+ return err.code === "EPERM";
214
+ }
215
+ };
216
+ var LaunchCaptures = class {
217
+ alive;
218
+ captures = /* @__PURE__ */ new Map();
219
+ latest;
220
+ constructor(alive = defaultProcessAlive) {
221
+ this.alive = alive;
222
+ }
223
+ key(udid, bundleId) {
224
+ return `${udid}\u0000${bundleId}`;
225
+ }
226
+ /**
227
+ * One capture per simulator and app. A relaunch replaces the record rather
228
+ * than adding a second, so a read never has to choose between two files of
229
+ * the same app — the older file stays on disk, it is just no longer "the" log.
230
+ */
231
+ record(capture) {
232
+ this.captures.set(this.key(capture.udid, capture.bundleId), capture);
233
+ this.latest = capture;
234
+ }
235
+ find(udid, bundleId) {
236
+ if (udid && bundleId) return this.captures.get(this.key(udid, bundleId));
237
+ const all = [...this.captures.values()].filter((c) => (!udid || c.udid === udid) && (!bundleId || c.bundleId === bundleId));
238
+ if (all.length === 0) return void 0;
239
+ return this.latest && all.includes(this.latest) ? this.latest : all.toSorted((a, b) => b.startedAt.localeCompare(a.startedAt))[0];
240
+ }
241
+ /**
242
+ * Whether the app is still writing. A pid can be reused once the app exits,
243
+ * and that is accepted: the cost of a false "running" is only that a final
244
+ * half-written line waits for the next read.
245
+ */
246
+ running(capture) {
247
+ return capture.pid !== void 0 && this.alive(capture.pid);
248
+ }
249
+ };
250
+ /** True when `path` exists on this Mac. */
251
+ const exists = async (path) => stat(path).then(() => true, () => false);
252
+ // src/client/shape.ts
210
253
  /**
211
254
  * Flatten the runtime-keyed map `list devices` returns.
212
255
  *
@@ -228,7 +271,8 @@ const summarizeDevices = (devices, runtimes, deviceTypes) => {
228
271
  deviceTypeIdentifier: sim.deviceTypeIdentifier,
229
272
  available: sim.isAvailable !== false,
230
273
  ...sim.availabilityError ? { unavailableReason: sim.availabilityError } : {},
231
- ...sim.lastBootedAt ? { lastBootedAt: sim.lastBootedAt } : {}
274
+ ...sim.lastBootedAt ? { lastBootedAt: sim.lastBootedAt } : {},
275
+ ...sim.dataPath ? { dataPath: sim.dataPath } : {}
232
276
  })));
233
277
  };
234
278
  /** `file:///Users/…/Foo.app/` → `/Users/…/Foo.app`, which is what a person can use. */
@@ -260,8 +304,7 @@ const toListRow = (sim) => ({
260
304
  ...sim.available ? {} : { available: false },
261
305
  ...sim.unavailableReason ? { unavailableReason: sim.unavailableReason } : {}
262
306
  });
263
- //#endregion
264
- //#region src/client/simctl.ts
307
+ // src/client/simctl.ts
265
308
  /**
266
309
  * Pull the human sentence out of a CoreSimulator error envelope.
267
310
  *
@@ -291,6 +334,7 @@ const remedyFor = (stderr) => {
291
334
  if (text.includes("unable to lookup in current state: shutdown")) return "The simulator is shut down. Boot it with ios_simulator_power {\"state\":\"booted\"}.";
292
335
  if (text.includes("current state: booted")) return "The simulator is already booted, which for most operations means there is nothing to do.";
293
336
  if (text.includes("no such file or directory")) return "Check the path exists and is a `.app` **bundle directory** built for the simulator — an `.ipa` or a device build will not install, and reports exactly this.";
337
+ if (text.includes("the request to open")) return "Check the app is installed with ios_simulator_list_apps: a bundle id that is not there fails exactly like this, with nothing in the message saying so.";
294
338
  if (text.includes("failed to initialize io ports")) return "The simulator's runtime is not installed, so CoreSimulator cannot service it. Install the runtime, or clear the orphans with `xcrun simctl delete unavailable`.";
295
339
  };
296
340
  /**
@@ -310,6 +354,12 @@ var Simctl = class {
310
354
  exec;
311
355
  /** Device types change only when Xcode is updated; re-reading is pure cost. */
312
356
  deviceTypes;
357
+ /**
358
+ * Read once, like device types: both change only when Xcode or a runtime is
359
+ * installed, and every tool call resolves a simulator. The cost is that a
360
+ * runtime added or deleted while this server runs is seen after a restart.
361
+ */
362
+ runtimes;
313
363
  constructor(opts) {
314
364
  this.opts = opts;
315
365
  this.exec = opts.exec ?? createExec({
@@ -319,9 +369,9 @@ var Simctl = class {
319
369
  });
320
370
  }
321
371
  /** Raw stdout, for the subcommands that answer in prose. */
322
- async run(args) {
372
+ async run(args, timeoutMs = this.opts.timeoutMs) {
323
373
  this.opts.logger?.debug?.("simctl", ...args);
324
- const { stdout } = await this.exec(this.opts.xcrunPath, ["simctl", ...args], this.opts.timeoutMs);
374
+ const { stdout } = await this.exec(this.opts.xcrunPath, ["simctl", ...args], timeoutMs);
325
375
  return stdout;
326
376
  }
327
377
  /**
@@ -378,7 +428,8 @@ var Simctl = class {
378
428
  return (await this.runJson(["list", "devices"])).devices ?? {};
379
429
  }
380
430
  async listRuntimes() {
381
- return (await this.runJson(["list", "runtimes"])).runtimes ?? [];
431
+ this.runtimes ??= (await this.runJson(["list", "runtimes"])).runtimes ?? [];
432
+ return this.runtimes;
382
433
  }
383
434
  async listDeviceTypes() {
384
435
  this.deviceTypes ??= (await this.runJson(["list", "devicetypes"])).devicetypes ?? [];
@@ -388,6 +439,16 @@ var Simctl = class {
388
439
  assertNotBulkTarget(udid);
389
440
  await this.run(["boot", udid]);
390
441
  }
442
+ /**
443
+ * Wait until a booting simulator has finished, or `timeoutMs` passes (a
444
+ * `CommandTimeoutError`). Not the state in `list`: measured on an iOS 27.0
445
+ * runtime, that reads "Booted" 0.4s after `boot` returns, while this returns
446
+ * 17s later, once the system is up.
447
+ */
448
+ async bootstatus(udid, timeoutMs) {
449
+ assertNotBulkTarget(udid);
450
+ await this.run(["bootstatus", udid], timeoutMs);
451
+ }
391
452
  async shutdown(udid) {
392
453
  assertNotBulkTarget(udid);
393
454
  await this.run(["shutdown", udid]);
@@ -454,23 +515,55 @@ var Simctl = class {
454
515
  * Launch, optionally replacing a running copy.
455
516
  *
456
517
  * Environment variables do not go on the command line: simctl reads them from
457
- * its **own** environment under a `SIMCTL_CHILD_` prefix. `ExecImpl` pins a
458
- * minimal environment on purpose, so a caller wanting them has to say so, and
459
- * this is where that would be threaded through.
518
+ * its **own** environment under a `SIMCTL_CHILD_` prefix. `ExecImpl` takes
519
+ * argv and pins a minimal environment on purpose, so the variables reach
520
+ * simctl through `/usr/bin/env` as `SIMCTL_CHILD_NAME=value` argv entries in
521
+ * front of xcrun — still no shell, and still one argv entry per value. Names
522
+ * are checked here because `env` reads everything up to the first entry with
523
+ * no `=` as an assignment, so a name holding one would shift the command.
524
+ *
525
+ * `stdout` and `stderr` may be the same path, and that is how a log capture
526
+ * uses them: measured on Xcode 27.0 with an iOS 27.0 runtime, a probe app
527
+ * writing to `print`, to stderr and to `Logger` in turn left one file with all
528
+ * three interleaved in order, nothing overwritten. The redirect *appends* — a
529
+ * second launch into the same file kept the first one's lines — and where it
530
+ * lands is not always the path given; see `SimulatorClient.locateRedirect`.
460
531
  */
461
532
  async launch(udid, bundleId, opts = {}) {
462
533
  assertNotBulkTarget(udid);
463
- const flags = [
464
- ...opts.terminateExisting ? ["--terminate-running-process"] : [],
465
- ...opts.stdout ? [`--stdout=${opts.stdout}`] : [],
466
- ...opts.stderr ? [`--stderr=${opts.stderr}`] : []
467
- ];
468
- return this.run([
534
+ const argv = [
535
+ "simctl",
469
536
  "launch",
470
- ...flags,
537
+ ...[
538
+ ...opts.terminateExisting ? ["--terminate-running-process"] : [],
539
+ ...opts.stdout ? [`--stdout=${opts.stdout}`] : [],
540
+ ...opts.stderr ? [`--stderr=${opts.stderr}`] : []
541
+ ],
471
542
  udid,
472
543
  bundleId,
473
544
  ...opts.args ?? []
545
+ ];
546
+ const env = Object.entries(opts.env ?? {});
547
+ if (env.length === 0) return this.run(argv.slice(1));
548
+ for (const [name] of env) if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new IosError$1(`Refusing environment variable name "${name}".`, { remedy: "Use letters, digits and underscores only, e.g. OS_ACTIVITY_DT_MODE." });
549
+ this.opts.logger?.debug?.("simctl", ...argv.slice(1), `(env: ${env.map(([k]) => k).join(" ")})`);
550
+ const { stdout } = await this.exec(this.opts.envPath, [
551
+ ...env.map(([name, value]) => `SIMCTL_CHILD_${name}=${value}`),
552
+ this.opts.xcrunPath,
553
+ ...argv
554
+ ], this.opts.timeoutMs);
555
+ return stdout;
556
+ }
557
+ /**
558
+ * Remove an app and its data container. Irreversible, which is why the tool
559
+ * in front of this takes `confirm`.
560
+ */
561
+ async uninstall(udid, bundleId) {
562
+ assertNotBulkTarget(udid);
563
+ await this.run([
564
+ "uninstall",
565
+ udid,
566
+ bundleId
474
567
  ]);
475
568
  }
476
569
  async terminate(udid, bundleId) {
@@ -622,8 +715,7 @@ var Simctl = class {
622
715
  }
623
716
  }
624
717
  };
625
- //#endregion
626
- //#region src/client/simulator.ts
718
+ // src/client/simulator.ts
627
719
  /**
628
720
  * What to say when WebDriverAgent fails, on a simulator.
629
721
  *
@@ -645,6 +737,7 @@ var SimulatorClient = class {
645
737
  plutilPath;
646
738
  openPath;
647
739
  execTimeoutMs;
740
+ captures;
648
741
  opts;
649
742
  wdaClients = /* @__PURE__ */ new Map();
650
743
  /** `simctl list` is ~80ms and most tools want it twice; 2s collapses that. */
@@ -660,9 +753,11 @@ var SimulatorClient = class {
660
753
  this.plutilPath = opts.plutilPath;
661
754
  this.openPath = opts.openPath;
662
755
  this.execTimeoutMs = opts.execTimeoutMs;
756
+ this.captures = new LaunchCaptures(opts.processAlive);
663
757
  this.simctl = new Simctl({
664
758
  xcrunPath: opts.xcrunPath,
665
759
  plutilPath: opts.plutilPath,
760
+ envPath: opts.envPath ?? "/usr/bin/env",
666
761
  timeoutMs: opts.execTimeoutMs,
667
762
  ...opts.exec ? { exec: opts.exec } : {},
668
763
  ...opts.logger ? { logger: opts.logger } : {}
@@ -684,6 +779,13 @@ var SimulatorClient = class {
684
779
  return simulators;
685
780
  }
686
781
  /**
782
+ * Drop the cached list. Called after a tool changes a simulator's state, so
783
+ * the next call within the cache window does not act on the state before it.
784
+ */
785
+ forgetSimulators() {
786
+ this.cache = void 0;
787
+ }
788
+ /**
687
789
  * Resolve a hint to exactly one simulator, or refuse.
688
790
  *
689
791
  * The device server's rule — never guess between two — transfers, but
@@ -752,11 +854,46 @@ var SimulatorClient = class {
752
854
  exec: this.exec,
753
855
  timeoutMs: this.execTimeoutMs
754
856
  }) : {};
755
- if (id) this.profiles.set(id, profile);
857
+ if (id && profile.mainScreenScale !== void 0) this.profiles.set(id, profile);
756
858
  }
757
859
  const capture = capturePng ? pngDimensions(Buffer.from(capturePng, "base64")) : void 0;
758
860
  return toDisplayInfo(profile, capture);
759
861
  }
862
+ /**
863
+ * Where a `simctl launch --stdout/--stderr` file actually landed.
864
+ *
865
+ * Not necessarily where it was asked to. The redirect is opened by the
866
+ * simulator's own launchd, which sees the simulator's filesystem, and that
867
+ * has its own `/tmp` and `/var/folders`. Measured on an iOS 27.0 runtime:
868
+ * `--stdout=/tmp/x/a.out` created `<dataPath>/tmp/x/a.out`, and a path under
869
+ * `/var/folders/…/T` — which is where `os.tmpdir()`, and so this server's
870
+ * default output directory, points — landed under `<dataPath>/var/folders/…`,
871
+ * the `/private` prefix of either making no difference. A path under `/Users`
872
+ * was written exactly where it said, missing directories created.
873
+ *
874
+ * Rather than encode which prefixes are remapped, which is a property of a
875
+ * runtime and can change with the next one, this looks: the path as given
876
+ * first, then the same path under the simulator's data root. Neither existing
877
+ * means the redirect went somewhere unforeseen, and the path as given is
878
+ * returned with `found: false` so the caller can say so.
879
+ */
880
+ async locateRedirect(target, path) {
881
+ if (await exists(path)) return {
882
+ path,
883
+ found: true
884
+ };
885
+ if (target.dataPath) {
886
+ const inside = join(target.dataPath, path.replace(/^\/private(?=\/)/, ""));
887
+ if (await exists(inside)) return {
888
+ path: inside,
889
+ found: true
890
+ };
891
+ }
892
+ return {
893
+ path,
894
+ found: false
895
+ };
896
+ }
760
897
  /** The capture lane that needs no runner — the whole reason this server is cheap to start. */
761
898
  async screenshotPng(target) {
762
899
  this.requireBooted(target);
@@ -776,6 +913,10 @@ var SimulatorClient = class {
776
913
  wdaUrl() {
777
914
  return this.opts.wdaUrl ?? `http://127.0.0.1:${this.opts.wdaPort}`;
778
915
  }
916
+ /** The simulator `resolveTarget` falls back to when no hint is given. */
917
+ get defaultSimulatorId() {
918
+ return this.opts.defaultSimulatorId;
919
+ }
779
920
  get wdaPort() {
780
921
  return this.opts.wdaPort;
781
922
  }
@@ -794,8 +935,7 @@ var SimulatorClient = class {
794
935
  return client;
795
936
  }
796
937
  };
797
- //#endregion
798
- //#region src/tools/util.ts
938
+ // src/tools/util.ts
799
939
  /**
800
940
  * How the shared tools name themselves and each other in this server.
801
941
  *
@@ -826,8 +966,26 @@ const { bundleIdArg, confirmArg, detailArg, screenshotArg, settleArg, xArg, yArg
826
966
  * worse than either alone.
827
967
  */
828
968
  const deviceArg = args.targetArg;
829
- //#endregion
830
- //#region src/tools/apps.ts
969
+ // src/tools/apps.ts
970
+ /** `io.mgcrea.Canopy: 12345` is the whole of what `simctl launch` prints. */
971
+ const parsePid = (output) => {
972
+ const pid = /:\s*(\d+)\s*$/.exec(output.trim())?.[1];
973
+ return pid ? Number(pid) : void 0;
974
+ };
975
+ const NOT_FOUND_WARNING = "The log file is neither where it was asked for nor under the simulator's data directory. Set IOS_SIMULATOR_OUTPUT_DIR to a directory under your home folder, which the simulator writes to as-is.";
976
+ /**
977
+ * Find the redirect, giving the simulator a moment to create it. `simctl
978
+ * launch` returns once the app is spawned, and the file is opened as part of
979
+ * that spawn, so the first look nearly always finds it; the retries only cover
980
+ * a slow spawn rather than a known race.
981
+ */
982
+ const locate = async (client, target, path) => {
983
+ for (let attempt = 0;; attempt += 1) {
984
+ const found = await client.locateRedirect(target, path);
985
+ if (found.found || attempt >= 4) return found;
986
+ await new Promise((resolve) => setTimeout(resolve, 100));
987
+ }
988
+ };
831
989
  const registerListAppsTool = (server, client) => {
832
990
  server.registerTool("ios_simulator_list_apps", {
833
991
  title: "iOS Simulator: List Apps",
@@ -867,39 +1025,95 @@ const registerAppTools = (server, client, launchArgs, outputDir) => {
867
1025
  }));
868
1026
  server.registerTool("ios_simulator_launch", {
869
1027
  title: "iOS Simulator: Launch",
870
- description: "Launch an installed app. Pass `arguments` to put it into a fixture or demo mode — that is what IOS_SIMULATOR_LAUNCH_ARGS sets as the default for every launch that does not override it. Standard output and error are captured to files under the output directory, so an app that dies on launch leaves something readable behind.",
1028
+ description: "Launch an installed app. Pass `arguments` to put it into a fixture or demo mode — that is what IOS_SIMULATOR_LAUNCH_ARGS sets as the default for every launch that does not override it. Standard output and error are captured to files under the output directory, so an app that dies on launch leaves something readable behind. Pass `capture_logs: true` to also get its Logger/os_log output, in one file read with ios_simulator_read_logs.",
871
1029
  inputSchema: z.object({
872
1030
  device: deviceArg,
873
1031
  bundle_id: bundleIdArg,
874
1032
  arguments: z.array(z.string()).optional().describe("Launch arguments, e.g. [\"-DemoMode\", \"YES\"]. Overrides IOS_SIMULATOR_LAUNCH_ARGS rather than adding to it."),
875
- terminate_first: z.boolean().default(true).describe("Replace a running copy rather than attaching to it. On by default so a launch means a fresh process and a predictable first screen.")
1033
+ terminate_first: z.boolean().default(true).describe("Replace a running copy rather than attaching to it. On by default so a launch means a fresh process and a predictable first screen."),
1034
+ capture_logs: z.boolean().default(false).describe("Write stdout, stderr and every Logger/os_log message to one fresh file, for ios_simulator_read_logs. Sets OS_ACTIVITY_DT_MODE=YES in the app's environment, which is what Xcode sets to mirror unified logging onto stderr; without it an app's Logger output reaches no file at all.")
876
1035
  }),
877
1036
  annotations: {
878
1037
  readOnlyHint: false,
879
1038
  destructiveHint: false,
880
1039
  idempotentHint: false
881
1040
  }
882
- }, async ({ device, bundle_id, arguments: args, terminate_first }) => wrap(async () => {
1041
+ }, async ({ device, bundle_id, arguments: args, terminate_first, capture_logs }) => wrap(async () => {
883
1042
  const target = client.requireBooted(await client.resolveTarget(device));
884
1043
  const effective = args ?? launchArgs;
885
- const stdout = `${outputDir}/${bundle_id}.out.log`;
886
- const stderr = `${outputDir}/${bundle_id}.err.log`;
1044
+ const said = {
1045
+ ...effective.length > 0 ? { arguments: effective } : {},
1046
+ ...args === void 0 && launchArgs.length > 0 ? { argumentsFrom: "IOS_SIMULATOR_LAUNCH_ARGS" } : {}
1047
+ };
1048
+ if (capture_logs) {
1049
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-");
1050
+ const requested = join(outputDir, "logs", `${bundle_id}-${stamp}.log`);
1051
+ const output = await client.simctl.launch(target.id, bundle_id, {
1052
+ args: effective,
1053
+ terminateExisting: terminate_first,
1054
+ stdout: requested,
1055
+ stderr: requested,
1056
+ env: { OS_ACTIVITY_DT_MODE: "YES" }
1057
+ });
1058
+ const pid = parsePid(output);
1059
+ const log = await locate(client, target, requested);
1060
+ client.captures.record({
1061
+ udid: target.id,
1062
+ simulatorName: target.name,
1063
+ bundleId: bundle_id,
1064
+ path: log.path,
1065
+ pid,
1066
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1067
+ });
1068
+ return {
1069
+ launched: bundle_id,
1070
+ ...pid !== void 0 ? { pid } : {},
1071
+ ...said,
1072
+ capturingLogs: true,
1073
+ log: log.path,
1074
+ ...log.found ? {} : { warning: NOT_FOUND_WARNING }
1075
+ };
1076
+ }
1077
+ const stdout = join(outputDir, `${bundle_id}.out.log`);
1078
+ const stderr = join(outputDir, `${bundle_id}.err.log`);
887
1079
  const output = await client.simctl.launch(target.id, bundle_id, {
888
1080
  args: effective,
889
1081
  terminateExisting: terminate_first,
890
1082
  stdout,
891
1083
  stderr
892
1084
  });
893
- const pid = /:\s*(\d+)\s*$/.exec(output.trim())?.[1];
1085
+ const pid = parsePid(output);
1086
+ const [out, err] = await Promise.all([locate(client, target, stdout), locate(client, target, stderr)]);
894
1087
  return {
895
1088
  launched: bundle_id,
896
- ...pid ? { pid: Number(pid) } : {},
897
- ...effective.length > 0 ? { arguments: effective } : {},
898
- ...args === void 0 && launchArgs.length > 0 ? { argumentsFrom: "IOS_SIMULATOR_LAUNCH_ARGS" } : {},
1089
+ ...pid !== void 0 ? { pid } : {},
1090
+ ...said,
899
1091
  logs: {
900
- stdout,
901
- stderr
902
- }
1092
+ stdout: out.path,
1093
+ stderr: err.path
1094
+ },
1095
+ ...out.found && err.found ? {} : { warning: NOT_FOUND_WARNING }
1096
+ };
1097
+ }));
1098
+ server.registerTool("ios_simulator_uninstall", {
1099
+ title: "iOS Simulator: Uninstall",
1100
+ description: "Remove an app from the simulator, **with its data container** — documents, databases and preferences all go, and nothing brings them back. To get back to a cold start while keeping the data, use ios_simulator_terminate. To test a genuine first launch of one app, this is narrower than ios_simulator_erase, which wipes every app on the simulator. Uninstalling an app that is not installed also succeeds, so success does not prove it was there.",
1101
+ inputSchema: z.object({
1102
+ device: deviceArg,
1103
+ bundle_id: bundleIdArg,
1104
+ confirm: z.literal(true).describe("Must be true. Explicit acknowledgement that the app's data is deleted.")
1105
+ }),
1106
+ annotations: {
1107
+ readOnlyHint: false,
1108
+ destructiveHint: true,
1109
+ idempotentHint: true
1110
+ }
1111
+ }, async ({ device, bundle_id }) => wrap(async () => {
1112
+ const target = client.requireBooted(await client.resolveTarget(device));
1113
+ await client.simctl.uninstall(target.id, bundle_id);
1114
+ return {
1115
+ uninstalled: bundle_id,
1116
+ udid: target.id
903
1117
  };
904
1118
  }));
905
1119
  server.registerTool("ios_simulator_terminate", {
@@ -937,8 +1151,7 @@ const registerAppTools = (server, client, launchArgs, outputDir) => {
937
1151
  return { opened: url };
938
1152
  }));
939
1153
  };
940
- //#endregion
941
- //#region src/tools/diagnostics.ts
1154
+ // src/tools/diagnostics.ts
942
1155
  /**
943
1156
  * Which simulator owns the WebDriverAgent port.
944
1157
  *
@@ -1026,7 +1239,7 @@ const diagnose = async (client, ctx, deviceHint) => {
1026
1239
  try {
1027
1240
  target = await client.resolveTarget(deviceHint);
1028
1241
  } catch (err) {
1029
- if (booted.length > 0) {
1242
+ if (booted.length > 0 || (deviceHint ?? client.defaultSimulatorId) !== void 0) {
1030
1243
  problems.push(err instanceof Error ? err.message : String(err));
1031
1244
  const remedy = err.remedy;
1032
1245
  if (remedy) nextSteps.push(remedy);
@@ -1096,8 +1309,7 @@ const registerDiagnosticsTools = (server, client, ctx) => {
1096
1309
  text: JSON.stringify(await diagnose(client, ctx))
1097
1310
  }] }));
1098
1311
  };
1099
- //#endregion
1100
- //#region src/tools/environment.ts
1312
+ // src/tools/environment.ts
1101
1313
  /** simctl's own list, verbatim — a value it does not accept is not worth guessing at. */
1102
1314
  const PRIVACY_SERVICES = [
1103
1315
  "all",
@@ -1173,6 +1385,26 @@ const registerEnvironmentTools = (server, client) => {
1173
1385
  }
1174
1386
  }, async ({ device, appearance, content_size, increase_contrast, status_bar, clear_status_bar, location, clear_location, permission }) => wrap(async () => {
1175
1387
  const target = client.requireBooted(await client.resolveTarget(device));
1388
+ const statusBarFlags = status_bar ? [
1389
+ ...status_bar.time ? ["--time", status_bar.time] : [],
1390
+ ...status_bar.wifi_bars !== void 0 ? [
1391
+ "--wifiMode",
1392
+ "active",
1393
+ "--wifiBars",
1394
+ String(status_bar.wifi_bars)
1395
+ ] : [],
1396
+ ...status_bar.cellular_bars !== void 0 ? [
1397
+ "--cellularMode",
1398
+ "active",
1399
+ "--cellularBars",
1400
+ String(status_bar.cellular_bars)
1401
+ ] : [],
1402
+ ...status_bar.operator_name !== void 0 ? ["--operatorName", status_bar.operator_name] : [],
1403
+ ...status_bar.battery_level !== void 0 ? ["--batteryLevel", String(status_bar.battery_level)] : [],
1404
+ ...status_bar.battery_state ? ["--batteryState", status_bar.battery_state] : []
1405
+ ] : [];
1406
+ if (status_bar && statusBarFlags.length === 0) throw new IosError$1("`status_bar` was given with no fields to override.", { remedy: "Set at least one field, or use `clear_status_bar` to drop the overrides." });
1407
+ if (permission && permission.action !== "reset" && !permission.bundle_id) throw new IosError$1(`\`${permission.action}\` needs a bundle_id.`, { remedy: "Pass the app's bundle id, or use `reset`, which does not need one." });
1176
1408
  const applied = [];
1177
1409
  if (appearance) {
1178
1410
  await client.simctl.uiSet(target.id, "appearance", appearance);
@@ -1191,26 +1423,7 @@ const registerEnvironmentTools = (server, client) => {
1191
1423
  applied.push("status_bar cleared");
1192
1424
  }
1193
1425
  if (status_bar) {
1194
- const flags = [
1195
- ...status_bar.time ? ["--time", status_bar.time] : [],
1196
- ...status_bar.wifi_bars !== void 0 ? [
1197
- "--wifiMode",
1198
- "active",
1199
- "--wifiBars",
1200
- String(status_bar.wifi_bars)
1201
- ] : [],
1202
- ...status_bar.cellular_bars !== void 0 ? [
1203
- "--cellularMode",
1204
- "active",
1205
- "--cellularBars",
1206
- String(status_bar.cellular_bars)
1207
- ] : [],
1208
- ...status_bar.operator_name !== void 0 ? ["--operatorName", status_bar.operator_name] : [],
1209
- ...status_bar.battery_level !== void 0 ? ["--batteryLevel", String(status_bar.battery_level)] : [],
1210
- ...status_bar.battery_state ? ["--batteryState", status_bar.battery_state] : []
1211
- ];
1212
- if (flags.length === 0) throw new IosError$1("`status_bar` was given with no fields to override.", { remedy: "Set at least one field, or use `clear_status_bar` to drop the overrides." });
1213
- await client.simctl.statusBarOverride(target.id, flags);
1426
+ await client.simctl.statusBarOverride(target.id, statusBarFlags);
1214
1427
  applied.push("status_bar overridden");
1215
1428
  }
1216
1429
  if (clear_location) {
@@ -1222,17 +1435,21 @@ const registerEnvironmentTools = (server, client) => {
1222
1435
  applied.push(`location=${location.latitude},${location.longitude}`);
1223
1436
  }
1224
1437
  if (permission) {
1225
- if (permission.action !== "reset" && !permission.bundle_id) throw new IosError$1(`\`${permission.action}\` needs a bundle_id.`, { remedy: "Pass the app's bundle id, or use `reset`, which does not need one." });
1226
1438
  await client.simctl.privacy(target.id, permission.action, permission.service, permission.bundle_id);
1227
1439
  applied.push(`permission ${permission.action} ${permission.service}`);
1228
1440
  }
1229
1441
  if (applied.length === 0) throw new IosError$1("Nothing to do — no field was given.", { remedy: "Set at least one of appearance, content_size, status_bar, location, permission." });
1442
+ const [appearanceNow, contentSize, statusBar] = await Promise.all([
1443
+ client.simctl.uiGet(target.id, "appearance"),
1444
+ client.simctl.uiGet(target.id, "content_size"),
1445
+ client.simctl.statusBarList(target.id)
1446
+ ]);
1230
1447
  return {
1231
1448
  applied,
1232
1449
  state: {
1233
- appearance: await client.simctl.uiGet(target.id, "appearance"),
1234
- contentSize: await client.simctl.uiGet(target.id, "content_size"),
1235
- statusBar: await client.simctl.statusBarList(target.id)
1450
+ appearance: appearanceNow,
1451
+ contentSize,
1452
+ statusBar
1236
1453
  }
1237
1454
  };
1238
1455
  }));
@@ -1282,11 +1499,7 @@ const registerEnvironmentTools = (server, client) => {
1282
1499
  };
1283
1500
  }));
1284
1501
  };
1285
- //#endregion
1286
- //#region src/tools/lifecycle.ts
1287
- /** Booting is slow and visible; this is how long `power` waits to see it land. */
1288
- const BOOT_POLL_MS = 500;
1289
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1502
+ // src/tools/lifecycle.ts
1290
1503
  const registerListTool = (server, client) => {
1291
1504
  server.registerTool("ios_simulator_list", {
1292
1505
  title: "iOS Simulator: List",
@@ -1331,13 +1544,14 @@ const registerLifecycleTools = (server, client) => {
1331
1544
  }, async ({ device, state, open_window, wait_ms }) => wrap(async () => {
1332
1545
  const target = await client.resolveTarget(device);
1333
1546
  if (state === "shutdown") {
1334
- if (target.state !== "Booted") return {
1547
+ if (target.state === "Shutdown") return {
1335
1548
  udid: target.id,
1336
1549
  name: target.name,
1337
1550
  state: "Shutdown",
1338
1551
  alreadyThere: true
1339
1552
  };
1340
1553
  await client.simctl.shutdown(target.id);
1554
+ client.forgetSimulators();
1341
1555
  return {
1342
1556
  udid: target.id,
1343
1557
  name: target.name,
@@ -1345,7 +1559,10 @@ const registerLifecycleTools = (server, client) => {
1345
1559
  };
1346
1560
  }
1347
1561
  const already = target.state === "Booted";
1348
- if (!already) await client.simctl.boot(target.id);
1562
+ if (!already) {
1563
+ await client.simctl.boot(target.id);
1564
+ client.forgetSimulators();
1565
+ }
1349
1566
  let windowWarning;
1350
1567
  let windowNote;
1351
1568
  if (open_window) await client.simctl.openApp(client.openPath).then((app) => {
@@ -1353,25 +1570,27 @@ const registerLifecycleTools = (server, client) => {
1353
1570
  }).catch((err) => {
1354
1571
  windowWarning = `Booted, but no window could be opened: ${err instanceof Error ? err.message : String(err)}`;
1355
1572
  });
1356
- const deadline = Date.now() + wait_ms;
1357
- let observed = target.state;
1358
- while (Date.now() < deadline) {
1359
- observed = (await client.listSimulators({ fresh: true })).find((sim) => sim.id === target.id)?.state ?? observed;
1360
- if (observed === "Booted") break;
1361
- await sleep(BOOT_POLL_MS);
1573
+ let ready = already;
1574
+ let waitWarning = `Not waited for, since wait_ms is 0: it may still be starting up.`;
1575
+ if (!already && wait_ms > 0) try {
1576
+ await client.simctl.bootstatus(target.id, wait_ms);
1577
+ ready = true;
1578
+ } catch (err) {
1579
+ waitWarning = err instanceof CommandTimeoutError ? `Not finished booting after ${wait_ms}ms. Raise wait_ms, or check Xcode.` : `Booted, but could not confirm it finished: ${err instanceof Error ? err.message : String(err)}`;
1362
1580
  }
1581
+ const observed = Object.values(await client.simctl.listDevices()).flat().find((sim) => sim.udid === target.id)?.state ?? target.state;
1363
1582
  return {
1364
1583
  udid: target.id,
1365
1584
  name: target.name,
1366
1585
  state: observed,
1367
1586
  ...already ? { alreadyThere: true } : {},
1368
1587
  ...windowNote ? { note: windowNote } : {},
1369
- ...observed === "Booted" ? windowWarning ? { warning: windowWarning } : {} : { warning: `Still ${observed} after ${wait_ms}ms. Raise wait_ms, or check Xcode.` }
1588
+ ...ready ? windowWarning ? { warning: windowWarning } : {} : { warning: waitWarning }
1370
1589
  };
1371
1590
  }));
1372
1591
  server.registerTool("ios_simulator_erase", {
1373
1592
  title: "iOS Simulator: Erase",
1374
- description: "Wipe a simulator back to a factory state: installed apps, their data, the keychain and every granted permission. The only irreversible tool here, and the reason it is the only one behind `confirm`. Use it to test a genuine first launch. A booted simulator is shut down first and booted again afterwards, because simctl refuses to erase a running one.",
1593
+ description: "Wipe a simulator back to a factory state: installed apps, their data, the keychain and every granted permission. Irreversible, hence `confirm`; ios_simulator_uninstall is the narrower way to a first launch of one app. A booted simulator is shut down first and booted again afterwards, because simctl refuses to erase a running one.",
1375
1594
  inputSchema: z.object({
1376
1595
  device: deviceArg,
1377
1596
  confirm: confirmArg
@@ -1387,6 +1606,7 @@ const registerLifecycleTools = (server, client) => {
1387
1606
  if (wasBooted) await client.simctl.shutdown(target.id);
1388
1607
  await client.simctl.erase(target.id);
1389
1608
  if (wasBooted) await client.simctl.boot(target.id);
1609
+ client.forgetSimulators();
1390
1610
  return {
1391
1611
  udid: target.id,
1392
1612
  name: target.name,
@@ -1395,8 +1615,261 @@ const registerLifecycleTools = (server, client) => {
1395
1615
  };
1396
1616
  }));
1397
1617
  };
1398
- //#endregion
1399
- //#region src/tools/runner.ts
1618
+ // src/client/crash.ts
1619
+ /**
1620
+ * Simulator crash reports, out of this Mac's own DiagnosticReports.
1621
+ *
1622
+ * A simulator app is a Mac process, so when it crashes the Mac's ReportCrash
1623
+ * writes the `.ips` — into `~/Library/Logs/DiagnosticReports`, older ones under
1624
+ * `Retired/`, mixed in with every Mac app's and daemon's. On the machine this
1625
+ * was written against that was 593 `.ips` files, 464 of them from simulators,
1626
+ * and nearly all of those were the simulators' own daemons rather than an app
1627
+ * anyone was debugging.
1628
+ *
1629
+ * Telling the two apart, as measured on Xcode 27.0 with an iOS 27.0 runtime:
1630
+ *
1631
+ * - The header line's `platform` is the Mach-O platform of the crashed binary.
1632
+ * Every simulator report had 7 (`PLATFORM_IOSSIMULATOR` in
1633
+ * `<mach-o/loader.h>`) and every Mac report had 1, with no exceptions either
1634
+ * way. That is one line of a few hundred bytes, so it is the filter.
1635
+ * - The header's `is_simulated` is **not** the marker, despite the name: it
1636
+ * was set on 13 reports, every one a Mac process with platform 1 — Safari,
1637
+ * Mail and two of their helpers.
1638
+ * - `procPath` does not say either: it is anonymised to `/Volumes/VOLUME/…` or
1639
+ * `/Users/USER/…` with the middle starred out, so no device path survives.
1640
+ * - Which simulator is in the report body, as `coalitionName`:
1641
+ * `com.apple.CoreSimulator.SimDevice.<UDID>`. Its key ended by byte 1,731 in
1642
+ * every one of the 464, so reading the first `HEAD_BYTES` finds it without
1643
+ * parsing reports that run to 80 KB.
1644
+ */
1645
+ /** `PLATFORM_*SIMULATOR` in `<mach-o/loader.h>`: iOS, tvOS, watchOS, visionOS. Only 7 was seen. */
1646
+ const SIMULATOR_PLATFORMS = /* @__PURE__ */ new Set([
1647
+ 7,
1648
+ 8,
1649
+ 9,
1650
+ 12
1651
+ ]);
1652
+ /** Enough for the header and the body's `coalitionName`, with room to spare. */
1653
+ const HEAD_BYTES = 8192;
1654
+ const COALITION = /"coalitionName"\s*:\s*"com\.apple\.CoreSimulator\.SimDevice\.([0-9A-Fa-f-]{36})"/;
1655
+ /** Opened at once. The directory holds hundreds of files; this keeps clear of EMFILE. */
1656
+ const CONCURRENCY = 32;
1657
+ const readHead = async (path) => {
1658
+ const handle = await open(path, "r");
1659
+ try {
1660
+ const buffer = Buffer.alloc(HEAD_BYTES);
1661
+ const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
1662
+ return buffer.subarray(0, bytesRead).toString("utf8");
1663
+ } finally {
1664
+ await handle.close();
1665
+ }
1666
+ };
1667
+ const parseHeader = (text) => {
1668
+ const newline = text.indexOf("\n");
1669
+ try {
1670
+ return JSON.parse(newline === -1 ? text : text.slice(0, newline));
1671
+ } catch {
1672
+ return;
1673
+ }
1674
+ };
1675
+ /** `2026-10-08 23:28:26.00 +0200` → epoch ms, for ordering. File mtimes are not reliable here. */
1676
+ const timestampMs = (value) => {
1677
+ const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?) ([+-]\d{2})(\d{2})$/.exec(value ?? "");
1678
+ return m ? Date.parse(`${m[1]}T${m[2]}${m[3]}:${m[4]}`) : 0;
1679
+ };
1680
+ const listIps = async (dir) => {
1681
+ const top = await readdir(dir).catch((err) => {
1682
+ if (err.code === "ENOENT") return [];
1683
+ throw err;
1684
+ });
1685
+ const retired = await readdir(join(dir, "Retired")).catch(() => []);
1686
+ return [...top.filter((name) => name.endsWith(".ips")), ...retired.filter((name) => name.endsWith(".ips")).map((name) => `Retired/${name}`)];
1687
+ };
1688
+ /**
1689
+ * What each report's head said, by path; `null` for one that is not from a
1690
+ * simulator. A written `.ips` never changes and its name carries a timestamp,
1691
+ * so after the first list only new reports cost a read. A Mac with Xcode
1692
+ * holds hundreds of them, and every list used to open each one.
1693
+ */
1694
+ const scanned = /* @__PURE__ */ new Map();
1695
+ /**
1696
+ * Every simulator report in `dir`, newest first. Reads the first 8 KB of each
1697
+ * `.ips`, never the whole file, except to find the UDID in the rare report
1698
+ * whose `coalitionName` did not fit in that.
1699
+ */
1700
+ const listSimulatorCrashReports = async (dir) => {
1701
+ const names = await listIps(dir);
1702
+ const rows = [];
1703
+ for (let i = 0; i < names.length; i += CONCURRENCY) {
1704
+ const batch = await Promise.all(names.slice(i, i + CONCURRENCY).map(async (name) => {
1705
+ const path = join(dir, name);
1706
+ const known = scanned.get(path);
1707
+ if (known !== void 0) return known ?? void 0;
1708
+ const head = await readHead(path).catch(() => "");
1709
+ const header = parseHeader(head);
1710
+ if (!header) return void 0;
1711
+ if (!SIMULATOR_PLATFORMS.has(Number(header.platform))) {
1712
+ scanned.set(path, null);
1713
+ return;
1714
+ }
1715
+ let udid = COALITION.exec(head)?.[1];
1716
+ if (!udid) udid = COALITION.exec(await readFile(path, "utf8").catch(() => ""))?.[1];
1717
+ const row = {
1718
+ name,
1719
+ process: header.app_name || header.name || crashProcessName(name),
1720
+ bundleId: header.bundleID,
1721
+ kind: crashKind(header.bug_type),
1722
+ timestamp: header.timestamp,
1723
+ udid: udid?.toUpperCase()
1724
+ };
1725
+ scanned.set(path, row);
1726
+ return row;
1727
+ }));
1728
+ for (const row of batch) if (row) rows.push(row);
1729
+ }
1730
+ return rows.toSorted((a, b) => timestampMs(b.timestamp) - timestampMs(a.timestamp));
1731
+ };
1732
+ /**
1733
+ * The absolute path of a report named by `ios_simulator_list_crash_logs`, or a
1734
+ * refusal. The schema already rejects `..` and absolute names; this is the
1735
+ * check that cannot be argued with, made on the resolved path.
1736
+ */
1737
+ const crashReportPath = (dir, name) => {
1738
+ const root = resolve(dir);
1739
+ const path = resolve(root, name);
1740
+ const rel = relative(root, path);
1741
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new IosError$1(`"${name}" is not inside the crash reports directory.`, { remedy: "Pass a `name` exactly as ios_simulator_list_crash_logs returned it." });
1742
+ return path;
1743
+ };
1744
+ /** Whether a report's header says it came from a simulator. */
1745
+ const isSimulatorReport = (text) => {
1746
+ const header = parseHeader(text);
1747
+ return header !== void 0 && SIMULATOR_PLATFORMS.has(Number(header.platform));
1748
+ };
1749
+ /** The simulator a full report came from, when its body says. */
1750
+ const reportUdid = (text) => COALITION.exec(text)?.[1]?.toUpperCase();
1751
+ // src/tools/logs.ts
1752
+ /** UDID → name, for labelling reports. Best effort: a Mac with no Xcode still has reports. */
1753
+ const simulatorNames = async (client) => {
1754
+ try {
1755
+ return new Map((await client.listSimulators()).map((sim) => [sim.id.toUpperCase(), sim.name]));
1756
+ } catch {
1757
+ return /* @__PURE__ */ new Map();
1758
+ }
1759
+ };
1760
+ /**
1761
+ * Crash reports: the half of "logs" that needs nothing launched. Observing, so
1762
+ * registered whatever the write gate says — reading a report changes nothing
1763
+ * on the simulator, the same way a screenshot does not.
1764
+ */
1765
+ const registerCrashLogTools = (server, client, ctx) => {
1766
+ const dir = ctx.config.crashReportsDir;
1767
+ server.registerTool("ios_simulator_list_crash_logs", {
1768
+ title: "iOS Simulator: List Crash Logs",
1769
+ description: "List crash reports from simulator processes, newest first. A simulator app's crash is written by this Mac into ~/Library/Logs/DiagnosticReports alongside the Mac's own crashes; this keeps only the simulators' and says which simulator each came from. Most of them are the simulators' own system daemons, so pass `process` to find your app's. Read one with ios_simulator_get_crash_log. Nothing needs to be booted.",
1770
+ inputSchema: z.object({
1771
+ device: deviceArg.describe("Only reports from this simulator, by UDID or name as shown by ios_simulator_list. Omit for every simulator's — unlike the other tools, this does not default to the booted one, since a crash often outlives the boot it happened in."),
1772
+ process: z.string().optional().describe("Case-insensitive substring of the process name or bundle id, e.g. \"Canopy\" or \"io.mgcrea\". The process name is the app's executable name, usually but not always its display name."),
1773
+ kinds: z.array(z.string()).optional().describe("Report kinds to include, e.g. [\"crash\"]. Defaults to [\"crash\", \"jetsam\", \"user_fault\"]; kinds without a name come back as \"bug_type_<n>\". Pass [\"all\"] for everything."),
1774
+ limit: z.number().int().min(1).max(200).default(20).describe("Maximum reports to return, newest first (1-200). Defaults to 20.")
1775
+ }),
1776
+ annotations: { readOnlyHint: true }
1777
+ }, async ({ device, process, kinds, limit }) => wrap(async () => {
1778
+ const target = device ? await client.resolveTarget(device) : void 0;
1779
+ const wanted = kinds ?? DEFAULT_CRASH_KINDS;
1780
+ const needle = process?.toLowerCase();
1781
+ const [all, names] = await Promise.all([listSimulatorCrashReports(dir), target ? void 0 : simulatorNames(client)]);
1782
+ const matching = all.filter((report) => (!target || report.udid === target.id.toUpperCase()) && (wanted.includes("all") || wanted.includes(report.kind)) && (!needle || report.process.toLowerCase().includes(needle) || (report.bundleId?.toLowerCase().includes(needle) ?? false)));
1783
+ return {
1784
+ ...target ? {
1785
+ simulator: target.name,
1786
+ udid: target.id
1787
+ } : {},
1788
+ reports: matching.slice(0, limit).map(({ udid, bundleId, ...row }) => ({
1789
+ ...row,
1790
+ ...bundleId ? { bundleId } : {},
1791
+ ...names && udid ? {
1792
+ udid,
1793
+ ...names.get(udid) ? { simulator: names.get(udid) } : {}
1794
+ } : {}
1795
+ })),
1796
+ total: matching.length
1797
+ };
1798
+ }));
1799
+ server.registerTool("ios_simulator_get_crash_log", {
1800
+ title: "iOS Simulator: Get Crash Log",
1801
+ description: "Read one simulator crash report and return what matters in it: the exception, why the process was terminated, any application-specific message, and the faulting thread's top frames with library names resolved. The full report's path is returned too; it is often tens of kilobytes, so read it only when the summary is not enough. A Swift fatalError's message may not be in the report at all — on a simulator it goes to the app's stderr.",
1802
+ inputSchema: z.object({ name: z.string().regex(/^[^/][^\0]*\.ips$/, "Pass a `name` exactly as ios_simulator_list_crash_logs returned it.").refine((value) => !value.split("/").includes(".."), "`name` cannot contain `..`.").describe("The report's `name` from ios_simulator_list_crash_logs, e.g. \"Canopy-2026-10-08-101500.ips\" or \"Retired/Canopy-2026-10-02-213409.ips\". Older reports move under Retired/, so a name that worked yesterday may need listing again.") }),
1803
+ annotations: { readOnlyHint: true }
1804
+ }, async ({ name }) => wrap(async () => {
1805
+ const path = crashReportPath(dir, name);
1806
+ let text;
1807
+ try {
1808
+ text = await readFile(path, "utf8");
1809
+ } catch {
1810
+ throw new IosError$1(`No crash report named ${name}.`, { remedy: "Run ios_simulator_list_crash_logs again: reports move to Retired/ as they age, and the system deletes old ones." });
1811
+ }
1812
+ if (!isSimulatorReport(text)) throw new IosError$1(`${name} is a crash report from this Mac, not from a simulator.`, { remedy: "Pick a report from ios_simulator_list_crash_logs, which lists only simulators'." });
1813
+ const summary = summarizeCrashReport(text);
1814
+ const udid = reportUdid(text);
1815
+ const simulator = udid ? (await simulatorNames(client)).get(udid) : void 0;
1816
+ const asi = summary.crash?.["applicationSpecificInfo"];
1817
+ return {
1818
+ name,
1819
+ path,
1820
+ ...udid ? { udid } : {},
1821
+ ...simulator ? { simulator } : {},
1822
+ ...summary,
1823
+ ...summary.crash ? asi ? {} : { note: "The report carries no application-specific message. A Swift fatalError's text goes to the app's stderr on a simulator" + (ctx.allowWrites ? ", so relaunch with ios_simulator_launch `capture_logs: true` and read it with ios_simulator_read_logs." : ".") } : { note: "Not a crash report with a faulting thread; read the file at `path`." }
1824
+ };
1825
+ }));
1826
+ };
1827
+ /**
1828
+ * Reading an app's captured log. Registered with the launch tool it depends on,
1829
+ * behind the write gate: without `ios_simulator_launch` there is never anything
1830
+ * to read.
1831
+ */
1832
+ const registerLogReadTools = (server, client) => {
1833
+ server.registerTool("ios_simulator_read_logs", {
1834
+ title: "iOS Simulator: Read Logs",
1835
+ description: "Read an app's log output: stdout, stderr and every Logger/os_log message the process emits, interleaved in one file. Only available for an app started with ios_simulator_launch `capture_logs: true`. Returns the newest `limit` lines plus a `next` cursor; pass it back to get only what arrived since, which is how to see what one tap logged. `capturing` turns false once the app's process has exited, and what it wrote stays readable. A crash's backtrace is not in here — see ios_simulator_list_crash_logs — but its fatalError message is.",
1836
+ inputSchema: z.object({
1837
+ device: deviceArg,
1838
+ bundle_id: z.string().optional().describe("Which app's capture, e.g. \"io.mgcrea.Canopy\". Defaults to the most recent capture."),
1839
+ cursor: z.number().int().min(0).optional().describe("The `next` value from the previous read, to get only newer lines. Omit to read from the start of the capture."),
1840
+ filter: z.string().optional().describe("Case-insensitive regular expression a line must match, e.g. \"error|fail\" or \"\\[Sync\\]\". Applied before `limit`, so it finds matches across the whole range."),
1841
+ limit: z.number().int().min(1).max(2e3).default(200).describe("Most lines to return; the newest are kept and the rest counted in `omitted`. Defaults to 200 — filter rather than raising this.")
1842
+ }),
1843
+ annotations: { readOnlyHint: true }
1844
+ }, async ({ device, bundle_id, cursor, filter, limit }) => wrap(async () => {
1845
+ const target = device ? await client.resolveTarget(device) : void 0;
1846
+ const capture = client.captures.find(target?.id, bundle_id);
1847
+ if (!capture) throw new IosError$1(bundle_id ? `No log capture for ${bundle_id}.` : "No log capture has been started.", { remedy: "Launch the app with ios_simulator_launch {\"bundle_id\":\"…\",\"capture_logs\":true}. A capture belongs to this server process, so one started before it restarted is not known here, though its file is still under the output directory." });
1848
+ const running = client.captures.running(capture);
1849
+ let result;
1850
+ try {
1851
+ result = await readLogFile(capture.path, {
1852
+ cursor,
1853
+ filter,
1854
+ limit,
1855
+ complete: !running
1856
+ });
1857
+ } catch (err) {
1858
+ if (err.code !== "ENOENT") throw err;
1859
+ throw new IosError$1(`The log file for ${capture.bundleId} is gone: ${capture.path}`, { remedy: "Erasing the simulator deletes it when it was written under the simulator's data directory. Launch again with `capture_logs: true`." });
1860
+ }
1861
+ return {
1862
+ bundleId: capture.bundleId,
1863
+ simulator: capture.simulatorName,
1864
+ capturing: running,
1865
+ ...capture.pid !== void 0 ? { pid: capture.pid } : {},
1866
+ ...result,
1867
+ path: capture.path,
1868
+ ...result.skippedBytes > 0 ? { note: `${result.skippedBytes} bytes before this window were not read. Pass the returned \`next\` sooner, or read the file at \`path\`.` } : {}
1869
+ };
1870
+ }));
1871
+ };
1872
+ // src/tools/runner.ts
1400
1873
  const defaultSpawnRunner = async (command, args, { env, logPath }) => {
1401
1874
  await mkdir(dirname(logPath), { recursive: true });
1402
1875
  const log = await open(logPath, "a");
@@ -1418,9 +1891,22 @@ const defaultSpawnRunner = async (command, args, { env, logPath }) => {
1418
1891
  };
1419
1892
  const scriptPath = () => {
1420
1893
  const here = dirname(fileURLToPath(import.meta.url));
1421
- for (const candidate of [join(here, "..", "scripts", "wda.sh"), join(here, "..", "..", "scripts", "wda.sh")]) return candidate;
1894
+ for (const candidate of [join(here, "..", "scripts", "wda.sh"), join(here, "..", "..", "scripts", "wda.sh")]) if (existsSync(candidate)) return candidate;
1422
1895
  throw new IosError$1("Could not find `scripts/wda.sh` beside this module.");
1423
1896
  };
1897
+ /**
1898
+ * Whether `wda.sh run` has anything to run: the same directory and glob it
1899
+ * looks in. Without a build it exits with "no build found" into a log nobody
1900
+ * reads, while a detached spawn has already been reported as started.
1901
+ */
1902
+ const runnerBuilt = async () => {
1903
+ const wdaDir = process.env["WDA_DIR"] || join(homedir(), ".cache", "mcp-ios-simulator", "WebDriverAgent");
1904
+ const products = join(wdaDir, ".build", "Build", "Products");
1905
+ return {
1906
+ built: (await readdir(products).catch(() => [])).some((name) => name.startsWith("WebDriverAgentRunner_iphonesimulator") && name.endsWith(".xctestrun")),
1907
+ products
1908
+ };
1909
+ };
1424
1910
  const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpawnRunner) => {
1425
1911
  server.registerTool("ios_simulator_restart_wda", {
1426
1912
  title: "iOS Simulator: Restart WebDriverAgent",
@@ -1437,12 +1923,16 @@ const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpa
1437
1923
  }, async ({ device, port }) => wrap(async () => {
1438
1924
  const target = client.requireBooted(await client.resolveTarget(device));
1439
1925
  const usePort = port ?? client.wdaPort;
1926
+ const { built, products } = await runnerBuilt();
1927
+ 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." });
1440
1928
  const logPath = join(outputDir, "wda.log");
1441
1929
  const pid = await spawnRunner("/bin/bash", [scriptPath(), "run"], {
1442
1930
  env: {
1443
1931
  IOS_SIMULATOR_ID: target.id,
1444
1932
  IOS_SIMULATOR_WDA_PORT: String(usePort),
1445
- USE_PORT: String(usePort)
1933
+ USE_PORT: String(usePort),
1934
+ NODE: process.execPath,
1935
+ PATH: `${dirname(process.execPath)}:${process.env["PATH"] ?? ""}`
1446
1936
  },
1447
1937
  logPath
1448
1938
  });
@@ -1457,8 +1947,7 @@ const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpa
1457
1947
  };
1458
1948
  }));
1459
1949
  };
1460
- //#endregion
1461
- //#region src/tools/index.ts
1950
+ // src/tools/index.ts
1462
1951
  /**
1463
1952
  * All capability decisions in one place, so "why can't I call that" is answered
1464
1953
  * by one file.
@@ -1473,6 +1962,12 @@ const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpa
1473
1962
  * refused, because a refusal still lets a model try, retry and reason about a
1474
1963
  * way around it.
1475
1964
  *
1965
+ * Reading logs splits across the line. Crash reports are files this Mac
1966
+ * already wrote, so listing and reading them is observing and survives the
1967
+ * gate. `ios_simulator_read_logs` is read-only too, but it only ever reads what
1968
+ * `ios_simulator_launch` started capturing, so it goes where launch goes — a
1969
+ * tool that can only answer "no capture" is noise in the list.
1970
+ *
1476
1971
  * The observe half is unusually capable here: `screenshot` goes through simctl,
1477
1972
  * so seeing the screen needs no WebDriverAgent and no setup of any kind. Only
1478
1973
  * `ui_tree` does, and that is a runtime fact reported by
@@ -1494,21 +1989,23 @@ const registerTools = (server, client, ctx, spawnRunner) => {
1494
1989
  registerListTool(server, client);
1495
1990
  registerListAppsTool(server, client);
1496
1991
  registerScreenTools(server, shared);
1992
+ registerCrashLogTools(server, client, ctx);
1497
1993
  if (!ctx.allowWrites) return;
1498
1994
  registerInputTools(server, shared);
1499
1995
  registerLifecycleTools(server, client);
1500
1996
  registerAppTools(server, client, ctx.config.launchArgs, ctx.config.outputDir);
1997
+ registerLogReadTools(server, client);
1501
1998
  registerEnvironmentTools(server, client);
1502
1999
  registerRunnerTools(server, client, ctx.config.outputDir, spawnRunner);
1503
2000
  };
1504
- //#endregion
1505
- //#region src/server.ts
2001
+ // src/server.ts
1506
2002
  const SERVER_NAME = BUILD_INFO.name;
1507
2003
  const SERVER_VERSION = BUILD_INFO.version;
1508
2004
  /**
1509
- * A pure factory. The injectable seams — `exec`, `fetch` and `spawnRunner` —
1510
- * are the whole reason the test suite can drive real tools through the real SDK
1511
- * with no simulator, no Xcode, no WebDriverAgent and no process left running.
2005
+ * A pure factory. The injectable seams — `exec`, `fetch`, `spawnRunner` and
2006
+ * `processAlive`, plus `crashReportsDir` in the config — are the whole reason
2007
+ * the test suite can drive real tools through the real SDK with no simulator,
2008
+ * no Xcode, no WebDriverAgent and no process left running.
1512
2009
  * Nothing below `config.ts` reads `process.env`.
1513
2010
  */
1514
2011
  const createServer = (opts) => {
@@ -1530,6 +2027,7 @@ const createServer = (opts) => {
1530
2027
  ...config.simulatorId ? { defaultSimulatorId: config.simulatorId } : {},
1531
2028
  ...opts.exec ? { exec: opts.exec } : {},
1532
2029
  ...opts.fetch ? { fetch: opts.fetch } : {},
2030
+ ...opts.processAlive ? { processAlive: opts.processAlive } : {},
1533
2031
  ...opts.logger ? { logger: opts.logger } : {}
1534
2032
  });
1535
2033
  registerTools(server, client, {
@@ -1541,8 +2039,7 @@ const createServer = (opts) => {
1541
2039
  client
1542
2040
  };
1543
2041
  };
1544
- //#endregion
1545
- //#region src/config.ts
2042
+ // src/config.ts
1546
2043
  /**
1547
2044
  * There are no credentials here, and unlike the device server there is barely
1548
2045
  * any setup either. Two lanes reach a simulator and both are local:
@@ -1575,14 +2072,24 @@ const ConfigSchema = z.object({
1575
2072
  * **On by default**, which is the one place this server deliberately breaks
1576
2073
  * the fleet convention. What makes the device server read-only is that a
1577
2074
  * phone belongs to a real person; a simulator is disposable and holds
1578
- * nobody's data. `ios_simulator_erase` still requires an explicit `confirm`
1579
- * because it is the only irreversible tool here.
2075
+ * nobody's data. `ios_simulator_erase` and `ios_simulator_uninstall` still
2076
+ * require an explicit `confirm`, because they are the irreversible ones.
1580
2077
  */
1581
2078
  allowWrites: z.boolean().default(true),
1582
2079
  /** Applied by `launch` when the call passes no `arguments` of its own. */
1583
2080
  launchArgs: z.array(z.string()).default([]),
1584
- /** Saved screenshots, launch logs and the runner log. */
2081
+ /**
2082
+ * Saved screenshots, launch logs and the runner log. A launch log is opened
2083
+ * by the simulator rather than by this server, and a simulator has its own
2084
+ * `/tmp` and `/var/folders` — under this default it lands in the
2085
+ * simulator's data directory, and `launch` reports where it really is.
2086
+ */
1585
2087
  outputDir: z.string().min(1).default(join(tmpdir(), "mcp-ios-simulator")),
2088
+ /**
2089
+ * Where this Mac's ReportCrash writes `.ips` files, simulator apps' included.
2090
+ * Configurable for tests and for a report folder copied off another Mac.
2091
+ */
2092
+ crashReportsDir: z.string().min(1).default(join(homedir(), "Library", "Logs", "DiagnosticReports")),
1586
2093
  maxTreeBytes: z.number().int().min(1e3).max(5e5).default(24e3),
1587
2094
  xcrunPath: z.string().min(1).default("/usr/bin/xcrun"),
1588
2095
  sipsPath: z.string().min(1).default("/usr/bin/sips"),
@@ -1650,6 +2157,7 @@ const loadConfig = (env = process.env, configPath = defaultConfigPath(env)) => {
1650
2157
  allowWrites: pick(parseBool(env.IOS_SIMULATOR_ALLOW_WRITES), "allowWrites"),
1651
2158
  launchArgs: pick(parseArgs(env.IOS_SIMULATOR_LAUNCH_ARGS), "launchArgs"),
1652
2159
  outputDir: pick(trimmed(env.IOS_SIMULATOR_OUTPUT_DIR), "outputDir"),
2160
+ crashReportsDir: pick(trimmed(env.IOS_SIMULATOR_CRASH_REPORTS_DIR), "crashReportsDir"),
1653
2161
  maxTreeBytes: pick(parseIntOpt(env.IOS_SIMULATOR_MAX_TREE_BYTES), "maxTreeBytes"),
1654
2162
  xcrunPath: pick(trimmed(env.IOS_SIMULATOR_XCRUN_PATH), "xcrunPath"),
1655
2163
  sipsPath: pick(trimmed(env.IOS_SIMULATOR_SIPS_PATH), "sipsPath"),
@@ -1657,7 +2165,6 @@ const loadConfig = (env = process.env, configPath = defaultConfigPath(env)) => {
1657
2165
  openPath: pick(trimmed(env.IOS_SIMULATOR_OPEN_PATH), "openPath")
1658
2166
  });
1659
2167
  };
1660
- //#endregion
1661
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 };
1662
2169
 
1663
- //# sourceMappingURL=config-qc0zwYZr.js.map
2170
+ //# sourceMappingURL=config-HmqayUrt.js.map