@mgcrea/mcp-apple-maps 1.15.0 → 1.17.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,4 +1,4 @@
1
- import { AppleAutomationError, AppleAutomationError as AppleMapsError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, SchemaDriftError, SchemaDriftError as SchemaDriftError$1, columnsOf, compact, describeStore, detectEpoch, escapeLike, fail, fingerprintSchema, inspectFile, limitArg, ok, openReadOnly, parseBool, parseConfig, parseIntOpt, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, resolveLimit, trimmed, withLazyTools, wrap, wrapResult } from "@mgcrea/mcp-apple-core";
1
+ import { AppleAutomationError, AppleAutomationError as AppleMapsError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, SchemaDriftError, SchemaDriftError as SchemaDriftError$1, columnsOf, compact, describeStore, detectEpoch, escapeLike, fail, fingerprintSchema, inspectFile, interferenceNote, limitArg, ok, openAxChannel, openReadOnly, parseBool, parseConfig, parseIntOpt, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, resolveLimit, trimmed, watchInterference, withLazyTools, wrap, wrapResult } from "@mgcrea/mcp-apple-core";
2
2
  import { readdirSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
@@ -15,8 +15,8 @@ const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
15
15
  const BUILD_INFO = {
16
16
  name: pkg.name,
17
17
  version: pkg.version,
18
- gitCommit: "dfc8616",
19
- gitCommitDate: "2026-09-06T00:04:53+02:00"
18
+ gitCommit: "d05a45c",
19
+ gitCommitDate: "2026-09-07T16:21:19+02:00"
20
20
  };
21
21
  //#endregion
22
22
  //#region src/client/dates.ts
@@ -238,6 +238,296 @@ const locateStore = (opts = {}) => {
238
238
  };
239
239
  };
240
240
  //#endregion
