@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 +21 -0
- package/README.md +70 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +29 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +558 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/server-BjWSCaMN.js +1480 -0
- package/dist/server-BjWSCaMN.js.map +1 -0
- package/package.json +68 -0
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
|
package/dist/cli.js.map
ADDED
|
@@ -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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|