@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Olivier Louvignes <olivier@mgcrea.io>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # @mgcrea/mcp-apple-contacts
2
+
3
+ Model Context Protocol server for the macOS **Apple Contacts** address book.
4
+
5
+ > **Unofficial.** Not affiliated with Apple. It reads the address book already on your Mac.
6
+
7
+ ## Why it exists: resolving handles
8
+
9
+ `apple_contacts_resolve_handles` turns phone numbers and email addresses into names, in batches of up
10
+ to 500. Everything else here is in service of that.
11
+
12
+ This is the surface where the file-lane rule is about **capability, not speed**. Contacts' scripting
13
+ dictionary answers in ~65 ms — comfortably fast, and still useless for this: resolving a phone number
14
+ means a suffix-keyed index over every stored number, and no quantity of Apple Events round trips
15
+ produces one. So reads go through read-only SQLite, and there is no Apple Events read lane.
16
+
17
+ The lookup builds in **3 ms** over 947 phone keys and 295 email keys, then is kept for the life of
18
+ the process.
19
+
20
+ ## Permissions — this one is different
21
+
22
+ | Permission | Needed for | Notes |
23
+ | ------------------------- | ----------- | ---------------------------------------------- |
24
+ | **Contacts** (its own) | reads | **prompts** — answer the dialog, and that's it |
25
+ | **Automation → Contacts** | writes only | reads are unaffected |
26
+
27
+ Contacts fails differently from every other surface in this family: it sits behind its **own TCC
28
+ service** rather than behind Full Disk Access, and unlike Full Disk Access that permission
29
+ _prompts_. So the fix is usually "answer the dialog", not "go to System Settings" — and nothing here
30
+ asks you to hand over whole-disk access for an address book.
31
+
32
+ ## Tools
33
+
34
+ Read: `diagnostics`, `resolve_handles`, `search_contacts`, `list_contacts`, `get_contact`.
35
+
36
+ Write, registered **only** when `APPLE_CONTACTS_ALLOW_WRITES=1` — with the flag off they are
37
+ invisible to the model, not merely refused: `create_contact`, `update_contact`.
38
+
39
+ `resolveHandles()` is also exported from the package root, for callers already inside Node that
40
+ would rather not go through MCP to reach it.
41
+
42
+ ## Configuration
43
+
44
+ | Variable | Default | |
45
+ | ------------------------------------- | ------- | ------------------------------------------ |
46
+ | `APPLE_CONTACTS_ALLOW_WRITES` | off | Register the mutating tools. |
47
+ | `APPLE_CONTACTS_PHONE_SUFFIX_DIGITS` | `9` | Suffix key length; 6–15. |
48
+ | `APPLE_CONTACTS_INDEX_MODE` | `auto` | `auto` \| `ro` \| `immutable` \| `off`. |
49
+ | `APPLE_CONTACTS_STORE` | auto | Explicit store path. |
50
+ | `APPLE_CONTACTS_OSASCRIPT_TIMEOUT_MS` | `30000` | Sized for the first-run permission prompt. |
51
+
52
+ ## Notes that will bite you
53
+
54
+ - **The store is plural.** `locate.ts` returns a list and every query fans out — on a real machine,
55
+ `stores=2/2, contacts=421`, matching Apple Events exactly. A server reading only the first store
56
+ silently loses an account.
57
+ - **Nine-digit suffix keys, and the length travels inside the lookup object** so the index and the
58
+ query cannot diverge. A self-join over a real address book at 7, 9 and 10 digits settles it: 7 and
59
+ 9 collide identically (10 of 947 keys, 1.1%), and 10 drops a key while losing the
60
+ national-to-E.164 join.
61
+ - **Ambiguity is a status, never a guess** — `resolved` / `unknown` / `ambiguous` / `shortcode`.
62
+ `unknown` is documented in the tool description as expected, not as an error.
63
+ - **`Z_ENT` filters to `ABCDContact`.** Without it, a list tool returns your groups as people.
64
+ - **A write can succeed and still not persist.** Contacts holds changes in an unsaved buffer, so a
65
+ mutation can read back correctly inside the same script and never reach the store. Every write
66
+ script saves and then re-reads.
67
+
68
+ ## Licence
69
+
70
+ [MIT](LICENSE).
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {}
package/dist/cli.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { L as BUILD_INFO, M as CONTACTS_SURFACE, a as loadConfig, r as createServer } from "./server-BjWSCaMN.js";
3
+ import { runStdioServer } from "@mgcrea/mcp-apple-core";
4
+ //#region src/cli.ts
5
+ const LOG_PREFIX = "apple-contacts-mcp";
6
+ runStdioServer({
7
+ build: BUILD_INFO,
8
+ surface: CONTACTS_SURFACE,
9
+ logPrefix: LOG_PREFIX,
10
+ start: async (logger) => {
11
+ const config = loadConfig();
12
+ const { server, client } = createServer({
13
+ config,
14
+ logger
15
+ });
16
+ const status = client.status();
17
+ return {
18
+ server,
19
+ banner: `read-only, stores=${status.shards.length}/${status.located.candidates.length}, contacts=${status.totalContacts}, suffix=${config.phoneSuffixDigits}, index=${config.indexMode}`
20
+ };
21
+ }
22
+ }).catch((err) => {
23
+ console.error(`[${LOG_PREFIX}] fatal:`, err);
24
+ process.exit(1);
25
+ });
26
+ //#endregion
27
+ export {};
28
+
29
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { runStdioServer } from \"@mgcrea/mcp-apple-core\";\n\nimport { BUILD_INFO } from \"./build-info.js\";\nimport { CONTACTS_SURFACE } from \"./client/errors.js\";\nimport { loadConfig } from \"./config.js\";\nimport { createServer } from \"./server.js\";\n\nconst LOG_PREFIX = \"apple-contacts-mcp\";\n\nrunStdioServer({\n build: BUILD_INFO,\n surface: CONTACTS_SURFACE,\n logPrefix: LOG_PREFIX,\n start: async (logger) => {\n const config = loadConfig();\n const { server, client } = createServer({ config, logger });\n const status = client.status();\n return {\n server,\n // No writes= line: this surface has no mutating tool, and printing a flag\n // that gates nothing would imply one exists.\n banner:\n `read-only, ` +\n `stores=${status.shards.length}/${status.located.candidates.length}, ` +\n `contacts=${status.totalContacts}, ` +\n `suffix=${config.phoneSuffixDigits}, ` +\n `index=${config.indexMode}`,\n };\n },\n}).catch((err: unknown) => {\n console.error(`[${LOG_PREFIX}] fatal:`, err);\n process.exit(1);\n});\n"],"mappings":";;;;AAQA,MAAM,aAAa;AAEnB,eAAe;CACb,OAAO;CACP,SAAS;CACT,WAAW;CACX,OAAO,OAAO,WAAW;EACvB,MAAM,SAAS,WAAW;EAC1B,MAAM,EAAE,QAAQ,WAAW,aAAa;GAAE;GAAQ;EAAO,CAAC;EAC1D,MAAM,SAAS,OAAO,OAAO;EAC7B,OAAO;GACL;GAGA,QACE,qBACU,OAAO,OAAO,OAAO,GAAG,OAAO,QAAQ,WAAW,OAAO,aACvD,OAAO,cAAc,WACvB,OAAO,kBAAkB,UAC1B,OAAO;EACpB;CACF;AACF,CAAC,CAAC,CAAC,OAAO,QAAiB;CACzB,QAAQ,MAAM,IAAI,WAAW,WAAW,GAAG;CAC3C,QAAQ,KAAK,CAAC;AAChB,CAAC"}
@@ -0,0 +1,558 @@
1
+ import { AppleAutomationError, AppleAutomationError as AppleContactsError, BuildInfo, IndexUnavailableError, Logger, OsascriptRunner, ReadOnlyMode, SchemaDriftError, StoreFacts, SurfaceContext } from "@mgcrea/mcp-apple-core";
2
+ import { z } from "zod";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ //#region src/build-info.d.ts
6
+ declare const BUILD_INFO: BuildInfo;
7
+ //#endregion
8
+ //#region src/config.d.ts
9
+ /**
10
+ * Configuration is environment-only — this server holds no secret at all, its
11
+ * access is the macOS permission the user granted.
12
+ *
13
+ * Note what is ABSENT, and why:
14
+ *
15
+ * - **No `allowWrites` behaviour.** It is inherited from `BaseConfigSchema` and
16
+ * deliberately ignored: this surface registers no mutating tool, so there is
17
+ * nothing for the flag to gate. Editing someone's address book from a tool
18
+ * call was never part of what Contacts was probed for.
19
+ * - **No `osascript` settings in use.** Also inherited, also unused — there is
20
+ * no Apple Events lane here at all, which is what lets this server run without
21
+ * an Automation grant.
22
+ * - **No account allowlist.** Contacts are unioned across accounts precisely so
23
+ * that a handle resolves wherever its owner lives; scoping that by account
24
+ * would reintroduce the bug this surface exists to avoid.
25
+ */
26
+ declare const ConfigSchema: z.ZodObject<{
27
+ allowWrites: z.ZodDefault<z.ZodBoolean>;
28
+ debug: z.ZodDefault<z.ZodBoolean>;
29
+ osascriptPath: z.ZodDefault<z.ZodString>;
30
+ osascriptTimeoutMs: z.ZodDefault<z.ZodNumber>;
31
+ maxResults: z.ZodDefault<z.ZodNumber>;
32
+ storePath: z.ZodOptional<z.ZodString>;
33
+ indexMode: z.ZodDefault<z.ZodEnum<{
34
+ auto: "auto";
35
+ immutable: "immutable";
36
+ off: "off";
37
+ ro: "ro";
38
+ }>>;
39
+ phoneSuffixDigits: z.ZodDefault<z.ZodNumber>;
40
+ }, z.core.$strict>;
41
+ type Config = z.infer<typeof ConfigSchema>;
42
+ declare const loadConfig: (env?: NodeJS.ProcessEnv) => Config;
43
+ //#endregion
44
+ //#region src/client/locate.d.ts
45
+ /**
46
+ * Find Contacts' stores — plural, which is the whole point of this file.
47
+ *
48
+ * Every other surface in this repo has one store. Contacts has one per account
49
+ * plus a root database, and `docs/contacts.md` measured what that means:
50
+ *
51
+ * AddressBook-v22.abcddb 1 contact
52
+ * Sources/<uuid>/AddressBook-v22.abcddb 420 contacts
53
+ *
54
+ * The obvious path — the one at the top of the directory — is present, readable,
55
+ * correctly shaped, and empty. A server that opens it gets a working database
56
+ * with nobody in it, which fails no check and returns no answer. That is not a
57
+ * hypothetical: `scripts/probe-contacts.mjs` did exactly this and reported a
58
+ * confident 0% resolution rate before anyone noticed.
59
+ *
60
+ * So there is no "the" store here. Everything readable is opened and the rows
61
+ * are unioned, and the number of sources is discovered rather than assumed —
62
+ * one on the probed machine, more with Google or Exchange accounts.
63
+ */
64
+ /** `~/Library/Application Support/AddressBook`. */
65
+ declare const ADDRESSBOOK_DIR: string;
66
+ /** The per-account subdirectory. Each child holds one database. */
67
+ declare const SOURCES_DIRNAME = "Sources";
68
+ /** Constant on every store, root and source alike. */
69
+ declare const STORE_FILENAME = "AddressBook-v22.abcddb";
70
+ type StoreCandidate = StoreFacts & {
71
+ path: string;
72
+ /** `root` for the top-level database, else the source directory name. */
73
+ label: string;
74
+ };
75
+ type LocateResult = {
76
+ dirPath: string;
77
+ dirListable: boolean;
78
+ /** Every store-shaped file found, root first. */
79
+ candidates: StoreCandidate[];
80
+ /** The subset that can actually be opened. May be empty. */
81
+ readable: StoreCandidate[];
82
+ /** How many `Sources/*` directories were seen, readable or not. */
83
+ sourceCount: number;
84
+ reason: string | null;
85
+ };
86
+ declare const defaultDirPath: (home?: string) => string;
87
+ declare const locateStores: (opts?: {
88
+ storePath?: string | undefined;
89
+ home?: string;
90
+ }) => LocateResult;
91
+ //#endregion
92
+ //#region src/client/phone.d.ts
93
+ /**
94
+ * Phone number matching, which on this surface is the whole product.
95
+ *
96
+ * Contacts stores what the user typed. `docs/contacts.md` measured a 400-row
97
+ * sample of `ZFULLNUMBER`: 222 formatted (`06 12 34 56 78`), 159 already E.164,
98
+ * 15 bare digits. Messages, meanwhile, stores a handle as E.164 and nothing
99
+ * else. So the two never meet as strings, and the measurement says so with an
100
+ * unusually blunt number: **exact string equality resolves 3.7% of message
101
+ * traffic.** A resolver that joins on the stored value is not slightly wrong, it
102
+ * is useless.
103
+ *
104
+ * What works is a SUFFIX. No prefix rule connects `06…` to `+336…` without
105
+ * knowing the user's country, which nothing here has any business guessing, but
106
+ * the two agree from the ninth digit back.
107
+ */
108
+ /** Everything that is not a digit, removed. The base of every key below. */
109
+ declare const digitsOf: (value: string) => string;
110
+ /**
111
+ * How many trailing digits make a key. Nine, measured rather than picked.
112
+ *
113
+ * | key | recent traffic resolved | ambiguous |
114
+ * | -------- | ----------------------- | --------- |
115
+ * | exact | 8.5% | 1 |
116
+ * | 10 | 96.7% | 5 |
117
+ * | **9** | **97.6%** | **6** |
118
+ * | 7 | 97.6% | 6 |
119
+ *
120
+ * Seven ties nine on every column measured, so nine wins on the tie-break that
121
+ * matters: a shorter key can only ever collide more. Ten is where French
122
+ * national numbers (`0612345678`, ten digits) stop lining up with the same
123
+ * number in E.164 (`+33612345678`, eleven) — which is exactly why 10 does no
124
+ * better than plain digits and 9 does.
125
+ */
126
+ declare const SUFFIX_DIGITS = 9;
127
+ declare const isShortcode: (value: string) => boolean;
128
+ /**
129
+ * The lookup key, or `null` when the value is too short to make one.
130
+ *
131
+ * Returning `null` rather than a short key is deliberate: a three-digit key
132
+ * would match any number ending in those digits, which is the failure mode this
133
+ * whole module exists to avoid.
134
+ */
135
+ declare const suffixKey: (value: string, digits?: number) => string | null;
136
+ /** Email keys are simply case-folded. Measured: 37 of 60 resolve, none ambiguous. */
137
+ declare const emailKey: (value: string) => string;
138
+ type HandleKind = "phone" | "email" | "shortcode";
139
+ /**
140
+ * What kind of thing a Messages handle is.
141
+ *
142
+ * Order matters: `@` decides first, because an email address can contain digits
143
+ * and a phone number can never contain an `@`.
144
+ */
145
+ declare const handleKind: (handle: string) => HandleKind;
146
+ //#endregion
147
+ //#region src/client/store.d.ts
148
+ type StoreCapabilities = {
149
+ fingerprint: string;
150
+ recordColumns: Set<string>;
151
+ phoneColumns: Set<string>;
152
+ emailColumns: Set<string>;
153
+ /** `Z_ENT` values that mean a person. Empty means the filter could not be built. */
154
+ contactEntities: number[];
155
+ hasPhones: boolean;
156
+ hasEmails: boolean;
157
+ hasNotes: boolean;
158
+ epochOffset: number;
159
+ };
160
+ type IndexContact = {
161
+ recordPk: number;
162
+ /** Stable across runs; the ref is built from it. */
163
+ uniqueId: string | null;
164
+ firstName: string | null;
165
+ lastName: string | null;
166
+ nickname: string | null;
167
+ organization: string | null;
168
+ jobTitle: string | null;
169
+ /** Assembled below — never a raw column, because no single column holds it. */
170
+ displayName: string;
171
+ /** Which store this came from, so a duplicate across accounts is explicable. */
172
+ source: string;
173
+ linkId: number | null;
174
+ isMe: boolean;
175
+ };
176
+ type ContactPhone = {
177
+ recordPk: number;
178
+ value: string;
179
+ label: string | null;
180
+ };
181
+ type ContactEmail = {
182
+ recordPk: number;
183
+ value: string;
184
+ label: string | null;
185
+ };
186
+ /**
187
+ * A name to show, assembled from whatever the record actually carries.
188
+ *
189
+ * Falls through deliberately: plenty of real contacts are an organisation with
190
+ * no person name (a garage, a doctor's office), and plenty are a first name
191
+ * alone. Returning an empty string would put a blank where a sender should be,
192
+ * so the last resort is explicit.
193
+ */
194
+ declare const displayNameOf: (c: {
195
+ firstName: string | null;
196
+ lastName: string | null;
197
+ nickname: string | null;
198
+ organization: string | null;
199
+ }) => string;
200
+ /** One opened database, with what was learned about it. */
201
+ type Shard = {
202
+ db: DatabaseSync;
203
+ mode: string;
204
+ caps: StoreCapabilities;
205
+ path: string;
206
+ label: string;
207
+ contacts: number;
208
+ };
209
+ declare class ContactsIndex {
210
+ #private;
211
+ readonly shards: readonly Shard[];
212
+ constructor(shards: readonly Shard[]);
213
+ /** Every shard's fingerprint. More than one distinct value is worth showing. */
214
+ get fingerprints(): string[];
215
+ get totalContacts(): number;
216
+ /** Every contact, across every shard. */
217
+ list(limit: number): IndexContact[];
218
+ /**
219
+ * Name search.
220
+ *
221
+ * `LIKE ? ESCAPE '\'` with core's `escapeLike`, so a contact called "100%
222
+ * Design" can be searched for literally instead of matching everyone.
223
+ */
224
+ search(query: string, limit: number): IndexContact[];
225
+ byPk(shardLabel: string, recordPk: number): IndexContact | null;
226
+ phonesFor(shardLabel: string, recordPks: readonly number[]): ContactPhone[];
227
+ emailsFor(shardLabel: string, recordPks: readonly number[]): ContactEmail[];
228
+ /**
229
+ * The resolver index: every phone suffix and every email, keyed to a contact.
230
+ *
231
+ * Built in one pass over every shard because a handle does not know which
232
+ * account its owner lives in. A key mapping to more than one DISTINCT contact
233
+ * is kept as such — see `resolve.ts`, which reports ambiguity rather than
234
+ * picking. `docs/contacts.md` measured six such collisions at nine digits, and
235
+ * twenty-eight at four.
236
+ */
237
+ buildLookup(suffixDigits?: number): HandleLookup;
238
+ close(): void;
239
+ }
240
+ type HandleLookup = {
241
+ /** Phone suffix → the contacts carrying it. Key length is `suffixDigits`. */
242
+ byPhone: Map<string, Set<string>>;
243
+ /** Case-folded address → the contacts carrying it. */
244
+ byEmail: Map<string, Set<string>>;
245
+ /** `"<shard>:<pk>"` → the contact. */
246
+ contacts: Map<string, IndexContact>;
247
+ /** How the phone keys were built. Queries MUST use the same length. */
248
+ suffixDigits: number;
249
+ };
250
+ declare const introspect: (db: DatabaseSync) => StoreCapabilities;
251
+ /** Count the people in one opened shard, with the entity filter applied. */
252
+ declare const countContacts: (db: DatabaseSync, caps: StoreCapabilities) => number;
253
+ declare const openShard: (path: string, label: string, mode: ReadOnlyMode, logger?: Logger) => Shard | null;
254
+ //#endregion
255
+ //#region src/client/resolve.d.ts
256
+ /**
257
+ * Turn Messages handles into names.
258
+ *
259
+ * This is the function `packages/messages` exists to call, and the reason
260
+ * Contacts was probed at all: `chat.db` records a correspondent as
261
+ * `+15551234567` and nothing else, so a Messages server without this answers
262
+ * "+15551234567 said …", which is complete and useless.
263
+ *
264
+ * ## What the measurement says it must do
265
+ *
266
+ * `docs/contacts.md` measured resolution against a real 958-handle store two
267
+ * ways, and the gap between them is the whole design:
268
+ *
269
+ * | denominator | resolved |
270
+ * | -------------------------- | -------- |
271
+ * | every handle ever seen | 27.6% |
272
+ * | messages in the last year | 97.6% |
273
+ * | the 25 busiest correspondents | 84% |
274
+ *
275
+ * The first number is a fact about the address book — 321 handles sent exactly
276
+ * one message, ever — not about this resolver. The last one is the one that
277
+ * shapes the API: **about one in six of the busiest correspondents does not
278
+ * resolve.** So `unknown` is a normal, expected, first-class outcome. It is not
279
+ * an error, it must not throw, and a caller that treats it as a failure will be
280
+ * wrong several times on any real inbox.
281
+ */
282
+ type ResolutionStatus =
283
+ /** Exactly one contact carries this handle. */
284
+ "resolved" |
285
+ /** Nobody does. Normal — see above. */
286
+ "unknown" |
287
+ /**
288
+ * More than one distinct contact does.
289
+ *
290
+ * Reported rather than resolved by picking a winner. Six handles collided at
291
+ * nine digits on the probed store, and the failure mode of guessing is putting
292
+ * one person's name on another person's messages — which is worse than no name
293
+ * at all, because it is not visibly wrong.
294
+ */
295
+ "ambiguous" |
296
+ /** A shortcode: a bank, a courier, a 2FA sender. Can never be a contact. */
297
+ "shortcode";
298
+ type ResolvedHandle = {
299
+ handle: string;
300
+ kind: HandleKind;
301
+ status: ResolutionStatus;
302
+ /** The name to show. Null unless `status` is `resolved`. */
303
+ name: string | null;
304
+ contact: IndexContact | null;
305
+ /** How many distinct contacts matched. 0, 1, or more. */
306
+ matches: number;
307
+ };
308
+ declare const resolveHandle: (handle: string, lookup: HandleLookup) => ResolvedHandle;
309
+ declare const resolveHandles: (handles: readonly string[], lookup: HandleLookup) => ResolvedHandle[];
310
+ /** Counts by status, for a caller that wants to report coverage honestly. */
311
+ declare const summarise: (results: readonly ResolvedHandle[]) => Record<ResolutionStatus, number>;
312
+ //#endregion
313
+ //#region src/client/contacts.d.ts
314
+ /**
315
+ * Contacts' one lane, orchestrated.
316
+ *
317
+ * There is no lane *choice* here, which is what makes this the smallest client
318
+ * in the repo: no Apple Events fallback, no write path, no cache TTL. What it
319
+ * does own is the two things the store is awkward about — opening several
320
+ * databases instead of one, and building the resolver index lazily, because
321
+ * building it walks every contact and every phone row and most callers only
322
+ * want to list a few names.
323
+ */
324
+ type CreateClientOptions = {
325
+ config: Config;
326
+ logger?: Logger;
327
+ /** Injected by tests so nothing spawns a process or touches real Contacts. */
328
+ osascript?: OsascriptRunner;
329
+ /** Injected by tests. */
330
+ home?: string;
331
+ };
332
+ /** The scalar fields a write may set. Absent means "leave alone". */
333
+ type ContactFields = {
334
+ firstName?: string | null;
335
+ lastName?: string | null;
336
+ nickname?: string | null;
337
+ organization?: string | null;
338
+ jobTitle?: string | null;
339
+ department?: string | null;
340
+ note?: string | null;
341
+ company?: boolean;
342
+ };
343
+ type LabelledValue = {
344
+ label?: string;
345
+ value: string;
346
+ };
347
+ /** What a write reports: what Contacts stored, re-read after the save. */
348
+ type WriteResult = {
349
+ ref: string | null;
350
+ personId: string | null;
351
+ name: string | null;
352
+ organization: string | null;
353
+ phones: {
354
+ label: string | null;
355
+ value: string | null;
356
+ }[];
357
+ emails: {
358
+ label: string | null;
359
+ value: string | null;
360
+ }[];
361
+ source: "apple-events";
362
+ };
363
+ type LaneStatus = {
364
+ located: LocateResult;
365
+ /** One row per store that opened. */
366
+ shards: {
367
+ label: string;
368
+ path: string;
369
+ mode: string;
370
+ contacts: number;
371
+ fingerprint: string;
372
+ }[];
373
+ totalContacts: number;
374
+ indexMode: string;
375
+ };
376
+ type ContactDetail = IndexContact & {
377
+ phones: {
378
+ value: string;
379
+ label: string | null;
380
+ }[];
381
+ emails: {
382
+ value: string;
383
+ label: string | null;
384
+ }[];
385
+ };
386
+ declare class AppleContactsClient {
387
+ #private;
388
+ constructor(opts: CreateClientOptions);
389
+ get config(): Config;
390
+ located(): LocateResult;
391
+ /**
392
+ * Open every readable store, once.
393
+ *
394
+ * `indexMode: "off"` is honoured as a hard no — it is what the test suite uses
395
+ * so that a machine WITH the grant does not silently read the developer's own
396
+ * address book and pass or fail on data nobody wrote.
397
+ */
398
+ index(): ContactsIndex | null;
399
+ list(limit?: number): IndexContact[];
400
+ search(query: string, limit?: number): IndexContact[];
401
+ /** One contact with its phone numbers and email addresses. */
402
+ get(source: string, recordPk: number): ContactDetail | null;
403
+ /**
404
+ * Built on first use and kept.
405
+ *
406
+ * Walking every contact and every phone row is cheap once (970 rows on the
407
+ * probed store) and pointless per call. There is no TTL: this process does not
408
+ * write to Contacts, and a server that has been running while the user edited
409
+ * their address book is not the case worth optimising for. `diagnostics`
410
+ * reports when it was built.
411
+ */
412
+ lookup(): HandleLookup;
413
+ /** The function `packages/messages` is meant to call. */
414
+ resolve(handles: readonly string[]): {
415
+ results: ResolvedHandle[];
416
+ summary: Record<string, number>;
417
+ };
418
+ createContact(input: {
419
+ fields: ContactFields;
420
+ phones?: readonly LabelledValue[];
421
+ emails?: readonly LabelledValue[];
422
+ }): Promise<WriteResult>;
423
+ updateContact(input: {
424
+ personId: string;
425
+ fields: ContactFields;
426
+ phones?: readonly LabelledValue[];
427
+ emails?: readonly LabelledValue[];
428
+ }): Promise<WriteResult>;
429
+ status(): LaneStatus;
430
+ close(): void;
431
+ }
432
+ //#endregion
433
+ //#region src/client/errors.d.ts
434
+ declare const CONTACTS_SURFACE: SurfaceContext;
435
+ /**
436
+ * Contacts' Apple Events target — and, like Calendar's `com.apple.iCal`, not the
437
+ * display name. Contacts.app kept the id it shipped with as Address Book;
438
+ * `com.apple.Contacts` does not exist.
439
+ *
440
+ * Used by the write lane only. Reads never send an Apple Event.
441
+ */
442
+ declare const CONTACTS_BUNDLE_ID = "com.apple.AddressBook";
443
+ /** A contact ref no longer resolves — deleted, or its account was removed. */
444
+ declare class ContactNotFoundError extends AppleAutomationError {
445
+ readonly name = "ContactNotFoundError";
446
+ constructor(ref: string);
447
+ }
448
+ /**
449
+ * The store could not be read, with the reason spelled out.
450
+ *
451
+ * Its own error because Contacts fails differently from every other surface in
452
+ * this repo: it sits behind its own TCC service rather than behind Full Disk
453
+ * Access, and unlike Full Disk Access that permission PROMPTS. So the fix is
454
+ * usually "answer the dialog", not "go to System Settings" — and telling
455
+ * somebody to grant whole-disk access for an address book would be asking for
456
+ * far more than this server needs.
457
+ */
458
+ declare class ContactsUnavailableError extends AppleAutomationError {
459
+ readonly name = "ContactsUnavailableError";
460
+ constructor(reason: string);
461
+ }
462
+ //#endregion
463
+ //#region src/client/ref.d.ts
464
+ /**
465
+ * `k1:<account>/<recordPk>` — an opaque handle for one contact.
466
+ *
467
+ * ## Why the account rides along
468
+ *
469
+ * Because the store is plural. A record's `Z_PK` is a rowid, and rowids are only
470
+ * unique WITHIN one database — the root store and each account store number
471
+ * their rows from 1 independently. A bare pk would therefore resolve to a
472
+ * different person depending on which store happened to be read first, which is
473
+ * the kind of bug that produces a plausible wrong answer rather than an error.
474
+ *
475
+ * ## Why not `ZUNIQUEID`
476
+ *
477
+ * It exists and is stable, but it is not what the child tables join on —
478
+ * `ZABCDPHONENUMBER.ZOWNER` points at `Z_PK`. Carrying the pk means a `get`
479
+ * needs no extra lookup, and the account prefix supplies the uniqueness the pk
480
+ * lacks. `uniqueId` is still returned on results for callers that want a
481
+ * durable identity across a re-index.
482
+ *
483
+ * ## Why `k1`
484
+ *
485
+ * `c1:` is Calendar's and `r1:` is Reminders'; `k1` is free and the version
486
+ * prefix keeps a future scheme change additive rather than a silent
487
+ * reinterpretation of refs already sitting in a conversation.
488
+ */
489
+ declare const REF_VERSION = "k1";
490
+ type ContactRef = {
491
+ source: string;
492
+ recordPk: number;
493
+ };
494
+ declare class InvalidContactRefError extends AppleAutomationError {
495
+ readonly name = "InvalidContactRefError";
496
+ constructor(raw: string);
497
+ }
498
+ declare const encodeRef: (source: string, recordPk: number) => string;
499
+ declare const decodeRef: (raw: string) => ContactRef;
500
+ //#endregion
501
+ //#region src/server.d.ts
502
+ declare const SERVER_NAME: string;
503
+ declare const SERVER_VERSION: string;
504
+ type CreateServerOptions = {
505
+ config: Config;
506
+ logger?: Logger;
507
+ /** Injected by tests so nothing spawns a process or touches real Contacts. */
508
+ osascript?: OsascriptRunner;
509
+ /** Injected by tests so discovery never reaches the developer's real home. */
510
+ home?: string;
511
+ };
512
+ type CreatedServer = {
513
+ server: McpServer;
514
+ client: AppleContactsClient;
515
+ };
516
+ /**
517
+ * Build the server. Side-effect free: it opens no database and reads no file,
518
+ * so a test can construct it freely and every external dependency arrives
519
+ * through an option.
520
+ */
521
+ declare const createServer: (opts: CreateServerOptions) => CreatedServer;
522
+ //#endregion
523
+ //#region src/tools/index.d.ts
524
+ type ToolContext = {
525
+ /**
526
+ * Register the mutating tools too. Off by default — with the flag off they are
527
+ * not merely refused, they are invisible and cannot be called at all, because
528
+ * MCP clients cache the tool list and a tool that exists and says no is one
529
+ * the model will keep trying.
530
+ */
531
+ allowWrites: boolean;
532
+ };
533
+ /**
534
+ * Register the Apple Contacts tools.
535
+ *
536
+ * READS are file-lane and ask for no Automation grant. WRITES are Apple Events,
537
+ * always — the store is opened `PRAGMA query_only` because Contacts owns it and
538
+ * reconciles it against iCloud, so writing to it would corrupt sync state.
539
+ *
540
+ * That split has a cost worth stating plainly: this surface used to need no
541
+ * Automation grant at all, which docs/distribution.md calls the strongest
542
+ * argument for file-first. Turning writes on gives that up — the first write
543
+ * prompts for permission to control Contacts. With `allowWrites` off, nothing
544
+ * here ever sends an Apple Event and the old property still holds.
545
+ *
546
+ * There is no delete. Contacts' scripting dictionary has no delete command of
547
+ * any kind; see `client/jxa/core.ts` for the measurement.
548
+ *
549
+ * The registered set does NOT vary with whether the store is readable. That is a
550
+ * runtime condition which can change while the process lives, and MCP clients
551
+ * cache the tool list, so a tool that appeared and disappeared would leave
552
+ * clients calling names the server no longer has. Tools that need the store
553
+ * report what is missing instead.
554
+ */
555
+ declare const registerTools: (server: McpServer, client: AppleContactsClient, ctx: ToolContext) => void;
556
+ //#endregion
557
+ export { ADDRESSBOOK_DIR, AppleContactsClient, AppleContactsError, BUILD_INFO, type BuildInfo, CONTACTS_BUNDLE_ID, CONTACTS_SURFACE, type Config, type ContactDetail, ContactNotFoundError, type ContactRef, ContactsIndex, ContactsUnavailableError, type CreateClientOptions, type CreateServerOptions, type HandleKind, type HandleLookup, type IndexContact, IndexUnavailableError, InvalidContactRefError, type LaneStatus, type LocateResult, REF_VERSION, type ResolutionStatus, type ResolvedHandle, SERVER_NAME, SERVER_VERSION, SOURCES_DIRNAME, STORE_FILENAME, SUFFIX_DIGITS, SchemaDriftError, type Shard, type StoreCandidate, type StoreCapabilities, type ToolContext, countContacts, createServer, decodeRef, defaultDirPath, digitsOf, displayNameOf, emailKey, encodeRef, handleKind, introspect, isShortcode, loadConfig, locateStores, openShard, registerTools, resolveHandle, resolveHandles, suffixKey, summarise };
558
+ //# sourceMappingURL=index.d.ts.map