241
+ //#region src/client/ax.ts
242
+ /**
243
+ * Maps' place card, driven through Cupertino's native Accessibility driver.
244
+ *
245
+ * ## Why this exists beside the SQL lane rather than replacing it
246
+ *
247
+ * `client/write.ts` writes favourites by copying a record Maps minted and
248
+ * INSERTing it. That lane is correct and stays: pressing the card's `AddButton`
249
+ * does not create a favourite. `docs/desktop.md` measured it — the press lands
250
+ * an **unfiled saved place** and `ZFAVORITEITEM` was 26 rows before and 26
251
+ * after. The two lanes address different objects.
252
+ *
253
+ * What this reaches is the set the SQL lane does not offer at all:
254
+ *
255
+ * * **The Places library** — `add_to_places` / `delete_from_places`.
256
+ * * **Guide membership** — `docs/maps.md` lists it as unbuilt: "Adding a place
257
+ * to a collection needs one `Z_6PLACES` join row".
258
+ *
259
+ * ## The safety argument, which is the real reason to prefer it here
260
+ *
261
+ * The store is mirrored by `NSPersistentCloudKitContainer` and mirroring does
262
+ * not wait to be told, so a malformed row is not a local mistake — it reaches
263
+ * every device on the account. `write.ts` carries the rule that keeps SQL safe:
264
+ * never fabricate a place record, only copy one Maps wrote. **A write made
265
+ * through the interface cannot be malformed at all**, because Maps performs it.
266
+ * Where both lanes could do a job, this one has the smaller blast radius.
267
+ *
268
+ * ## The cost, which is the same one the SQL lane pays
269
+ *
270
+ * Reaching the card means opening `maps://?q=<name>&ll=<lat>,<lon>`, so the
271
+ * place is left in **Recents** whether or not anything is kept. That is the
272
+ * mechanism rather than an oversight, and every tool built on this says so.
273
+ */
274
+ const MAPS_BUNDLE = "com.apple.Maps";
275
+ /** Identifiers Maps sets on the card. Unlocalised, unlike every name here. */
276
+ const CARD = {
277
+ add: "AddButton",
278
+ more: "MoreButton",
279
+ card: "PlaceCardViewController",
280
+ addToGuides: "add_to_guides",
281
+ deleteFromPlaces: "delete_from_places",
282
+ addToPlaces: "add_to_places",
283
+ picker: "GuidesPickerView",
284
+ guideRow: "UserGuidesPickerRowCell",
285
+ done: "CardButtonTypeDone",
286
+ close: "CardButtonTypeClose"
287
+ };
288
+ const defaultOpenUrl$1 = (url) => {
289
+ execFileSync("/usr/bin/open", ["-g", url], {
290
+ timeout: 1e4,
291
+ stdio: "ignore"
292
+ });
293
+ };
294
+ /**
295
+ * `"Favorites, 3 places"` -> `{ name: "Favorites", places: 3 }`.
296
+ *
297
+ * The count is a bonus and the name is the point, so a row whose shape is not
298
+ * recognised keeps its whole label as the name rather than being dropped. A
299
+ * guide that cannot be matched is worse than one with an odd name.
300
+ */
301
+ const parseGuideRow = (label) => {
302
+ const match = /^(.*),\s*(\d+)\s+places?$/.exec(label);
303
+ if (!match?.[1]) return {
304
+ name: label,
305
+ places: null
306
+ };
307
+ return {
308
+ name: match[1],
309
+ places: Number(match[2])
310
+ };
311
+ };
312
+ const DEFAULT_WAITS = {
313
+ card: 1e4,
314
+ sheet: 4e3
315
+ };
316
+ var MapsAxLane = class MapsAxLane {
317
+ #channel;
318
+ #openUrl;
319
+ #waits;
320
+ constructor(channel, openUrl, waits) {
321
+ this.#channel = channel;
322
+ this.#openUrl = openUrl;
323
+ this.#waits = waits;
324
+ }
325
+ /** Null when this server is not hosted by Cupertino — see `core/src/ax.ts`. */
326
+ static open(env = process.env, openUrl = defaultOpenUrl$1, waits = {}) {
327
+ const channel = openAxChannel(MAPS_SURFACE, env);
328
+ return channel ? new MapsAxLane(channel, openUrl, {
329
+ ...DEFAULT_WAITS,
330
+ ...waits
331
+ }) : null;
332
+ }
333
+ close() {
334
+ this.#channel.close();
335
+ }
336
+ /** Start watching for someone using the Mac during a sequence. */
337
+ watch() {
338
+ return watchInterference(this.#channel);
339
+ }
340
+ #call(tool, args = {}) {
341
+ return this.#channel.call({
342
+ tool: `apple_desktop_${tool}`,
343
+ args
344
+ });
345
+ }
346
+ async #tree() {
347
+ return (await this.#call("ui_tree", {
348
+ bundleId: MAPS_BUNDLE,
349
+ detail: "all",
350
+ maxDepth: 20,
351
+ maxNodes: 4e3,
352
+ budgetSeconds: 20
353
+ })).elements ?? [];
354
+ }
355
+ /**
356
+ * Wait for an element rather than for a duration.
357
+ *
358
+ * `docs/desktop.md` calls this the trap that cost it the most: a card's chrome
359
+ * appears before its content, so no fixed settle time is correct. Cheap here —
360
+ * a whole Maps walk is ~0.17 s.
361
+ */
362
+ async #poll(match, timeoutMs = this.#waits.card) {
363
+ const deadline = Date.now() + timeoutMs;
364
+ let lastError = null;
365
+ for (;;) {
366
+ try {
367
+ const found = (await this.#tree()).find(match);
368
+ if (found) return found;
369
+ lastError = null;
370
+ } catch (error) {
371
+ lastError = error;
372
+ }
373
+ if (Date.now() >= deadline) {
374
+ if (lastError) throw lastError;
375
+ return null;
376
+ }
377
+ await new Promise((resolve) => setTimeout(resolve, 300));
378
+ }
379
+ }
380
+ /**
381
+ * Open a place and wait for its card.
382
+ *
383
+ * The combined `q=` and `ll=` form, because a coordinate positions the map and
384
+ * a NAME selects a place — `write.ts` records the same thing for the same
385
+ * reason.
386
+ */
387
+ async openCard(place) {
388
+ this.#openUrl(`maps://?q=${encodeURIComponent(place.name)}&ll=${place.latitude},${place.longitude}`);
389
+ return await this.#poll((e) => e.id === CARD.add) !== null;
390
+ }
391
+ /**
392
+ * Is this place saved?
393
+ *
394
+ * Read off `AddButton`'s NAME, which is the state bit. `docs/desktop.md`
395
+ * corrected itself on this: `FavoriteButton` reports identical attributes
396
+ * either way, and the sibling carries `"Add"` versus `"Added"`.
397
+ *
398
+ * Localised, and that is a real limit rather than an oversight — there is no
399
+ * unlocalised state anywhere on this card.
400
+ */
401
+ async isSaved() {
402
+ const add = await this.#poll((e) => e.id === CARD.add, this.#waits.sheet);
403
+ if (!add?.name) return null;
404
+ return add.name !== "Add";
405
+ }
406
+ /**
407
+ * Open the card's overflow menu and return its items.
408
+ *
409
+ * POLLED, not settled. The first version asked for the items in the same
410
+ * breath as the press and got an empty list every time — the menu takes on the
411
+ * order of half a second to appear — and then reported "the overflow menu did
412
+ * not open", which is a sentence about Maps rather than about the race. That
413
+ * is this repo's own rule broken in the file that quotes it: **poll for the
414
+ * control, never wait a fixed time for it.**
415
+ */
416
+ async #openMenu() {
417
+ const more = await this.#poll((e) => e.id === CARD.more);
418
+ if (!more) return [];
419
+ await this.#call("press", { handle: more.handle });
420
+ const deadline = Date.now() + this.#waits.sheet;
421
+ for (;;) {
422
+ const items = await this.#call("find_elements", {
423
+ bundleId: MAPS_BUNDLE,
424
+ role: "AXMenuItem"
425
+ });
426
+ if (items.elements?.length) return items.elements;
427
+ if (Date.now() >= deadline) return [];
428
+ await new Promise((resolve) => setTimeout(resolve, 250));
429
+ }
430
+ }
431
+ async #dismiss() {
432
+ await this.#call("key", {
433
+ key: "escape",
434
+ modifiers: []
435
+ });
436
+ }
437
+ /**
438
+ * Save the open place into the Places library.
439
+ *
440
+ * Two presses, not one: `AddButton` raises a **"Name This Location"** sheet
441
+ * and nothing is written until its `Save` is pressed. The sheet REPLACES the
442
+ * window list — a full walk during it returns the sheet and nothing else — so
443
+ * the card's elements are unreachable until it closes, and `Save` carries no
444
+ * `AXIdentifier`, which is why it is addressed by name.
445
+ */
446
+ async savePlace() {
447
+ const add = await this.#poll((e) => e.id === CARD.add);
448
+ if (!add) return false;
449
+ await this.#call("press", { handle: add.handle });
450
+ const save = await this.#poll((e) => e.role === "AXButton" && e.name === "Save", this.#waits.sheet);
451
+ if (!save) return false;
452
+ await this.#call("press", { handle: save.handle });
453
+ return true;
454
+ }
455
+ /**
456
+ * Remove the open place from the Places library.
457
+ *
458
+ * The menu item is `delete_from_places`, and it is only present when the place
459
+ * IS saved — the same slot carries `add_to_places` when it is not. So its
460
+ * absence is a state reading rather than a failure, and this reports which.
461
+ */
462
+ async removePlace() {
463
+ const items = await this.#openMenu();
464
+ if (!items.length) return "no-menu";
465
+ const remove = items.find((e) => e.id === CARD.deleteFromPlaces);
466
+ if (!remove) {
467
+ await this.#dismiss();
468
+ return items.some((e) => e.id === CARD.addToPlaces) ? "not-saved" : "no-menu";
469
+ }
470
+ await this.#call("press", { handle: remove.handle });
471
+ return "removed";
472
+ }
473
+ /**
474
+ * Every guide Maps knows about, read off its own picker.
475
+ *
476
+ * This is the authoritative list, and it is not the same as the store's:
477
+ * `docs/desktop.md` found the picker showing a "Favorites" guide that
478
+ * `apple_maps_list_collections` does not return. Read here as a side effect of
479
+ * being on the way to a write, rather than as a tool of its own — reaching it
480
+ * needs a place card, which needs a `maps://` open, which leaves a Recents
481
+ * entry. That is too much side effect for something calling itself a read.
482
+ *
483
+ * Leaves the picker OPEN, because the caller is normally about to press a row.
484
+ */
485
+ async openGuidePicker() {
486
+ const items = await this.#openMenu();
487
+ const guides = items.find((e) => e.id === CARD.addToGuides);
488
+ if (!guides) {
489
+ if (items.length) await this.#dismiss();
490
+ return null;
491
+ }
492
+ await this.#call("press", { handle: guides.handle });
493
+ if (!await this.#poll((e) => e.id === CARD.picker, this.#waits.sheet)) return null;
494
+ return (await this.#tree()).filter((e) => e.id === CARD.guideRow && e.name).map((e) => parseGuideRow(e.name));
495
+ }
496
+ /** Press one guide row, then Done. Returns false if no row matched. */
497
+ async chooseGuide(name) {
498
+ const wanted = name.toLowerCase();
499
+ const row = (await this.#tree()).find((e) => e.id === CARD.guideRow && parseGuideRow(e.name ?? "").name.toLowerCase() === wanted);
500
+ if (!row) return false;
501
+ await this.#call("press", { handle: row.handle });
502
+ const done = await this.#poll((e) => e.id === CARD.done, this.#waits.sheet);
503
+ if (!done) return false;
504
+ await this.#call("press", { handle: done.handle });
505
+ return true;
506
+ }
507
+ /**
508
+ * Re-open the card and read the state bit, which is the only way to verify.
509
+ *
510
+ * **A write closes the card.** Saving through the naming sheet dismisses the
511
+ * whole place card and returns Maps to its main view, so reading `AddButton`
512
+ * straight after a press finds nothing and reports "the card does not say it
513
+ * is saved" — which is a sentence about the write having failed, when what
514
+ * actually happened is that the thing being read went away.
515
+ *
516
+ * So verification re-opens the place first. That costs another `maps://`,
517
+ * which is free in the sense that matters: the Recents entry this leaves was
518
+ * already left by opening the card in the first place.
519
+ */
520
+ async verifySaved(place) {
521
+ if (!await this.openCard(place)) return null;
522
+ return this.isSaved();
523
+ }
524
+ /** Abandon the picker without filing anything. */
525
+ async cancelGuidePicker() {
526
+ const close = await this.#poll((e) => e.id === CARD.close, this.#waits.sheet);
527
+ if (close) await this.#call("press", { handle: close.handle });
528
+ }
529
+ };
530
+ //#endregion
241
531
  //#region src/client/ref.ts
242
532
  /**
243
533
  * Refs for places and collections.
@@ -1038,6 +1328,10 @@ const openStore = (opts) => {
1038
1328
  * Hence: never fabricate a place record, only ever copy one Maps wrote.
1039
1329
  */
1040
1330
  const STORAGE_POLL_MS = 250;
1331
+ /** A pause that yields the event loop, unlike `Atomics.wait`, which does not. */
1332
+ const sleep = (ms) => new Promise((resolve) => {
1333
+ setTimeout(resolve, ms);
1334
+ });
1041
1335
  /**
1042
1336
  * Progress, on stderr.
1043
1337
  *
@@ -1221,7 +1515,17 @@ var MapsWriter = class {
1221
1515
  * poll with a short-lived read-only handle each time.
1222
1516
  * 3. WRITE — open read-write, insert, close.
1223
1517
  */
1224
- addFavorite(input) {
1518
+ /**
1519
+ * Async because the seed can take THIRTY SECONDS.
1520
+ *
1521
+ * When the place is not already in the store this asks Maps to mint it
1522
+ * through the URL scheme and then polls for the row to appear. That poll used
1523
+ * `Atomics.wait`, which blocks the thread — and this server is single
1524
+ * threaded, so for the whole seed it answered nothing at all: not a ping, not
1525
+ * a cancellation, not another tool call. A sleep that yields costs the same
1526
+ * wall clock and leaves the server able to speak.
1527
+ */
1528
+ async addFavorite(input) {
1225
1529
  const hasCoords = input.latitude !== void 0 && input.longitude !== void 0;
1226
1530
  const lat = input.latitude ?? 0;
1227
1531
  const lon = input.longitude ?? 0;
@@ -1264,7 +1568,7 @@ var MapsWriter = class {
1264
1568
  } finally {
1265
1569
  probe.close();
1266
1570
  }
1267
- if (!donor) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, STORAGE_POLL_MS);
1571
+ if (!donor) await sleep(STORAGE_POLL_MS);
1268
1572
  polls += 1;
1269
1573
  if (polls % 8 === 0) progress(`still waiting (${polls * (STORAGE_POLL_MS / 1e3)}s)…`);
1270
1574
  }
@@ -1294,22 +1598,23 @@ var MapsWriter = class {
1294
1598
  progress("writing the favourite");
1295
1599
  const db = this.#open();
1296
1600
  try {
1297
- const favEnt = this.#entity(db, "FavoriteItem");
1298
- const mixEnt = this.#entity(db, "MixinMapItem");
1299
- const favPk = this.#nextPk(db, "FavoriteItem", "ZFAVORITEITEM");
1300
- const mixPk = this.#nextPk(db, "MixinMapItem", "ZMIXINMAPITEM");
1301
1601
  const now = Date.now() / 1e3 - CORE_DATA_EPOCH_OFFSET;
1302
1602
  const id = uuidBytes();
1303
1603
  const label = input.name ?? donor.name ?? input.query;
1304
- const position = Number(db.prepare(`SELECT COUNT(*) AS c FROM ZFAVORITEITEM`).get().c);
1604
+ let favPk = 0;
1305
1605
  db.exec("BEGIN IMMEDIATE");
1306
1606
  try {
1607
+ const favEnt = this.#entity(db, "FavoriteItem");
1608
+ const mixEnt = this.#entity(db, "MixinMapItem");
1609
+ favPk = this.#nextPk(db, "FavoriteItem", "ZFAVORITEITEM");
1610
+ const mixPk = this.#nextPk(db, "MixinMapItem", "ZMIXINMAPITEM");
1611
+ const position = Number(db.prepare(`SELECT COUNT(*) AS c FROM ZFAVORITEITEM`).get().c);
1307
1612
  db.prepare(`INSERT INTO "ZFAVORITEITEM"
1308
1613
  (Z_PK, Z_ENT, Z_OPT, ZHIDDEN, ZPOSITIONINDEX, ZSOURCE, ZTYPE, ZVERSION,
1309
1614
  ZMAPITEM, ZMUID, ZCREATETIME, ZMODIFICATIONTIME, ZMAPITEMLASTREFRESHED,
1310
1615
  ZLATITUDE, ZLONGITUDE, ZMAPITEMNAME, ZMAPITEMADDRESS, ZMAPITEMCATEGORY,
1311
1616
  ZIDENTIFIER)
1312
- VALUES (?, ?, 1, 0, ?, 0, 1, 2, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(favPk, favEnt, position, mixPk, donor.muid === null ? null : Number(donor.muid), now, now, now, donor.lat, donor.lon, label, donor.address, donor.category, id);
1617
+ VALUES (?, ?, 1, 0, ?, 0, 1, 2, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(favPk, favEnt, position, mixPk, donor.muid === null ? null : BigInt(donor.muid), now, now, now, donor.lat, donor.lon, label, donor.address, donor.category, id);
1313
1618
  db.prepare(`INSERT INTO "ZMIXINMAPITEM"
1314
1619
  (Z_PK, Z_ENT, Z_OPT, ZFAVORITEITEM, ZCREATETIME, ZMODIFICATIONTIME,
1315
1620
  ZLATITUDE, ZLONGITUDE, ZMAPITEMSTORAGE)
@@ -1381,6 +1686,14 @@ const summariseEntity = (e) => ({
1381
1686
  });
1382
1687
  var AppleMapsClient = class {
1383
1688
  #config;
1689
+ /**
1690
+ * The interface lane, when this server is hosted by Cupertino.
1691
+ *
1692
+ * Null when it is not, which is the supported case rather than a fault: the
1693
+ * npm package has to work with no app on the machine. `tools/index.ts` skips
1694
+ * registering the tools that need it.
1695
+ */
1696
+ #ax;
1384
1697
  #logger;
1385
1698
  #home;
1386
1699
  #located = null;
@@ -1388,12 +1701,16 @@ var AppleMapsClient = class {
1388
1701
  #storeError = null;
1389
1702
  constructor(opts) {
1390
1703
  this.#config = opts.config;
1704
+ this.#ax = opts.ax !== void 0 ? opts.ax : MapsAxLane.open();
1391
1705
  this.#logger = opts.logger;
1392
1706
  this.#home = opts.home;
1393
1707
  }
1394
1708
  get config() {
1395
1709
  return this.#config;
1396
1710
  }
1711
+ get axLane() {
1712
+ return this.#ax;
1713
+ }
1397
1714
  located() {
1398
1715
  this.#located ??= locateStore({
1399
1716
  storePath: this.#config.storePath,
@@ -1748,7 +2065,13 @@ const buildDiagnostics = async (client) => {
1748
2065
  working: status.store.opened
1749
2066
  },
1750
2067
  appleEvents: "none — Maps is not scriptable",
1751
- writes: "none — this server registers no mutating tool. The store is mirrored to iCloud by NSPersistentCloudKitContainer, so a write is an edit to one replica of a synchronising graph underneath a running app. That was never probed."
2068
+ writes: {
2069
+ enabled: client.config.allowWrites,
2070
+ lane: "SQL, directly into the Core Data store. Maps ships no scripting dictionary and registers no App Intents on macOS, so there is no lane where the app performs the write on our behalf — this is the only surface here that writes its own store.",
2071
+ seeding: "A place is only real to Maps if it carries a ZMAPITEMSTORAGE blob, which this repo cannot generate. Opening maps://?q=<name>&ll=<lat>,<lon> through LaunchServices makes Maps mint one, and that record is copied. The place is therefore left in Recents whether or not the favourite is kept, and resolving it can take tens of seconds.",
2072
+ blastRadius: "The store is mirrored by NSPersistentCloudKitContainer, and mirroring does not wait to be told. A malformed row is not a local mistake: it reaches every device on the account as soon as Maps next runs. Hence the rule in client/write.ts — never fabricate a place record, only ever copy one Maps wrote.",
2073
+ tools: client.config.allowWrites ? ["apple_maps_add_favorite", "apple_maps_remove_favorite"] : []
2074
+ }
1752
2075
  },
1753
2076
  store: {
1754
2077
  path: located.storePath,
@@ -1786,6 +2109,138 @@ const registerDiagnosticsTools = (server, client) => {
1786
2109
  }, async () => wrap(() => buildDiagnostics(client)));
1787
2110
  };
1788
2111
  //#endregion
2112
+ //#region src/tools/ax-writes.ts
2113
+ /**
2114
+ * The tools that go through Maps' own interface rather than its store.
2115
+ *
2116
+ * ## Why these are not in `writes.ts`
2117
+ *
2118
+ * Different lane, different objects, different failure modes. `writes.ts` writes
2119
+ * SQL into the Core Data store and reaches **favourites**; these press controls
2120
+ * on a place card and reach the **Places library** and **guide membership** —
2121
+ * two sets the SQL lane does not offer at all. `docs/maps.md` lists guide
2122
+ * membership as unbuilt for exactly that reason.
2123
+ *
2124
+ * ## Registered only when the app is hosting this server
2125
+ *
2126
+ * The Accessibility grant belongs to Cupertino.app, not to node, so a package
2127
+ * installed from npm and run by hand has no lane here and these tools are not
2128
+ * registered at all — the same rule the write gate follows, so a host is never
2129
+ * told about a tool that cannot work for it.
2130
+ *
2131
+ * ## What every description has to say
2132
+ *
2133
+ * **It opens Maps and leaves the place in Recents.** Reaching a card means
2134
+ * opening `maps://`, which is how the SQL lane seeds a record too. There is no
2135
+ * way to have the card without the Recents entry.
2136
+ *
2137
+ * **It moves the user's screen.** Unlike a SQL write, this brings a window
2138
+ * forward and presses things in it. That is worth stating plainly.
2139
+ */
2140
+ const noCard = (query, disturbed) => fail(`Maps did not show a place card for "${query}" within 10s. Nothing was changed. The coordinates may not resolve to a place Maps recognises, or Maps may not have finished loading it.` + disturbed);
2141
+ const registerAxWriteTools = (server, lane) => {
2142
+ const place = {
2143
+ query: z.string().min(1).describe("The place's name, as you would type it into Maps' search field."),
2144
+ latitude: z.number().min(-90).max(90).describe("Latitude of the place."),
2145
+ longitude: z.number().min(-180).max(180).describe("Longitude of the place.")
2146
+ };
2147
+ const openCard = async (args) => lane.openCard({
2148
+ name: args.query,
2149
+ latitude: args.latitude,
2150
+ longitude: args.longitude
2151
+ });
2152
+ server.registerTool("apple_maps_save_place", {
2153
+ description: "Save a place into Maps' Places library, by pressing Add on its card. This is NOT a favourite: the Pinned list is a different set, written by apple_maps_add_favorite. Maps performs the write itself, so it cannot produce a malformed record. SIDE EFFECTS: opens Maps, brings its window forward, and leaves the place in the user's Recents. Syncs to the user's other Apple devices. Needs Accessibility for Cupertino.",
2154
+ inputSchema: place,
2155
+ annotations: {
2156
+ readOnlyHint: false,
2157
+ destructiveHint: false,
2158
+ idempotentHint: true
2159
+ }
2160
+ }, async (args) => wrapResult(async () => {
2161
+ const watch = lane.watch();
2162
+ const disturbed = async () => interferenceNote(await watch.check());
2163
+ if (!await openCard(args)) return noCard(args.query, await disturbed());
2164
+ if (await lane.isSaved() === true) return ok({
2165
+ saved: true,
2166
+ alreadySaved: true,
2167
+ query: args.query
2168
+ });
2169
+ if (!await lane.savePlace()) return fail(`The card for "${args.query}" opened but the naming sheet did not, so nothing was saved. The card may still be on screen.` + await disturbed());
2170
+ return await lane.verifySaved({
2171
+ name: args.query,
2172
+ latitude: args.latitude,
2173
+ longitude: args.longitude
2174
+ }) === true ? ok({
2175
+ saved: true,
2176
+ alreadySaved: false,
2177
+ query: args.query
2178
+ }) : fail(`"${args.query}" was pressed through the save sheet but its card does not report it as saved, so this MUST NOT be reported as done. Check Maps.` + await disturbed());
2179
+ }));
2180
+ server.registerTool("apple_maps_remove_saved_place", {
2181
+ description: "Remove a place from Maps' Places library, through the card's overflow menu. This does NOT touch favourites — use apple_maps_remove_favorite for those. SIDE EFFECTS: opens Maps, brings its window forward, and leaves the place in the user's Recents. Syncs to the user's other Apple devices. Needs Accessibility for Cupertino.",
2182
+ inputSchema: place,
2183
+ annotations: {
2184
+ readOnlyHint: false,
2185
+ destructiveHint: true,
2186
+ idempotentHint: true
2187
+ }
2188
+ }, async (args) => wrapResult(async () => {
2189
+ const watch = lane.watch();
2190
+ const disturbed = async () => interferenceNote(await watch.check());
2191
+ if (!await openCard(args)) return noCard(args.query, await disturbed());
2192
+ const outcome = await lane.removePlace();
2193
+ if (outcome === "not-saved") return ok({
2194
+ removed: false,
2195
+ wasSaved: false,
2196
+ query: args.query
2197
+ });
2198
+ if (outcome === "no-menu") return fail(`The card for "${args.query}" opened but its overflow menu did not, so nothing was removed.` + await disturbed());
2199
+ return await lane.verifySaved({
2200
+ name: args.query,
2201
+ latitude: args.latitude,
2202
+ longitude: args.longitude
2203
+ }) === false ? ok({
2204
+ removed: true,
2205
+ wasSaved: true,
2206
+ query: args.query
2207
+ }) : fail(`"${args.query}" was pressed through Delete from Places but its card still reports it as saved, so this MUST NOT be reported as done. Check Maps.` + await disturbed());
2208
+ }));
2209
+ server.registerTool("apple_maps_add_place_to_guide", {
2210
+ description: "File a place into one of Maps' Guides, through the card's Add to Guides picker. This is the write apple_maps_list_collections has no counterpart for. IT CANNOT CONFIRM ITSELF: Maps does not expose whether the place ended up in the guide, so the result says filed: \"unverified\" and it must not be reported as done. The guide must already exist; when the name does not match, the refusal lists every guide Maps offers, which is the authoritative list and can differ from what apple_maps_list_collections returns. SIDE EFFECTS: opens Maps, brings its window forward, and leaves the place in the user's Recents. Syncs to the user's other Apple devices. Needs Accessibility for Cupertino.",
2211
+ inputSchema: {
2212
+ ...place,
2213
+ guide: z.string().min(1).describe("Name of the guide to file it into, as Maps shows it.")
2214
+ },
2215
+ annotations: {
2216
+ readOnlyHint: false,
2217
+ destructiveHint: false,
2218
+ idempotentHint: true
2219
+ }
2220
+ }, async (args) => wrapResult(async () => {
2221
+ const watch = lane.watch();
2222
+ const disturbed = async () => interferenceNote(await watch.check());
2223
+ if (!await openCard(args)) return noCard(args.query, await disturbed());
2224
+ const guides = await lane.openGuidePicker();
2225
+ if (guides === null) return fail(`The card for "${args.query}" opened but its Add to Guides picker did not, so nothing was filed.` + await disturbed());
2226
+ if (!await lane.chooseGuide(args.guide)) {
2227
+ await lane.cancelGuidePicker();
2228
+ return fail(`Maps offers no guide called "${args.guide}", so nothing was filed. It offers: ${guides.map((g) => g.name).join(", ")}.`, { guides: compactGuides(guides) });
2229
+ }
2230
+ return ok({
2231
+ filed: "unverified",
2232
+ query: args.query,
2233
+ guide: args.guide,
2234
+ guides: compactGuides(guides),
2235
+ note: "The guide row and then Done were pressed. Maps does not expose whether the place ended up in the guide — the picker's counts do not refresh in the accessibility tree — so this MUST NOT be reported to the user as filed. Check Maps."
2236
+ });
2237
+ }));
2238
+ };
2239
+ const compactGuides = (guides) => guides.map((g) => g.places === null ? { name: g.name } : {
2240
+ name: g.name,
2241
+ places: g.places
2242
+ });
2243
+ //#endregion
1789
2244
  //#region src/tools/places.ts
1790
2245
  /**
1791
2246
  * The place tools.
@@ -1863,10 +2318,11 @@ const registerPlaceTools = (server, client) => {
1863
2318
  }
1864
2319
  }, async ({ ref, limit }) => wrapResult(async () => {
1865
2320
  const collectionId = client.collectionRowId(decodeCollectionRef(ref));
2321
+ if (collectionId === null) return fail(`No collection matches ${ref}. Refs come from apple_maps_list_collections and go stale when a guide is deleted or renamed away.`);
1866
2322
  const capped = resolveLimit(limit, client.config.maxResults);
1867
2323
  const result = client.places("collection-item", {
1868
2324
  limit: capped,
1869
- collectionId: collectionId ?? void 0
2325
+ collectionId
1870
2326
  });
1871
2327
  if (!client.collections({ limit: 1e3 }).itemsEnumerable) return fail("This store does not expose which collection an item belongs to, so its places cannot be listed. The collection itself and its place count are still readable through apple_maps_list_collections.");
1872
2328
  return ok(compact({
@@ -1988,7 +2444,7 @@ const registerWriteTools = (server, client) => {
1988
2444
  idempotentHint: true
1989
2445
  }
1990
2446
  }, async ({ query, latitude, longitude, name }) => wrapResult(async () => {
1991
- const result = client.writer().addFavorite({
2447
+ const result = await client.writer().addFavorite({
1992
2448
  query,
1993
2449
  latitude,
1994
2450
  longitude,
@@ -2041,6 +2497,8 @@ const registerTools = (server, client, ctx) => {
2041
2497
  registerPlaceTools(server, client);
2042
2498
  if (!ctx.allowWrites) return;
2043
2499
  registerWriteTools(server, client);
2500
+ const ax = client.axLane;
2501
+ if (ax) registerAxWriteTools(server, ax);
2044
2502
  };
2045
2503
  //#endregion
2046
2504
  //#region src/server.ts
@@ -2089,4 +2547,4 @@ const createServer = (opts) => {
2089
2547
  //#endregion
2090
2548
  export { fromStoreTime as A, MAPS_SURFACE as C, UndatableStoreError as D, SchemaDriftError$1 as E, resolveEpoch as M, BUILD_INFO as N, APPLE_SECONDS as O, MAPS_BUNDLE_ID as S, PlaceNotFoundError as T, defaultDirectory as _, loadConfig as a, AppleMapsError as b, introspect as c, InvalidMapsRefError as d, PLACE_REF_VERSION as f, encodePlaceRef as g, encodeCollectionRef as h, registerTools as i, renderInstant as j, CORE_DATA_EPOCH_OFFSET as k, openStore as l, decodePlaceRef as m, SERVER_VERSION as n, AppleMapsClient as o, decodeCollectionRef as p, createServer as r, MapsStore as s, SERVER_NAME as t, COLLECTION_REF_VERSION as u, defaultStorePath as v, MapsStoreUnavailableError as w, IndexUnavailableError as x, locateStore as y };
2091
2549
 
2092
- //# sourceMappingURL=server-CpYiHM_a.js.map
2550
+ //# sourceMappingURL=server-C_leu2E0.js.map