@mgcrea/mcp-apple-contacts 0.0.0-bootstrap

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.
@@ -0,0 +1,1480 @@
1
+ import { AppleAutomationError, AppleAutomationError as AppleContactsError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, IndexUnavailableError as IndexUnavailableError$1, SchemaDriftError, SchemaDriftError as SchemaDriftError$1, columnsOf, createOsascriptRunner, describeStore, escapeLike, fail, fingerprintSchema, limitArg, ok, openReadOnly, parseBool, parseConfig, parseIntOpt, readPackageIdentity, trimmed, withBusyRetry, wrap } from "@mgcrea/mcp-apple-core";
2
+ import { readdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ //#region src/build-info.ts
8
+ const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
9
+ name: "@mgcrea/mcp-apple-contacts",
10
+ version: "0.0.0"
11
+ });
12
+ const BUILD_INFO = {
13
+ name: pkg.name,
14
+ version: pkg.version,
15
+ gitCommit: "df06e7f",
16
+ gitCommitDate: "2026-08-22T16:20:23+02:00"
17
+ };
18
+ //#endregion
19
+ //#region src/client/errors.ts
20
+ /**
21
+ * Contacts' error surface. The taxonomy lives in `@mgcrea/mcp-apple-core`; what
22
+ * belongs here is the identity those messages are written against.
23
+ */
24
+ const CONTACTS_SURFACE = {
25
+ appName: "Contacts",
26
+ envPrefix: "APPLE_CONTACTS"
27
+ };
28
+ /**
29
+ * Contacts' Apple Events target — and, like Calendar's `com.apple.iCal`, not the
30
+ * display name. Contacts.app kept the id it shipped with as Address Book;
31
+ * `com.apple.Contacts` does not exist.
32
+ *
33
+ * Used by the write lane only. Reads never send an Apple Event.
34
+ */
35
+ const CONTACTS_BUNDLE_ID = "com.apple.AddressBook";
36
+ /** A contact ref no longer resolves — deleted, or its account was removed. */
37
+ var ContactNotFoundError = class extends AppleAutomationError {
38
+ name = "ContactNotFoundError";
39
+ constructor(ref) {
40
+ super(`No contact for ref "${ref}". It was probably deleted, or the account holding it was removed, since the search ran. Re-run the search to get a current ref.`, { ref });
41
+ }
42
+ };
43
+ /**
44
+ * The store could not be read, with the reason spelled out.
45
+ *
46
+ * Its own error because Contacts fails differently from every other surface in
47
+ * this repo: it sits behind its own TCC service rather than behind Full Disk
48
+ * Access, and unlike Full Disk Access that permission PROMPTS. So the fix is
49
+ * usually "answer the dialog", not "go to System Settings" — and telling
50
+ * somebody to grant whole-disk access for an address book would be asking for
51
+ * far more than this server needs.
52
+ */
53
+ var ContactsUnavailableError = class extends AppleAutomationError {
54
+ name = "ContactsUnavailableError";
55
+ constructor(reason) {
56
+ super(reason, {});
57
+ }
58
+ };
59
+ /**
60
+ * A write did not survive the save.
61
+ *
62
+ * Its own error because Contacts fails this way and the other surfaces do not:
63
+ * changes sit in an unsaved buffer until `save()` runs, so a mutation can
64
+ * succeed, read back correctly inside the same script, and still never reach the
65
+ * store. Every write script saves and then re-reads; this is what it raises when
66
+ * the re-read comes back empty.
67
+ */
68
+ var ContactWriteNotPersistedError = class extends AppleAutomationError {
69
+ name = "ContactWriteNotPersistedError";
70
+ constructor(message) {
71
+ super(`${message} Contacts keeps edits in an unsaved buffer, so this usually means the save was refused — check whether Contacts has a modal sheet open, or an account that is read-only.`, {});
72
+ }
73
+ };
74
+ //#endregion
75
+ //#region src/client/jxa/core.ts
76
+ /**
77
+ * JXA script fragments for Contacts.
78
+ *
79
+ * Every script here is a static constant. None may contain a template
80
+ * interpolation — `assertStaticScript` rejects any script containing a dollar
81
+ * sign followed by a brace, including template literals written INSIDE the JXA
82
+ * source. Use string concatenation in JXA code.
83
+ *
84
+ * Every script follows the same contract:
85
+ * - it reads its parameters from `JSON.parse(argv[0])`
86
+ * - it returns `JSON.stringify({ok: true, data})` on success
87
+ * - it returns `JSON.stringify({ok: false, error: {code, message}})` on an
88
+ * application-level failure, still exiting 0
89
+ * so a non-zero exit always means infrastructure rather than "no such contact".
90
+ *
91
+ * ## There is no read.ts here, and that is still the design
92
+ *
93
+ * Adding writes did not add a read lane. Reads come off the file lane, which is
94
+ * the only place a suffix-keyed index over 970 phone numbers can be built at
95
+ * all — see `../store.ts`. These scripts exist only to make Contacts CHANGE
96
+ * something. `test/jxa.test.ts` asserts `read.ts` does not exist.
97
+ *
98
+ * ## What the dictionary actually offers
99
+ *
100
+ * MEASURED from `sdef /System/Applications/Contacts.app` on macOS 26.6. The
101
+ * whole command list is four verbs:
102
+ *
103
+ * make create a person, or a phone/email element under one
104
+ * add put a person in a group
105
+ * remove take a person out of a group
106
+ * save commit everything
107
+ *
108
+ * That is all of it. The Standard Suite here contains **only `make`** — the
109
+ * string "delete" does not appear anywhere in the dictionary. So there is no
110
+ * supported way to delete a contact over Apple Events, which is why this file
111
+ * has no DELETE script and why `delete_contacts` is not a tool. Whether Cocoa
112
+ * Scripting answers an undeclared `delete` event anyway is a separate question
113
+ * and an unmeasured one; guessing at it would risk destroying a real person's
114
+ * card on the strength of an assumption.
115
+ *
116
+ * ## `save` is explicit, global, and the whole reason this is not like Calendar
117
+ *
118
+ * Calendar and Reminders persist a property assignment immediately. Contacts
119
+ * does not: changes sit in an unsaved buffer until `Application("Contacts").save()`
120
+ * runs, and the dictionary says so — the application class carries an `unsaved`
121
+ * property, and `save` is documented as "Save ALL Contacts changes".
122
+ *
123
+ * Two consequences, both load-bearing:
124
+ *
125
+ * 1. **A write that forgets `save()` silently does nothing.** The object updates,
126
+ * every read-back inside the same script agrees, and the store never changes.
127
+ * Every script below saves before it verifies.
128
+ * 2. **`save()` is not scoped to our change.** It commits whatever else is
129
+ * pending, including an edit someone has half-typed in the Contacts window.
130
+ * That is a property of the dictionary, not a choice made here, and the tool
131
+ * descriptions say so.
132
+ */
133
+ /**
134
+ * Shared prelude.
135
+ *
136
+ * The bundle identifier is `com.apple.AddressBook`, which — like Calendar's
137
+ * `com.apple.iCal` — does not match the display name. Contacts.app kept the id
138
+ * it shipped with as Address Book. `Application("Contacts")` is the correct
139
+ * scripting name; `com.apple.Contacts` does not exist.
140
+ */
141
+ const PRELUDE = `
142
+ ObjC.import("AppKit");
143
+
144
+ function isContactsRunning() {
145
+ var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier("com.apple.AddressBook");
146
+ return apps.count > 0;
147
+ }
148
+
149
+ function ok(data) { return JSON.stringify({ ok: true, data: data }); }
150
+ function err(code, message) { return JSON.stringify({ ok: false, error: { code: code, message: String(message) } }); }
151
+
152
+ /** Read one property defensively: Contacts throws on properties it cannot supply. */
153
+ function prop(fn, fallback) {
154
+ try {
155
+ var v = fn();
156
+ return v === undefined ? fallback : v;
157
+ } catch (e) {
158
+ return fallback;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Find one person, without assuming the two lanes spell an id the same way.
164
+ *
165
+ * The file lane holds \`ZABCDRECORD.ZUNIQUEID\`; Apple Events returns whatever
166
+ * \`person.id()\` returns. That those are the same string is EXACTLY the kind of
167
+ * thing this project has been wrong about before — Calendar's \`calendar.uid()\`
168
+ * throws for every calendar, and the id bridge that held for its events did not
169
+ * extend to them. It is unmeasured here, so it is not assumed.
170
+ *
171
+ * Two attempts, cheapest first:
172
+ *
173
+ * 1. \`byId()\` with the value as given. Contacts offers a real by-id lookup,
174
+ * unlike Calendar, so when the forms do agree this costs one round trip.
175
+ * 2. A bulk \`people.id()\` fetch, matched on the UUID substring. This is the
176
+ * guard, and it is affordable precisely here: docs/contacts.md measured the
177
+ * whole id list at 63-73 ms over 421 people, against the 1.8 s that made the
178
+ * same trick unusable for Calendar's events.
179
+ *
180
+ * So a mismatch in id FORM degrades to a fast scan instead of a wrong "not
181
+ * found" — which is the failure this would otherwise produce, and the one that
182
+ * looks like the contact was deleted.
183
+ */
184
+ function uuidOf(value) {
185
+ var m = /[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/.exec(String(value || ""));
186
+ return m ? m[0].toUpperCase() : null;
187
+ }
188
+
189
+ function findPerson(C, personId) {
190
+ try {
191
+ var direct = C.people.byId(personId);
192
+ direct.id();
193
+ return direct;
194
+ } catch (e) {
195
+ // Fall through to the scan.
196
+ }
197
+
198
+ var wanted = uuidOf(personId);
199
+ if (!wanted) return null;
200
+
201
+ try {
202
+ var ids = C.people.id();
203
+ for (var i = 0; i < ids.length; i++) {
204
+ if (uuidOf(ids[i]) === wanted) {
205
+ var found = C.people.byId(ids[i]);
206
+ found.id();
207
+ return found;
208
+ }
209
+ }
210
+ } catch (e2) {
211
+ return null;
212
+ }
213
+ return null;
214
+ }
215
+
216
+ /** Everything a write returns, so a caller sees what Contacts stored. */
217
+ function shapePerson(p) {
218
+ return {
219
+ id: prop(function () { return String(p.id()); }, null),
220
+ name: prop(function () { return p.name(); }, null),
221
+ firstName: prop(function () { return p.firstName(); }, null),
222
+ lastName: prop(function () { return p.lastName(); }, null),
223
+ nickname: prop(function () { return p.nickname(); }, null),
224
+ organization: prop(function () { return p.organization(); }, null),
225
+ jobTitle: prop(function () { return p.jobTitle(); }, null),
226
+ department: prop(function () { return p.department(); }, null),
227
+ note: prop(function () { return p.note(); }, null),
228
+ company: prop(function () { return p.company(); }, false),
229
+ phones: prop(function () {
230
+ var out = [];
231
+ var xs = p.phones();
232
+ for (var i = 0; i < xs.length; i++) {
233
+ out.push({
234
+ label: prop(function () { return xs[i].label(); }, null),
235
+ value: prop(function () { return xs[i].value(); }, null)
236
+ });
237
+ }
238
+ return out;
239
+ }, []),
240
+ emails: prop(function () {
241
+ var out = [];
242
+ var xs = p.emails();
243
+ for (var i = 0; i < xs.length; i++) {
244
+ out.push({
245
+ label: prop(function () { return xs[i].label(); }, null),
246
+ value: prop(function () { return xs[i].value(); }, null)
247
+ });
248
+ }
249
+ return out;
250
+ }, [])
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Apply the scalar properties a caller supplied.
256
+ *
257
+ * Only keys actually present are touched: a missing key means "leave it alone",
258
+ * an explicit null means "clear it". Assigning undefined would blank a field the
259
+ * caller never mentioned.
260
+ */
261
+ function applyFields(p, f) {
262
+ if (f.firstName !== undefined) p.firstName = f.firstName;
263
+ if (f.lastName !== undefined) p.lastName = f.lastName;
264
+ if (f.nickname !== undefined) p.nickname = f.nickname;
265
+ if (f.organization !== undefined) p.organization = f.organization;
266
+ if (f.jobTitle !== undefined) p.jobTitle = f.jobTitle;
267
+ if (f.department !== undefined) p.department = f.department;
268
+ if (f.note !== undefined) p.note = f.note;
269
+ if (f.company !== undefined) p.company = f.company;
270
+ }
271
+
272
+ /**
273
+ * Add phone and email elements.
274
+ *
275
+ * These are ELEMENTS, not properties: a phone number is its own object made at
276
+ * the end of the person's phones. There is no way to set them as a bulk array,
277
+ * so each one is a separate \`make\`.
278
+ */
279
+ function addChildren(C, p, phones, emails) {
280
+ var i;
281
+ if (phones) {
282
+ for (i = 0; i < phones.length; i++) {
283
+ p.phones.push(C.Phone({ label: phones[i].label || "mobile", value: phones[i].value }));
284
+ }
285
+ }
286
+ if (emails) {
287
+ for (i = 0; i < emails.length; i++) {
288
+ p.emails.push(C.Email({ label: emails[i].label || "home", value: emails[i].value }));
289
+ }
290
+ }
291
+ }
292
+ `;
293
+ //#endregion
294
+ //#region src/client/jxa/write.ts
295
+ /**
296
+ * The write scripts. Two verbs, and the absence of a third is deliberate.
297
+ *
298
+ * `create` and `update` are both `make`-and-`save`. There is no `delete`,
299
+ * because the Contacts dictionary has no delete command — see `core.ts` for the
300
+ * measurement. That absence is enforced by `test/jxa.test.ts`, so removing a
301
+ * contact cannot be added here without the decision being taken again.
302
+ *
303
+ * Every script SAVES and then RE-READS, in that order. Contacts keeps changes in
304
+ * an unsaved buffer, so a script that skips the save mutates a live object,
305
+ * reports success from its own in-memory read, and leaves the store untouched.
306
+ * Verifying before saving would find exactly the same false success.
307
+ */
308
+ const CREATE_CONTACT = `${PRELUDE}
309
+ function run(argv) {
310
+ var p = JSON.parse(argv[0]);
311
+ var C = Application("Contacts");
312
+
313
+ if (!isContactsRunning() && !p.allowLaunch) {
314
+ return err("APP_NOT_RUNNING", "Contacts is not running.");
315
+ }
316
+
317
+ var person;
318
+ try {
319
+ person = C.Person({
320
+ firstName: p.fields.firstName || "",
321
+ lastName: p.fields.lastName || ""
322
+ });
323
+ C.people.push(person);
324
+ } catch (e) {
325
+ return err("CREATE_FAILED", e.message || e);
326
+ }
327
+
328
+ try {
329
+ applyFields(person, p.fields);
330
+ addChildren(C, person, p.phones, p.emails);
331
+ } catch (e) {
332
+ // The person exists but is incomplete. Save anyway so the caller is told
333
+ // about a real half-written card rather than a phantom one, and let the
334
+ // read-back below show exactly what landed.
335
+ try { C.save(); } catch (e2) {}
336
+ return err("CREATE_INCOMPLETE", e.message || e);
337
+ }
338
+
339
+ try {
340
+ C.save();
341
+ } catch (e) {
342
+ return err("SAVE_FAILED", e.message || e);
343
+ }
344
+
345
+ // Re-read AFTER the save, so what comes back is what Contacts stored rather
346
+ // than what this script asked for.
347
+ var fresh = findPerson(C, prop(function () { return String(person.id()); }, ""));
348
+ if (!fresh) {
349
+ return err("CREATE_NOT_PERSISTED", "Contacts saved without error but the contact could not be read back.");
350
+ }
351
+ return ok(shapePerson(fresh));
352
+ }
353
+ `;
354
+ const UPDATE_CONTACT = `${PRELUDE}
355
+ function run(argv) {
356
+ var p = JSON.parse(argv[0]);
357
+ var C = Application("Contacts");
358
+
359
+ if (!isContactsRunning() && !p.allowLaunch) {
360
+ return err("APP_NOT_RUNNING", "Contacts is not running.");
361
+ }
362
+
363
+ var person = findPerson(C, p.personId);
364
+ if (!person) {
365
+ return err("CONTACT_NOT_FOUND", "No contact with id " + p.personId + ".");
366
+ }
367
+
368
+ try {
369
+ applyFields(person, p.fields);
370
+ addChildren(C, person, p.phones, p.emails);
371
+ } catch (e) {
372
+ return err("UPDATE_FAILED", e.message || e);
373
+ }
374
+
375
+ try {
376
+ C.save();
377
+ } catch (e) {
378
+ return err("SAVE_FAILED", e.message || e);
379
+ }
380
+
381
+ var fresh = findPerson(C, p.personId);
382
+ if (!fresh) {
383
+ return err("UPDATE_NOT_PERSISTED", "Contacts saved without error but the contact could not be read back.");
384
+ }
385
+ return ok(shapePerson(fresh));
386
+ }
387
+ `;
388
+ //#endregion
389
+ //#region src/client/locate.ts
390
+ /**
391
+ * Find Contacts' stores — plural, which is the whole point of this file.
392
+ *
393
+ * Every other surface in this repo has one store. Contacts has one per account
394
+ * plus a root database, and `docs/contacts.md` measured what that means:
395
+ *
396
+ * AddressBook-v22.abcddb 1 contact
397
+ * Sources/<uuid>/AddressBook-v22.abcddb 420 contacts
398
+ *
399
+ * The obvious path — the one at the top of the directory — is present, readable,
400
+ * correctly shaped, and empty. A server that opens it gets a working database
401
+ * with nobody in it, which fails no check and returns no answer. That is not a
402
+ * hypothetical: `scripts/probe-contacts.mjs` did exactly this and reported a
403
+ * confident 0% resolution rate before anyone noticed.
404
+ *
405
+ * So there is no "the" store here. Everything readable is opened and the rows
406
+ * are unioned, and the number of sources is discovered rather than assumed —
407
+ * one on the probed machine, more with Google or Exchange accounts.
408
+ */
409
+ /** `~/Library/Application Support/AddressBook`. */
410
+ const ADDRESSBOOK_DIR = join("Library", "Application Support", "AddressBook");
411
+ /** The per-account subdirectory. Each child holds one database. */
412
+ const SOURCES_DIRNAME = "Sources";
413
+ /** Constant on every store, root and source alike. */
414
+ const STORE_FILENAME = "AddressBook-v22.abcddb";
415
+ const defaultDirPath = (home = homedir()) => join(home, ADDRESSBOOK_DIR);
416
+ /**
417
+ * The grant hint.
418
+ *
419
+ * Deliberately NOT the Full Disk Access sentence the other surfaces use.
420
+ * Contacts is protected by its own TCC service, and unlike Full Disk Access that
421
+ * one prompts — so the likely fix is a dialog that was dismissed, and the
422
+ * remedy names the Contacts pane rather than asking for the whole disk.
423
+ */
424
+ const GRANT_HINT = "Contacts is protected by its own privacy permission, not by Full Disk Access. macOS asks for it the first time something reads the address book; if that dialog was dismissed, re-enable the app under System Settings > Privacy & Security > Contacts and restart it.";
425
+ const listDirs = (dir) => {
426
+ try {
427
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
428
+ } catch {
429
+ return [];
430
+ }
431
+ };
432
+ const locateStores = (opts = {}) => {
433
+ const dirPath = defaultDirPath(opts.home);
434
+ if (opts.storePath) {
435
+ const candidate = {
436
+ ...describeStore(opts.storePath),
437
+ path: opts.storePath,
438
+ label: "explicit"
439
+ };
440
+ return {
441
+ dirPath,
442
+ dirListable: true,
443
+ candidates: [candidate],
444
+ readable: candidate.readable ? [candidate] : [],
445
+ sourceCount: 0,
446
+ reason: candidate.readable ? null : candidate.exists ? `The store at ${opts.storePath} exists but cannot be read. ${GRANT_HINT}` : `No file at ${opts.storePath}. APPLE_CONTACTS_STORE points at nothing.`
447
+ };
448
+ }
449
+ const rootPath = join(dirPath, STORE_FILENAME);
450
+ const sourceNames = listDirs(join(dirPath, SOURCES_DIRNAME));
451
+ const candidates = [{
452
+ ...describeStore(rootPath),
453
+ path: rootPath,
454
+ label: "root"
455
+ }, ...sourceNames.map((name) => {
456
+ const path = join(dirPath, SOURCES_DIRNAME, name, STORE_FILENAME);
457
+ return {
458
+ ...describeStore(path),
459
+ path,
460
+ label: name
461
+ };
462
+ })].filter((c) => c.exists);
463
+ const readable = candidates.filter((c) => c.readable);
464
+ const dirListable = listDirs(dirPath).length > 0 || sourceNames.length > 0;
465
+ const reason = readable.length ? null : candidates.length ? `Found ${candidates.length} Contacts store(s) under ${dirPath} but none could be opened. ${GRANT_HINT}` : dirListable ? `No ${STORE_FILENAME} under ${dirPath}. Has Contacts ever been set up on this account?` : `${dirPath} could not be listed, so the per-account stores could not be found. ${GRANT_HINT}`;
466
+ return {
467
+ dirPath,
468
+ dirListable,
469
+ candidates,
470
+ readable,
471
+ sourceCount: sourceNames.length,
472
+ reason
473
+ };
474
+ };
475
+ //#endregion
476
+ //#region src/client/phone.ts
477
+ /**
478
+ * Phone number matching, which on this surface is the whole product.
479
+ *
480
+ * Contacts stores what the user typed. `docs/contacts.md` measured a 400-row
481
+ * sample of `ZFULLNUMBER`: 222 formatted (`06 12 34 56 78`), 159 already E.164,
482
+ * 15 bare digits. Messages, meanwhile, stores a handle as E.164 and nothing
483
+ * else. So the two never meet as strings, and the measurement says so with an
484
+ * unusually blunt number: **exact string equality resolves 3.7% of message
485
+ * traffic.** A resolver that joins on the stored value is not slightly wrong, it
486
+ * is useless.
487
+ *
488
+ * What works is a SUFFIX. No prefix rule connects `06…` to `+336…` without
489
+ * knowing the user's country, which nothing here has any business guessing, but
490
+ * the two agree from the ninth digit back.
491
+ */
492
+ /** Everything that is not a digit, removed. The base of every key below. */
493
+ const digitsOf = (value) => value.replaceAll(/\D/g, "");
494
+ /**
495
+ * How many trailing digits make a key. Nine, measured rather than picked.
496
+ *
497
+ * | key | recent traffic resolved | ambiguous |
498
+ * | -------- | ----------------------- | --------- |
499
+ * | exact | 8.5% | 1 |
500
+ * | 10 | 96.7% | 5 |
501
+ * | **9** | **97.6%** | **6** |
502
+ * | 7 | 97.6% | 6 |
503
+ *
504
+ * Seven ties nine on every column measured, so nine wins on the tie-break that
505
+ * matters: a shorter key can only ever collide more. Ten is where French
506
+ * national numbers (`0612345678`, ten digits) stop lining up with the same
507
+ * number in E.164 (`+33612345678`, eleven) — which is exactly why 10 does no
508
+ * better than plain digits and 9 does.
509
+ */
510
+ const SUFFIX_DIGITS = 9;
511
+ /**
512
+ * Below this, a number is a shortcode — a bank, a delivery service, a 2FA
513
+ * sender. 115 of the 958 handles in the measured `chat.db` were these. They can
514
+ * never resolve to a contact, and counting them as failures is how a resolver
515
+ * ends up reporting a far worse rate than it earns.
516
+ */
517
+ const SHORTCODE_MAX_DIGITS = 6;
518
+ const isShortcode = (value) => {
519
+ const d = digitsOf(value);
520
+ return d.length > 0 && d.length <= SHORTCODE_MAX_DIGITS;
521
+ };
522
+ /**
523
+ * The lookup key, or `null` when the value is too short to make one.
524
+ *
525
+ * Returning `null` rather than a short key is deliberate: a three-digit key
526
+ * would match any number ending in those digits, which is the failure mode this
527
+ * whole module exists to avoid.
528
+ */
529
+ const suffixKey = (value, digits = 9) => {
530
+ const d = digitsOf(value);
531
+ return d.length >= digits ? d.slice(-digits) : null;
532
+ };
533
+ /** Email keys are simply case-folded. Measured: 37 of 60 resolve, none ambiguous. */
534
+ const emailKey = (value) => value.trim().toLowerCase();
535
+ /**
536
+ * What kind of thing a Messages handle is.
537
+ *
538
+ * Order matters: `@` decides first, because an email address can contain digits
539
+ * and a phone number can never contain an `@`.
540
+ */
541
+ const handleKind = (handle) => {
542
+ if (handle.includes("@")) return "email";
543
+ return isShortcode(handle) ? "shortcode" : "phone";
544
+ };
545
+ //#endregion
546
+ //#region src/client/resolve.ts
547
+ /**
548
+ * Distinct PEOPLE, not distinct rows.
549
+ *
550
+ * A contact with the same number stored twice (mobile and iPhone, which Contacts
551
+ * does routinely) would otherwise read as ambiguous. Linked records across two
552
+ * accounts are collapsed on `ZLINKID` for the same reason: Contacts shows one
553
+ * unified card for them, and reporting two names for one person would contradict
554
+ * what the user sees in the app.
555
+ */
556
+ const distinctPeople = (ids, lookup) => {
557
+ const seen = /* @__PURE__ */ new Map();
558
+ for (const id of ids) {
559
+ const contact = lookup.contacts.get(id);
560
+ if (!contact) continue;
561
+ const key = contact.linkId === null ? `pk:${id}` : `link:${contact.linkId}`;
562
+ if (!seen.has(key)) seen.set(key, contact);
563
+ }
564
+ return [...seen.values()];
565
+ };
566
+ const resolveHandle = (handle, lookup) => {
567
+ const kind = handleKind(handle);
568
+ const base = {
569
+ handle,
570
+ kind,
571
+ name: null,
572
+ contact: null,
573
+ matches: 0
574
+ };
575
+ if (kind === "shortcode") return {
576
+ ...base,
577
+ status: "shortcode"
578
+ };
579
+ const ids = kind === "email" ? lookup.byEmail.get(emailKey(handle)) : (() => {
580
+ const key = suffixKey(handle, lookup.suffixDigits);
581
+ return key === null ? void 0 : lookup.byPhone.get(key);
582
+ })();
583
+ if (!ids?.size) return {
584
+ ...base,
585
+ status: "unknown"
586
+ };
587
+ const people = distinctPeople(ids, lookup);
588
+ if (people.length === 1) {
589
+ const contact = people[0];
590
+ return {
591
+ handle,
592
+ kind,
593
+ status: "resolved",
594
+ name: contact.displayName,
595
+ contact,
596
+ matches: 1
597
+ };
598
+ }
599
+ if (people.length === 0) return {
600
+ ...base,
601
+ status: "unknown"
602
+ };
603
+ return {
604
+ ...base,
605
+ status: "ambiguous",
606
+ matches: people.length
607
+ };
608
+ };
609
+ const resolveHandles = (handles, lookup) => handles.map((h) => resolveHandle(h, lookup));
610
+ /** Counts by status, for a caller that wants to report coverage honestly. */
611
+ const summarise = (results) => {
612
+ const out = {
613
+ resolved: 0,
614
+ unknown: 0,
615
+ ambiguous: 0,
616
+ shortcode: 0
617
+ };
618
+ for (const r of results) out[r.status] += 1;
619
+ return out;
620
+ };
621
+ //#endregion
622
+ //#region src/client/store.ts
623
+ /**
624
+ * Contacts' file lane.
625
+ *
626
+ * Reads only, and there is no Apple Events lane at all — not even a fallback.
627
+ * `docs/contacts.md` measured the dictionary as fast (63 ms for every id, 52 ms
628
+ * for every phone number) and still ruled it out for reads, because the thing
629
+ * this surface exists to do is join 970 stored numbers against a set of handles
630
+ * by suffix, and no amount of round trips gets a keyed index out of `osascript`.
631
+ * The consequence is worth stating: **a read-only surface needs no Automation
632
+ * grant**, so this server never prompts for one.
633
+ *
634
+ * ## Two things measured here that are not obvious from the schema
635
+ *
636
+ * **The store is plural.** See `locate.ts`. Every method on `ContactsIndex` fans
637
+ * out over shards and merges.
638
+ *
639
+ * **`ZABCDRECORD` is not a table of contacts.** It is a Core Data single-table
640
+ * inheritance root, and groups, containers and an info row live in it alongside
641
+ * people — 425 rows for 420 contacts on the probed machine. `Z_ENT` is the only
642
+ * discriminator, and it is resolved through `Z_PRIMARYKEY` BY NAME rather than
643
+ * hardcoded, because Core Data assigns those numbers per model version.
644
+ */
645
+ /** Tables the lane cannot work without. */
646
+ const REQUIRED = ["ZABCDRECORD"];
647
+ /** The schema this was written against. Named in the drift error, not enforced. */
648
+ const PROBED_FINGERPRINT = "4f2871e93f6b";
649
+ const PROBED_MACOS = "26.6";
650
+ /**
651
+ * Entity names that mean "a person".
652
+ *
653
+ * `ABCDSubscribedContact` inherits from `ABCDContact` and is included: a contact
654
+ * arriving from a subscribed source is still someone whose name should appear
655
+ * beside their messages. Groups (`ABCDGroup`, `ABCDSmartGroup`) and the
656
+ * bookkeeping entities (`ABCDInfo`, `CNCDContainer`) are not people.
657
+ */
658
+ const CONTACT_ENTITIES = /^(ABCD)?(Subscribed)?Contact$/i;
659
+ const num = (v) => typeof v === "number" ? v : null;
660
+ const text = (v) => typeof v === "string" && v.length > 0 ? v : null;
661
+ /**
662
+ * A name to show, assembled from whatever the record actually carries.
663
+ *
664
+ * Falls through deliberately: plenty of real contacts are an organisation with
665
+ * no person name (a garage, a doctor's office), and plenty are a first name
666
+ * alone. Returning an empty string would put a blank where a sender should be,
667
+ * so the last resort is explicit.
668
+ */
669
+ const displayNameOf = (c) => {
670
+ return [c.firstName, c.lastName].filter(Boolean).join(" ").trim() || c.nickname || c.organization || "(no name)";
671
+ };
672
+ /**
673
+ * Add one key to one bucket. A null key is skipped, never stored as `""` — a
674
+ * number too short to make a key must not become a key that matches everything.
675
+ */
676
+ const remember = (map, key, id) => {
677
+ if (!key) return;
678
+ const bucket = map.get(key);
679
+ if (bucket) bucket.add(id);
680
+ else map.set(key, /* @__PURE__ */ new Set([id]));
681
+ };
682
+ var ContactsIndex = class ContactsIndex {
683
+ shards;
684
+ constructor(shards) {
685
+ this.shards = shards;
686
+ }
687
+ /** Every shard's fingerprint. More than one distinct value is worth showing. */
688
+ get fingerprints() {
689
+ return [...new Set(this.shards.map((s) => s.caps.fingerprint))];
690
+ }
691
+ get totalContacts() {
692
+ return this.shards.reduce((n, s) => n + s.contacts, 0);
693
+ }
694
+ /**
695
+ * Project a column, or a typed NULL when this store does not have it.
696
+ *
697
+ * Same guard as the other surfaces: the schema is reverse-engineered and
698
+ * unversioned, so an Apple rename costs one field rather than the lane.
699
+ */
700
+ static #col(present, table, name, alias) {
701
+ return present.has(name) ? `${table}."${name}" AS ${alias}` : `NULL AS ${alias}`;
702
+ }
703
+ static #entFilter(caps, alias) {
704
+ if (!caps.contactEntities.length) return "";
705
+ return `WHERE ${alias}."Z_ENT" IN (${caps.contactEntities.join(", ")})`;
706
+ }
707
+ #contactsFrom(shard, where, params, limit) {
708
+ const c = shard.caps.recordColumns;
709
+ const col = ContactsIndex.#col;
710
+ const ent = ContactsIndex.#entFilter(shard.caps, "r");
711
+ const extra = where ? `${ent ? "AND" : "WHERE"} ${where}` : "";
712
+ const sql = `
713
+ SELECT r."Z_PK" AS recordPk,
714
+ ${col(c, "r", "ZUNIQUEID", "uniqueId")},
715
+ ${col(c, "r", "ZFIRSTNAME", "firstName")},
716
+ ${col(c, "r", "ZLASTNAME", "lastName")},
717
+ ${col(c, "r", "ZNICKNAME", "nickname")},
718
+ ${col(c, "r", "ZORGANIZATION", "organization")},
719
+ ${col(c, "r", "ZJOBTITLE", "jobTitle")},
720
+ ${col(c, "r", "ZLINKID", "linkId")},
721
+ ${col(c, "r", "ZCONTAINERWHERECONTACTISME", "isMe")}
722
+ FROM "ZABCDRECORD" r
723
+ ${ent} ${extra}
724
+ ORDER BY r."ZLASTNAME" ASC, r."ZFIRSTNAME" ASC
725
+ LIMIT ${Math.max(1, Math.trunc(limit))}`;
726
+ return shard.db.prepare(sql).all(...params).map((r) => {
727
+ const parts = {
728
+ firstName: text(r.firstName),
729
+ lastName: text(r.lastName),
730
+ nickname: text(r.nickname),
731
+ organization: text(r.organization)
732
+ };
733
+ return {
734
+ recordPk: Number(r.recordPk),
735
+ uniqueId: text(r.uniqueId),
736
+ ...parts,
737
+ jobTitle: text(r.jobTitle),
738
+ displayName: displayNameOf(parts),
739
+ source: shard.label,
740
+ linkId: num(r.linkId),
741
+ isMe: num(r.isMe) !== null
742
+ };
743
+ });
744
+ }
745
+ /** Every contact, across every shard. */
746
+ list(limit) {
747
+ return this.shards.flatMap((s) => this.#contactsFrom(s, "", [], limit)).slice(0, limit);
748
+ }
749
+ /**
750
+ * Name search.
751
+ *
752
+ * `LIKE ? ESCAPE '\'` with core's `escapeLike`, so a contact called "100%
753
+ * Design" can be searched for literally instead of matching everyone.
754
+ */
755
+ search(query, limit) {
756
+ const needle = `%${escapeLike(query)}%`;
757
+ const out = [];
758
+ for (const shard of this.shards) {
759
+ const c = shard.caps.recordColumns;
760
+ const fields = [
761
+ "ZFIRSTNAME",
762
+ "ZLASTNAME",
763
+ "ZNICKNAME",
764
+ "ORGANIZATION",
765
+ "ZORGANIZATION"
766
+ ].filter((f) => c.has(f)).map((f) => `r."${f}" LIKE ? ESCAPE '\\'`);
767
+ if (!fields.length) continue;
768
+ out.push(...this.#contactsFrom(shard, `(${fields.join(" OR ")})`, fields.map(() => needle), limit));
769
+ }
770
+ return out.slice(0, limit);
771
+ }
772
+ byPk(shardLabel, recordPk) {
773
+ const shard = this.shards.find((s) => s.label === shardLabel);
774
+ if (!shard) return null;
775
+ return this.#contactsFrom(shard, `r."Z_PK" = ?`, [recordPk], 1)[0] ?? null;
776
+ }
777
+ #childRows(shard, table, caps, valueColumns, recordPks) {
778
+ if (!caps.size) return [];
779
+ const valueCol = valueColumns.find((v) => caps.has(v));
780
+ if (!valueCol || !caps.has("ZOWNER")) return [];
781
+ const scope = recordPks?.length ? `AND x."ZOWNER" IN (${recordPks.map(() => "?").join(", ")})` : "";
782
+ const sql = `
783
+ SELECT x."ZOWNER" AS recordPk,
784
+ x."${valueCol}" AS value,
785
+ ${caps.has("ZLABEL") ? `x."ZLABEL"` : "NULL"} AS label
786
+ FROM "${table}" x
787
+ WHERE x."${valueCol}" IS NOT NULL AND x."${valueCol}" <> '' ${scope}`;
788
+ return shard.db.prepare(sql).all(...recordPks ?? []).flatMap((r) => {
789
+ const value = text(r.value);
790
+ const recordPk = num(r.recordPk);
791
+ if (!value || recordPk === null) return [];
792
+ return [{
793
+ recordPk,
794
+ value,
795
+ label: text(r.label)
796
+ }];
797
+ });
798
+ }
799
+ phonesFor(shardLabel, recordPks) {
800
+ const shard = this.shards.find((s) => s.label === shardLabel);
801
+ if (!shard) return [];
802
+ return this.#childRows(shard, "ZABCDPHONENUMBER", shard.caps.phoneColumns, ["ZFULLNUMBER"], recordPks);
803
+ }
804
+ emailsFor(shardLabel, recordPks) {
805
+ const shard = this.shards.find((s) => s.label === shardLabel);
806
+ if (!shard) return [];
807
+ return this.#childRows(shard, "ZABCDEMAILADDRESS", shard.caps.emailColumns, ["ZADDRESS", "ZADDRESSNORMALIZED"], recordPks);
808
+ }
809
+ /**
810
+ * The resolver index: every phone suffix and every email, keyed to a contact.
811
+ *
812
+ * Built in one pass over every shard because a handle does not know which
813
+ * account its owner lives in. A key mapping to more than one DISTINCT contact
814
+ * is kept as such — see `resolve.ts`, which reports ambiguity rather than
815
+ * picking. `docs/contacts.md` measured six such collisions at nine digits, and
816
+ * twenty-eight at four.
817
+ */
818
+ buildLookup(suffixDigits = 9) {
819
+ const byPhone = /* @__PURE__ */ new Map();
820
+ const byEmail = /* @__PURE__ */ new Map();
821
+ const contacts = /* @__PURE__ */ new Map();
822
+ for (const shard of this.shards) {
823
+ const people = this.#contactsFrom(shard, "", [], Number.MAX_SAFE_INTEGER);
824
+ const byPk = new Map(people.map((p) => [p.recordPk, p]));
825
+ for (const p of people) contacts.set(`${shard.label}:${p.recordPk}`, p);
826
+ for (const row of this.#childRows(shard, "ZABCDPHONENUMBER", shard.caps.phoneColumns, ["ZFULLNUMBER"])) {
827
+ if (!byPk.has(row.recordPk)) continue;
828
+ remember(byPhone, suffixKey(row.value, suffixDigits), `${shard.label}:${row.recordPk}`);
829
+ }
830
+ for (const row of this.#childRows(shard, "ZABCDEMAILADDRESS", shard.caps.emailColumns, ["ZADDRESS", "ZADDRESSNORMALIZED"])) {
831
+ if (!byPk.has(row.recordPk)) continue;
832
+ remember(byEmail, emailKey(row.value), `${shard.label}:${row.recordPk}`);
833
+ }
834
+ }
835
+ return {
836
+ byPhone,
837
+ byEmail,
838
+ contacts,
839
+ suffixDigits
840
+ };
841
+ }
842
+ close() {
843
+ for (const s of this.shards) try {
844
+ s.db.close();
845
+ } catch {}
846
+ }
847
+ };
848
+ const introspect = (db) => {
849
+ const recordColumns = new Set(columnsOf(db, "ZABCDRECORD"));
850
+ for (const t of REQUIRED) if (recordColumns.size === 0) throw new SchemaDriftError(`This Contacts store has no ${t} table. It was probed on macOS ${PROBED_MACOS} with schema fingerprint ${PROBED_FINGERPRINT} (a PROBE fingerprint — compare it against another probe run, not against the one diagnostics reports); re-run \`pnpm probe:contacts\` to see what changed.`);
851
+ let contactEntities = [];
852
+ try {
853
+ contactEntities = db.prepare(`SELECT Z_ENT AS ent, Z_NAME AS name FROM Z_PRIMARYKEY`).all().filter((r) => CONTACT_ENTITIES.test(r.name)).map((r) => Number(r.ent));
854
+ } catch {
855
+ contactEntities = [];
856
+ }
857
+ const phoneColumns = new Set(columnsOf(db, "ZABCDPHONENUMBER"));
858
+ const emailColumns = new Set(columnsOf(db, "ZABCDEMAILADDRESS"));
859
+ return {
860
+ fingerprint: fingerprintSchema(db),
861
+ recordColumns,
862
+ phoneColumns,
863
+ emailColumns,
864
+ contactEntities,
865
+ hasPhones: phoneColumns.size > 0,
866
+ hasEmails: emailColumns.size > 0,
867
+ hasNotes: columnsOf(db, "ZABCDNOTE").length > 0,
868
+ epochOffset: CORE_DATA_EPOCH_OFFSET
869
+ };
870
+ };
871
+ /** Count the people in one opened shard, with the entity filter applied. */
872
+ const countContacts = (db, caps) => {
873
+ const where = caps.contactEntities.length ? `WHERE "Z_ENT" IN (${caps.contactEntities.join(", ")})` : "";
874
+ try {
875
+ const row = db.prepare(`SELECT COUNT(*) AS c FROM "ZABCDRECORD" ${where}`).get();
876
+ return Number(row.c ?? 0);
877
+ } catch {
878
+ return 0;
879
+ }
880
+ };
881
+ const openShard = (path, label, mode, logger) => {
882
+ try {
883
+ const { db, mode: used, validated } = openReadOnly(path, mode, {
884
+ label: "Contacts store",
885
+ envVar: "APPLE_CONTACTS_INDEX_MODE",
886
+ validate: introspect,
887
+ fatal: (err) => err instanceof SchemaDriftError,
888
+ onFallback: () => logger?.debug?.("opened a Contacts store with immutable=1, which skips the write-ahead log — very recent edits may be missing until Contacts checkpoints.")
889
+ });
890
+ return {
891
+ db,
892
+ mode: used,
893
+ caps: validated,
894
+ path,
895
+ label,
896
+ contacts: countContacts(db, validated)
897
+ };
898
+ } catch (err) {
899
+ if (err instanceof SchemaDriftError) throw err;
900
+ logger?.debug?.(`skipped Contacts store ${label}: ${String(err)}`);
901
+ return null;
902
+ }
903
+ };
904
+ //#endregion
905
+ //#region src/client/contacts.ts
906
+ var AppleContactsClient = class {
907
+ #config;
908
+ #logger;
909
+ #home;
910
+ #runner;
911
+ #located = null;
912
+ #index = null;
913
+ #indexTried = false;
914
+ #lookup = null;
915
+ constructor(opts) {
916
+ this.#config = opts.config;
917
+ this.#logger = opts.logger;
918
+ this.#home = opts.home;
919
+ this.#runner = opts.osascript ?? createOsascriptRunner({
920
+ surface: CONTACTS_SURFACE,
921
+ osascriptPath: opts.config.osascriptPath,
922
+ timeoutMs: opts.config.osascriptTimeoutMs,
923
+ ...opts.logger ? { logger: opts.logger } : {}
924
+ });
925
+ }
926
+ get config() {
927
+ return this.#config;
928
+ }
929
+ located() {
930
+ this.#located ??= locateStores({
931
+ storePath: this.#config.storePath,
932
+ ...this.#home ? { home: this.#home } : {}
933
+ });
934
+ return this.#located;
935
+ }
936
+ /**
937
+ * Open every readable store, once.
938
+ *
939
+ * `indexMode: "off"` is honoured as a hard no — it is what the test suite uses
940
+ * so that a machine WITH the grant does not silently read the developer's own
941
+ * address book and pass or fail on data nobody wrote.
942
+ */
943
+ index() {
944
+ if (this.#indexTried) return this.#index;
945
+ this.#indexTried = true;
946
+ if (this.#config.indexMode === "off") return null;
947
+ const located = this.located();
948
+ const mode = this.#config.indexMode === "auto" ? "ro" : this.#config.indexMode;
949
+ const shards = [];
950
+ for (const candidate of located.readable) {
951
+ const shard = openShard(candidate.path, candidate.label, mode, this.#logger);
952
+ if (shard) shards.push(shard);
953
+ }
954
+ this.#index = shards.length ? new ContactsIndex(shards) : null;
955
+ return this.#index;
956
+ }
957
+ /**
958
+ * The index, or an error naming what is wrong.
959
+ *
960
+ * Never `[]`. An empty list is the answer to "you have no contacts", and this
961
+ * surface has exactly one way to produce that answer wrongly — reading the
962
+ * root store and finding one person in it. Callers get a reason instead.
963
+ */
964
+ #require() {
965
+ const index = this.index();
966
+ if (index) return index;
967
+ if (this.#config.indexMode === "off") throw new IndexUnavailableError("The Contacts index is disabled (APPLE_CONTACTS_INDEX_MODE=off). This server has no other lane, so nothing can be read until it is re-enabled.");
968
+ throw new ContactsUnavailableError(this.located().reason ?? "No readable Contacts store was found.");
969
+ }
970
+ list(limit) {
971
+ return this.#require().list(limit ?? this.#config.maxResults);
972
+ }
973
+ search(query, limit) {
974
+ return this.#require().search(query, limit ?? this.#config.maxResults);
975
+ }
976
+ /** One contact with its phone numbers and email addresses. */
977
+ get(source, recordPk) {
978
+ const index = this.#require();
979
+ const contact = index.byPk(source, recordPk);
980
+ if (!contact) return null;
981
+ return {
982
+ ...contact,
983
+ phones: index.phonesFor(source, [recordPk]).map(({ value, label }) => ({
984
+ value,
985
+ label
986
+ })),
987
+ emails: index.emailsFor(source, [recordPk]).map(({ value, label }) => ({
988
+ value,
989
+ label
990
+ }))
991
+ };
992
+ }
993
+ /**
994
+ * Built on first use and kept.
995
+ *
996
+ * Walking every contact and every phone row is cheap once (970 rows on the
997
+ * probed store) and pointless per call. There is no TTL: this process does not
998
+ * write to Contacts, and a server that has been running while the user edited
999
+ * their address book is not the case worth optimising for. `diagnostics`
1000
+ * reports when it was built.
1001
+ */
1002
+ lookup() {
1003
+ this.#lookup ??= this.#require().buildLookup(this.#config.phoneSuffixDigits);
1004
+ return this.#lookup;
1005
+ }
1006
+ /** The function `packages/messages` is meant to call. */
1007
+ resolve(handles) {
1008
+ const results = resolveHandles(handles, this.lookup());
1009
+ return {
1010
+ results,
1011
+ summary: summarise(results)
1012
+ };
1013
+ }
1014
+ async #run(scriptText, params) {
1015
+ try {
1016
+ return await withBusyRetry(() => this.#runner.run(scriptText, params));
1017
+ } catch (err) {
1018
+ const code = err?.details?.code;
1019
+ const message = err instanceof Error ? err.message : String(err);
1020
+ if (code === "CONTACT_NOT_FOUND") throw new ContactNotFoundError(message);
1021
+ if (code === "CREATE_NOT_PERSISTED" || code === "UPDATE_NOT_PERSISTED") throw new ContactWriteNotPersistedError(message);
1022
+ throw err;
1023
+ }
1024
+ }
1025
+ /**
1026
+ * Invalidate the read lane after a write.
1027
+ *
1028
+ * The index and the resolver lookup are both built once and kept, so a contact
1029
+ * created through Apple Events would otherwise stay invisible to `resolve` for
1030
+ * the life of the process — the exact "wrote it, cannot find it" confusion the
1031
+ * id bridge exists to prevent.
1032
+ */
1033
+ #invalidate() {
1034
+ this.#index?.close();
1035
+ this.#index = null;
1036
+ this.#lookup = null;
1037
+ this.#indexTried = false;
1038
+ }
1039
+ #shapeWrite(data) {
1040
+ const personId = typeof data.id === "string" ? data.id : null;
1041
+ const shaped = (key) => Array.isArray(data[key]) ? data[key].map((r) => ({
1042
+ label: typeof r.label === "string" ? r.label : null,
1043
+ value: typeof r.value === "string" ? r.value : null
1044
+ })) : [];
1045
+ return {
1046
+ ref: null,
1047
+ personId,
1048
+ name: typeof data.name === "string" ? data.name : null,
1049
+ organization: typeof data.organization === "string" ? data.organization : null,
1050
+ phones: shaped("phones"),
1051
+ emails: shaped("emails"),
1052
+ source: "apple-events"
1053
+ };
1054
+ }
1055
+ async createContact(input) {
1056
+ const data = await this.#run(CREATE_CONTACT, {
1057
+ fields: input.fields,
1058
+ phones: input.phones ?? [],
1059
+ emails: input.emails ?? [],
1060
+ allowLaunch: true
1061
+ });
1062
+ this.#invalidate();
1063
+ return this.#shapeWrite(data);
1064
+ }
1065
+ async updateContact(input) {
1066
+ const data = await this.#run(UPDATE_CONTACT, {
1067
+ personId: input.personId,
1068
+ fields: input.fields,
1069
+ phones: input.phones ?? [],
1070
+ emails: input.emails ?? [],
1071
+ allowLaunch: true
1072
+ });
1073
+ this.#invalidate();
1074
+ return this.#shapeWrite(data);
1075
+ }
1076
+ status() {
1077
+ const index = this.index();
1078
+ return {
1079
+ located: this.located(),
1080
+ shards: (index?.shards ?? []).map((s) => ({
1081
+ label: s.label,
1082
+ path: s.path,
1083
+ mode: s.mode,
1084
+ contacts: s.contacts,
1085
+ fingerprint: s.caps.fingerprint
1086
+ })),
1087
+ totalContacts: index?.totalContacts ?? 0,
1088
+ indexMode: this.#config.indexMode
1089
+ };
1090
+ }
1091
+ close() {
1092
+ this.#index?.close();
1093
+ this.#index = null;
1094
+ this.#lookup = null;
1095
+ this.#indexTried = false;
1096
+ }
1097
+ };
1098
+ //#endregion
1099
+ //#region src/client/ref.ts
1100
+ /**
1101
+ * `k1:<account>/<recordPk>` — an opaque handle for one contact.
1102
+ *
1103
+ * ## Why the account rides along
1104
+ *
1105
+ * Because the store is plural. A record's `Z_PK` is a rowid, and rowids are only
1106
+ * unique WITHIN one database — the root store and each account store number
1107
+ * their rows from 1 independently. A bare pk would therefore resolve to a
1108
+ * different person depending on which store happened to be read first, which is
1109
+ * the kind of bug that produces a plausible wrong answer rather than an error.
1110
+ *
1111
+ * ## Why not `ZUNIQUEID`
1112
+ *
1113
+ * It exists and is stable, but it is not what the child tables join on —
1114
+ * `ZABCDPHONENUMBER.ZOWNER` points at `Z_PK`. Carrying the pk means a `get`
1115
+ * needs no extra lookup, and the account prefix supplies the uniqueness the pk
1116
+ * lacks. `uniqueId` is still returned on results for callers that want a
1117
+ * durable identity across a re-index.
1118
+ *
1119
+ * ## Why `k1`
1120
+ *
1121
+ * `c1:` is Calendar's and `r1:` is Reminders'; `k1` is free and the version
1122
+ * prefix keeps a future scheme change additive rather than a silent
1123
+ * reinterpretation of refs already sitting in a conversation.
1124
+ */
1125
+ const REF_VERSION = "k1";
1126
+ /**
1127
+ * An account label can contain almost anything — it is a directory name, and on
1128
+ * the probed machine a UUID — so the pk is anchored as the tail and the label is
1129
+ * whatever precedes the last `/`. Splitting on the FIRST separator would break
1130
+ * on any label containing one.
1131
+ */
1132
+ const REF_PATTERN = /^k1:(.+)\/(\d+)$/;
1133
+ var InvalidContactRefError = class extends AppleAutomationError {
1134
+ name = "InvalidContactRefError";
1135
+ constructor(raw) {
1136
+ super(`"${raw}" is not a contact ref. Refs come from apple_contacts_search_contacts or apple_contacts_list_contacts and look like "k1:<account>/<id>" — they are opaque and must not be constructed by hand.` + (raw.startsWith("c1:") || raw.startsWith("r1:") ? " That one belongs to another surface: \"c1:\" refs are Calendar events and \"r1:\" refs are Reminders." : ""), { ref: raw });
1137
+ }
1138
+ };
1139
+ const encodeRef = (source, recordPk) => `k1:${source}/${recordPk}`;
1140
+ const decodeRef = (raw) => {
1141
+ const m = REF_PATTERN.exec(raw.trim());
1142
+ if (!m) throw new InvalidContactRefError(raw);
1143
+ const recordPk = Number(m[2]);
1144
+ if (!Number.isSafeInteger(recordPk) || recordPk <= 0) throw new InvalidContactRefError(raw);
1145
+ return {
1146
+ source: m[1],
1147
+ recordPk
1148
+ };
1149
+ };
1150
+ //#endregion
1151
+ //#region src/config.ts
1152
+ /**
1153
+ * Configuration is environment-only — this server holds no secret at all, its
1154
+ * access is the macOS permission the user granted.
1155
+ *
1156
+ * Note what is ABSENT, and why:
1157
+ *
1158
+ * - **No `allowWrites` behaviour.** It is inherited from `BaseConfigSchema` and
1159
+ * deliberately ignored: this surface registers no mutating tool, so there is
1160
+ * nothing for the flag to gate. Editing someone's address book from a tool
1161
+ * call was never part of what Contacts was probed for.
1162
+ * - **No `osascript` settings in use.** Also inherited, also unused — there is
1163
+ * no Apple Events lane here at all, which is what lets this server run without
1164
+ * an Automation grant.
1165
+ * - **No account allowlist.** Contacts are unioned across accounts precisely so
1166
+ * that a handle resolves wherever its owner lives; scoping that by account
1167
+ * would reintroduce the bug this surface exists to avoid.
1168
+ */
1169
+ const ConfigSchema = BaseConfigSchema.extend({
1170
+ /**
1171
+ * Explicit store path. Bypasses discovery — for tests and forensic copies.
1172
+ *
1173
+ * Naming one file also DISABLES the union, which is the point of having it:
1174
+ * a test needs a single known database, not whatever the machine happens to
1175
+ * hold.
1176
+ */
1177
+ storePath: z.string().optional(),
1178
+ indexMode: z.enum([
1179
+ "auto",
1180
+ "ro",
1181
+ "immutable",
1182
+ "off"
1183
+ ]).default("auto"),
1184
+ /**
1185
+ * Trailing digits that make a phone key.
1186
+ *
1187
+ * Exposed because the right value is a fact about the user's country, and nine
1188
+ * was measured on one machine with mostly French and E.164 numbers. Lower is
1189
+ * more forgiving and collides more; the ambiguity count in `diagnostics` is
1190
+ * how to tell whether a change helped.
1191
+ */
1192
+ phoneSuffixDigits: z.number().int().min(6).max(15).default(9)
1193
+ }).strict();
1194
+ const loadConfig = (env = process.env) => parseConfig(ConfigSchema, {
1195
+ allowWrites: parseBool(env.APPLE_CONTACTS_ALLOW_WRITES),
1196
+ debug: parseBool(env.APPLE_CONTACTS_DEBUG),
1197
+ storePath: trimmed(env.APPLE_CONTACTS_STORE),
1198
+ indexMode: trimmed(env.APPLE_CONTACTS_INDEX_MODE),
1199
+ phoneSuffixDigits: parseIntOpt(env.APPLE_CONTACTS_PHONE_SUFFIX_DIGITS),
1200
+ osascriptPath: trimmed(env.APPLE_CONTACTS_OSASCRIPT_PATH),
1201
+ osascriptTimeoutMs: parseIntOpt(env.APPLE_CONTACTS_OSASCRIPT_TIMEOUT_MS),
1202
+ maxResults: parseIntOpt(env.APPLE_CONTACTS_MAX_RESULTS)
1203
+ });
1204
+ //#endregion
1205
+ //#region src/tools/actions.ts
1206
+ /**
1207
+ * The mutating tools. Registered only when `allowWrites` is on, and never
1208
+ * merely refused — an MCP client caches the tool list, so a tool that exists and
1209
+ * says no is a tool the model will keep trying.
1210
+ *
1211
+ * Two verbs, because the dictionary has two. There is no `delete_contacts`:
1212
+ * `sdef /System/Applications/Contacts.app` contains no delete command of any
1213
+ * kind, and writes go through Apple Events on every surface here because the
1214
+ * store is opened `PRAGMA query_only`. See `client/jxa/core.ts`.
1215
+ */
1216
+ const labelledValue = z.object({
1217
+ label: z.string().optional().describe("Which kind, e.g. \"mobile\", \"home\", \"work\". Defaults to mobile for phones."),
1218
+ value: z.string().min(1)
1219
+ });
1220
+ const fields = {
1221
+ firstName: z.string().nullable().optional(),
1222
+ lastName: z.string().nullable().optional(),
1223
+ nickname: z.string().nullable().optional(),
1224
+ organization: z.string().nullable().optional(),
1225
+ jobTitle: z.string().nullable().optional(),
1226
+ department: z.string().nullable().optional(),
1227
+ note: z.string().nullable().optional(),
1228
+ company: z.boolean().optional().describe("True for an organisation rather than a person — Contacts shows it differently.")
1229
+ };
1230
+ const registerActionTools = (server, client) => {
1231
+ server.registerTool("apple_contacts_create_contact", {
1232
+ description: "Create a new contact in the address book. This is a real card in the user's real Contacts, and on an iCloud account it syncs to their other devices within seconds. Give at least one of firstName, lastName or organization. Contacts keeps edits in an unsaved buffer and its save command commits EVERYTHING pending, so this also saves any edit someone has half-typed in the Contacts window. That is how the app's scripting works, not a choice this server makes. The result is re-read after saving, so what comes back is what Contacts stored rather than what was asked for.",
1233
+ inputSchema: {
1234
+ ...fields,
1235
+ phones: z.array(labelledValue).optional(),
1236
+ emails: z.array(labelledValue).optional()
1237
+ },
1238
+ annotations: {
1239
+ readOnlyHint: false,
1240
+ destructiveHint: false,
1241
+ idempotentHint: false
1242
+ }
1243
+ }, async ({ phones, emails, ...rest }) => wrap(async () => {
1244
+ if (!rest.firstName && !rest.lastName && !rest.organization) return fail("A contact needs at least one of firstName, lastName or organization — Contacts will otherwise create a nameless card that is hard to find again.");
1245
+ return ok(await client.createContact({
1246
+ fields: rest,
1247
+ ...phones ? { phones } : {},
1248
+ ...emails ? { emails } : {}
1249
+ }));
1250
+ }));
1251
+ server.registerTool("apple_contacts_update_contact", {
1252
+ description: "Change an existing contact, or add a phone number or email address to one. Omitting a field leaves it alone; passing null clears it. Phones and emails are ADDED, never replaced — Contacts models them as separate objects, and there is no way to remove one through its scripting dictionary. Contacts keeps edits in an unsaved buffer and its save command commits EVERYTHING pending, so this also saves any edit someone has half-typed in the Contacts window. That is how the app's scripting works, not a choice this server makes. The result is re-read after saving, so what comes back is what Contacts stored rather than what was asked for.",
1253
+ inputSchema: {
1254
+ ref: z.string().min(1).describe("A contact ref from a search or resolve result (looks like \"k1:<account>/<id>\"). Do not construct one by hand."),
1255
+ ...fields,
1256
+ phones: z.array(labelledValue).optional().describe("Added to whatever is already there."),
1257
+ emails: z.array(labelledValue).optional().describe("Added to whatever is already there.")
1258
+ },
1259
+ annotations: {
1260
+ readOnlyHint: false,
1261
+ destructiveHint: false,
1262
+ idempotentHint: false
1263
+ }
1264
+ }, async ({ ref, phones, emails, ...rest }) => wrap(async () => {
1265
+ const decoded = decodeRef(ref);
1266
+ const contact = client.get(decoded.source, decoded.recordPk);
1267
+ if (!contact) return fail(`No contact for ref "${ref}". It was probably deleted, or the account holding it was removed, since the search ran. Re-run the search to get a current ref.`);
1268
+ if (!contact.uniqueId) return fail(`The contact "${contact.displayName}" has no stable identifier in the address book database, so it cannot be addressed through Contacts' scripting interface. Edit it in Contacts.app instead.`);
1269
+ return ok(await client.updateContact({
1270
+ personId: contact.uniqueId,
1271
+ fields: rest,
1272
+ ...phones ? { phones } : {},
1273
+ ...emails ? { emails } : {}
1274
+ }));
1275
+ }));
1276
+ };
1277
+ //#endregion
1278
+ //#region src/tools/contacts.ts
1279
+ /**
1280
+ * NOTE ON `async` BELOW: core's `wrap` is typed `() => Promise<T>` because most
1281
+ * surfaces reach Apple Events. Contacts reads synchronous SQLite and never
1282
+ * leaves the process, so the thunks are marked async here rather than widening a
1283
+ * shared signature for every surface to accommodate one.
1284
+ */
1285
+ const registerContactTools = (server, client) => {
1286
+ server.registerTool("apple_contacts_search_contacts", {
1287
+ description: "Search the address book by name, nickname or organisation. Returns a ref for each match, plus which account it came from — the same person can legitimately appear twice if they are in two accounts. Use apple_contacts_get_contact for phone numbers and email addresses.",
1288
+ inputSchema: {
1289
+ query: z.string().min(1).describe("Text to look for in names and organisations."),
1290
+ limit: limitArg
1291
+ },
1292
+ annotations: { readOnlyHint: true }
1293
+ }, async ({ query, limit }) => wrap(async () => client.search(query, limit).map((c) => ({
1294
+ ref: encodeRef(c.source, c.recordPk),
1295
+ name: c.displayName,
1296
+ organization: c.organization,
1297
+ jobTitle: c.jobTitle,
1298
+ account: c.source
1299
+ }))));
1300
+ server.registerTool("apple_contacts_list_contacts", {
1301
+ description: "List contacts across every account. This is the whole address book, so prefer apple_contacts_search_contacts when you are looking for someone specific.",
1302
+ inputSchema: { limit: limitArg },
1303
+ annotations: { readOnlyHint: true }
1304
+ }, async ({ limit }) => wrap(async () => client.list(limit).map((c) => ({
1305
+ ref: encodeRef(c.source, c.recordPk),
1306
+ name: c.displayName,
1307
+ organization: c.organization,
1308
+ account: c.source
1309
+ }))));
1310
+ server.registerTool("apple_contacts_get_contact", {
1311
+ description: "One contact in full: name, organisation, job title, every phone number and every email address, each with its label.",
1312
+ inputSchema: { ref: z.string().min(1).describe("An opaque contact ref from a list or search result (looks like \"k1:<account>/<id>\"). Do not construct one by hand.") },
1313
+ annotations: { readOnlyHint: true }
1314
+ }, async ({ ref }) => wrap(async () => {
1315
+ const decoded = decodeRef(ref);
1316
+ const contact = client.get(decoded.source, decoded.recordPk);
1317
+ if (!contact) return fail(`No contact for ref "${ref}". It was probably deleted, or the account holding it was removed, since the search ran. Re-run the search to get a current ref.`);
1318
+ return ok({
1319
+ ref,
1320
+ name: contact.displayName,
1321
+ firstName: contact.firstName,
1322
+ lastName: contact.lastName,
1323
+ nickname: contact.nickname,
1324
+ organization: contact.organization,
1325
+ jobTitle: contact.jobTitle,
1326
+ account: contact.source,
1327
+ isMe: contact.isMe,
1328
+ phones: contact.phones,
1329
+ emails: contact.emails
1330
+ });
1331
+ }));
1332
+ };
1333
+ //#endregion
1334
+ //#region src/tools/diagnostics.ts
1335
+ /**
1336
+ * What this server can currently do, and why not more.
1337
+ *
1338
+ * The caveats are the point. Two of them are specific to this surface and both
1339
+ * produce a plausible wrong answer rather than an error, which is exactly the
1340
+ * kind of thing a caller cannot discover for itself.
1341
+ */
1342
+ const registerDiagnosticsTools = (server, client) => {
1343
+ server.registerTool("apple_contacts_diagnostics", {
1344
+ description: "Report which Contacts stores were opened, how many contacts each holds, and what this server cannot do. Start here when a lookup returns nothing.",
1345
+ inputSchema: {},
1346
+ annotations: { readOnlyHint: true }
1347
+ }, async () => wrap(async () => {
1348
+ const status = client.status();
1349
+ const located = status.located;
1350
+ return {
1351
+ server: {
1352
+ name: BUILD_INFO.name,
1353
+ version: BUILD_INFO.version
1354
+ },
1355
+ lane: {
1356
+ reads: "file lane (read-only SQLite)",
1357
+ writes: "none — this server registers no mutating tool",
1358
+ appleEvents: "not used at all, so no Automation grant is needed or requested"
1359
+ },
1360
+ stores: {
1361
+ directory: located.dirPath,
1362
+ directoryListable: located.dirListable,
1363
+ found: located.candidates.length,
1364
+ opened: status.shards.length,
1365
+ sourcesSeen: located.sourceCount,
1366
+ totalContacts: status.totalContacts,
1367
+ shards: status.shards,
1368
+ indexMode: status.indexMode,
1369
+ reason: located.reason
1370
+ },
1371
+ resolution: {
1372
+ phoneSuffixDigits: client.config.phoneSuffixDigits,
1373
+ note: "Phone matching uses the last N digits because Contacts stores numbers as typed. Fewer digits resolves more handles and collides more; the ambiguous count in a resolve result is how to tell whether a change helped."
1374
+ },
1375
+ caveats: [
1376
+ "Contacts is protected by its own privacy permission, NOT by Full Disk Access, and unlike Full Disk Access macOS prompts for it. A store that cannot be opened usually means that prompt was dismissed — re-enable this app under System Settings > Privacy & Security > Contacts.",
1377
+ "The address book is spread across several databases: one per account, plus a root store that is normally almost empty. All readable ones are unioned. If `opened` is lower than `found`, some accounts are missing from every answer here.",
1378
+ "A handle that resolves to more than one contact is reported as ambiguous with no name, never as a guess. Putting the wrong name on a message is worse than putting none, because it does not look wrong.",
1379
+ "Contacts held in two accounts are folded together on their link id, matching what Contacts.app shows as one unified card. A contact with no link id is not folded.",
1380
+ "This server is read-only by construction. It cannot create, edit or delete a contact, and enabling writes does not add a tool."
1381
+ ]
1382
+ };
1383
+ }));
1384
+ };
1385
+ //#endregion
1386
+ //#region src/tools/resolve.ts
1387
+ /**
1388
+ * The tool this surface was built for.
1389
+ *
1390
+ * Everything else here is an ordinary address book server. This one exists
1391
+ * because `chat.db` — and a caller ID, and an email header — carries an
1392
+ * identifier and no name, and turning one into the other is the only thing on
1393
+ * this machine that can.
1394
+ */
1395
+ const registerResolveTools = (server, client) => {
1396
+ server.registerTool("apple_contacts_resolve_handles", {
1397
+ description: "Turn phone numbers or email addresses into contact names. Give it the raw identifiers from somewhere else — Messages handles, a caller ID, an email header — and it returns one result per handle.\n\nRead `status` on every result rather than assuming a name came back:\n- \"resolved\" — exactly one contact. `name` is set.\n- \"unknown\" — nobody in the address book has this number. COMMON AND NOT AN ERROR: measured on a real store, about one in six of even the busiest correspondents does not resolve. Show the raw handle.\n- \"ambiguous\" — more than one contact has it, so no name is returned. Do not guess; `matches` says how many.\n- \"shortcode\" — a bank, a courier, a 2FA sender. Can never be a contact.\n\nPhone matching is by trailing digits, because Contacts stores numbers as typed (\"06 12 34 56 78\") while most systems hand you E.164 (\"+33612345678\"). Exact string matching resolves almost nothing and is not used.",
1398
+ inputSchema: { handles: z.array(z.string().min(1)).min(1).max(500).describe("Phone numbers or email addresses, in any format — \"+33612345678\", \"06 12 34 56 78\" and \"user@example.com\" all work.") },
1399
+ annotations: { readOnlyHint: true }
1400
+ }, async ({ handles }) => wrap(async () => {
1401
+ const { results, summary } = client.resolve(handles);
1402
+ return {
1403
+ summary,
1404
+ results: results.map((r) => ({
1405
+ handle: r.handle,
1406
+ kind: r.kind,
1407
+ status: r.status,
1408
+ name: r.name,
1409
+ matches: r.matches,
1410
+ ...r.contact ? {
1411
+ ref: encodeRef(r.contact.source, r.contact.recordPk),
1412
+ organization: r.contact.organization,
1413
+ account: r.contact.source
1414
+ } : {}
1415
+ }))
1416
+ };
1417
+ }));
1418
+ };
1419
+ //#endregion
1420
+ //#region src/tools/index.ts
1421
+ /**
1422
+ * Register the Apple Contacts tools.
1423
+ *
1424
+ * READS are file-lane and ask for no Automation grant. WRITES are Apple Events,
1425
+ * always — the store is opened `PRAGMA query_only` because Contacts owns it and
1426
+ * reconciles it against iCloud, so writing to it would corrupt sync state.
1427
+ *
1428
+ * That split has a cost worth stating plainly: this surface used to need no
1429
+ * Automation grant at all, which docs/distribution.md calls the strongest
1430
+ * argument for file-first. Turning writes on gives that up — the first write
1431
+ * prompts for permission to control Contacts. With `allowWrites` off, nothing
1432
+ * here ever sends an Apple Event and the old property still holds.
1433
+ *
1434
+ * There is no delete. Contacts' scripting dictionary has no delete command of
1435
+ * any kind; see `client/jxa/core.ts` for the measurement.
1436
+ *
1437
+ * The registered set does NOT vary with whether the store is readable. That is a
1438
+ * runtime condition which can change while the process lives, and MCP clients
1439
+ * cache the tool list, so a tool that appeared and disappeared would leave
1440
+ * clients calling names the server no longer has. Tools that need the store
1441
+ * report what is missing instead.
1442
+ */
1443
+ const registerTools = (server, client, ctx) => {
1444
+ registerDiagnosticsTools(server, client);
1445
+ registerContactTools(server, client);
1446
+ registerResolveTools(server, client);
1447
+ if (!ctx.allowWrites) return;
1448
+ registerActionTools(server, client);
1449
+ };
1450
+ //#endregion
1451
+ //#region src/server.ts
1452
+ const SERVER_NAME = BUILD_INFO.name;
1453
+ const SERVER_VERSION = BUILD_INFO.version;
1454
+ /**
1455
+ * Build the server. Side-effect free: it opens no database and reads no file,
1456
+ * so a test can construct it freely and every external dependency arrives
1457
+ * through an option.
1458
+ */
1459
+ const createServer = (opts) => {
1460
+ const { config } = opts;
1461
+ const server = new McpServer({
1462
+ name: SERVER_NAME,
1463
+ version: SERVER_VERSION
1464
+ });
1465
+ const client = new AppleContactsClient({
1466
+ config,
1467
+ ...opts.logger ? { logger: opts.logger } : {},
1468
+ ...opts.osascript ? { osascript: opts.osascript } : {},
1469
+ ...opts.home ? { home: opts.home } : {}
1470
+ });
1471
+ registerTools(server, client, { allowWrites: config.allowWrites });
1472
+ return {
1473
+ server,
1474
+ client
1475
+ };
1476
+ };
1477
+ //#endregion
1478
+ export { AppleContactsError as A, isShortcode as C, STORE_FILENAME as D, SOURCES_DIRNAME as E, IndexUnavailableError$1 as F, SchemaDriftError$1 as I, BUILD_INFO as L, CONTACTS_SURFACE as M, ContactNotFoundError as N, defaultDirPath as O, ContactsUnavailableError as P, handleKind as S, ADDRESSBOOK_DIR as T, resolveHandles as _, loadConfig as a, digitsOf as b, decodeRef as c, ContactsIndex as d, countContacts as f, resolveHandle as g, openShard as h, registerTools as i, CONTACTS_BUNDLE_ID as j, locateStores as k, encodeRef as l, introspect as m, SERVER_VERSION as n, InvalidContactRefError as o, displayNameOf as p, createServer as r, REF_VERSION as s, SERVER_NAME as t, AppleContactsClient as u, summarise as v, suffixKey as w, emailKey as x, SUFFIX_DIGITS as y };
1479
+
1480
+ //# sourceMappingURL=server-BjWSCaMN.js.map