apple-tools-mcp 1.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Contacts write operations: add, edit, remove.
3
+ *
4
+ * Contacts are addressed by their AddressBook unique id (Contacts.app's
5
+ * `id`, for example "ABCD1234-...:ABPerson"), which `contacts_search` and
6
+ * `contacts_lookup` report as "Contact ID" and `contacts_add` returns.
7
+ *
8
+ * Writes go through Contacts.app rather than the AddressBook sqlite file:
9
+ * writing that database directly corrupts iCloud sync.
10
+ */
11
+
12
+ import { runAppleScript, asString, CONTACTS_TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
13
+ import {
14
+ planWrite,
15
+ normalizeList,
16
+ isEmailAddress,
17
+ isPhoneNumber,
18
+ validateContactId,
19
+ validateSubject,
20
+ validateLabel,
21
+ writeErrorMessage,
22
+ writeSuccessMessage,
23
+ isFlagTrue,
24
+ truncate
25
+ } from "./writeGuards.js";
26
+
27
+ const MAX_VALUES_PER_FIELD = 10;
28
+
29
+ function validateContactEmails(value, label) {
30
+ const emails = normalizeList(value);
31
+ if (emails.length > MAX_VALUES_PER_FIELD) {
32
+ return { emails: [], error: `at most ${MAX_VALUES_PER_FIELD} email addresses per contact` };
33
+ }
34
+ const bad = emails.filter((e) => !isEmailAddress(e));
35
+ if (bad.length > 0) {
36
+ return { emails: [], error: `invalid email address(es): ${bad.map((e) => truncate(e, 40)).join(", ")}` };
37
+ }
38
+ const resolvedLabel = validateLabel(label, "work");
39
+ if (!resolvedLabel) return { emails: [], error: "email_label must be letters and spaces only" };
40
+ return { emails: emails.map((email) => ({ value: email, label: resolvedLabel })), error: null };
41
+ }
42
+
43
+ function validateContactPhones(value, label) {
44
+ const phones = normalizeList(value);
45
+ if (phones.length > MAX_VALUES_PER_FIELD) {
46
+ return { phones: [], error: `at most ${MAX_VALUES_PER_FIELD} phone numbers per contact` };
47
+ }
48
+ const bad = phones.filter((p) => !isPhoneNumber(p));
49
+ if (bad.length > 0) {
50
+ return { phones: [], error: `invalid phone number(s): ${bad.map((p) => truncate(p, 40)).join(", ")}` };
51
+ }
52
+ const resolvedLabel = validateLabel(label, "mobile");
53
+ if (!resolvedLabel) return { phones: [], error: "phone_label must be letters and spaces only" };
54
+ return { phones: phones.map((phone) => ({ value: phone, label: resolvedLabel })), error: null };
55
+ }
56
+
57
+ function findPersonHandler() {
58
+ return `on atmFindPerson(theId)
59
+ tell application "Contacts"
60
+ try
61
+ return person id theId
62
+ end try
63
+ try
64
+ set hits to (every person whose id is theId)
65
+ if (count of hits) > 0 then return item 1 of hits
66
+ end try
67
+ end tell
68
+ error "CONTACT_NOT_FOUND"
69
+ end atmFindPerson`;
70
+ }
71
+
72
+ function childLines(items, kind, personVar) {
73
+ return items
74
+ .map((item) => ` make new ${kind} at end of ${kind}s of ${personVar} with properties {label:${asString(item.label)}, value:${asString(item.value)}}`)
75
+ .join("\n");
76
+ }
77
+
78
+ function failure(action, summary, result, secrets = []) {
79
+ if (result.kind === "tcc") {
80
+ return `${action} failed — attempted to ${summary}. ${CONTACTS_TCC_GUIDANCE}`;
81
+ }
82
+ const raw = String(result.error || "");
83
+ if (raw.includes("CONTACT_NOT_FOUND")) {
84
+ return `${action} failed — attempted to ${summary}. No contact with that id was found; use the Contact ID from contacts_search or contacts_lookup.`;
85
+ }
86
+ if (result.kind === "attribution") {
87
+ return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
88
+ }
89
+ if (result.kind === "app_unavailable") {
90
+ return `${action} failed — attempted to ${summary}. Contacts.app could not be reached on this host.`;
91
+ }
92
+ return writeErrorMessage(action, summary, new Error(raw || "unknown error"), secrets);
93
+ }
94
+
95
+ export function buildAddContactScript({ properties, emails, phones }) {
96
+ const props = Object.entries(properties)
97
+ .map(([key, value]) => `${key}:${asString(value)}`)
98
+ .join(", ");
99
+
100
+ return `tell application "Contacts"
101
+ set newPerson to make new person with properties {${props}}
102
+ ${emails.length ? `${childLines(emails, "email", "newPerson")}\n` : ""}${phones.length ? `${childLines(phones, "phone", "newPerson")}\n` : ""} save
103
+ set newId to id of newPerson
104
+ end tell
105
+ return newId`;
106
+ }
107
+
108
+ export function contactsAdd(args = {}) {
109
+ const action = "contacts_add";
110
+
111
+ const firstName = validateSubject(args.first_name);
112
+ if (firstName.error) return { ok: false, message: `${action} refused: ${firstName.error.replace("subject", "first_name")}` };
113
+ const lastName = validateSubject(args.last_name);
114
+ if (lastName.error) return { ok: false, message: `${action} refused: ${lastName.error.replace("subject", "last_name")}` };
115
+ const organization = validateSubject(args.organization);
116
+ if (organization.error) return { ok: false, message: `${action} refused: ${organization.error.replace("subject", "organization")}` };
117
+ const jobTitle = validateSubject(args.job_title);
118
+ if (jobTitle.error) return { ok: false, message: `${action} refused: ${jobTitle.error.replace("subject", "job_title")}` };
119
+
120
+ if (!firstName.text && !lastName.text && !organization.text) {
121
+ return { ok: false, message: `${action} refused: provide at least first_name, last_name, or organization.` };
122
+ }
123
+
124
+ const emails = validateContactEmails(args.emails, args.email_label);
125
+ if (emails.error) return { ok: false, message: `${action} refused: ${emails.error}` };
126
+ const phones = validateContactPhones(args.phones, args.phone_label);
127
+ if (phones.error) return { ok: false, message: `${action} refused: ${phones.error}` };
128
+
129
+ const displayName = [firstName.text, lastName.text].filter(Boolean).join(" ") || organization.text;
130
+ const summary = `create contact "${truncate(displayName, 100)}"` +
131
+ `${emails.emails.length ? ` with ${emails.emails.length} email(s)` : ""}` +
132
+ `${phones.phones.length ? ` and ${phones.phones.length} phone number(s)` : ""}`;
133
+
134
+ const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
135
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
136
+
137
+ const properties = {};
138
+ if (firstName.text) properties["first name"] = firstName.text;
139
+ if (lastName.text) properties["last name"] = lastName.text;
140
+ if (organization.text) properties.organization = organization.text;
141
+ if (jobTitle.text) properties["job title"] = jobTitle.text;
142
+
143
+ const result = runAppleScript(
144
+ buildAddContactScript({ properties, emails: emails.emails, phones: phones.phones }),
145
+ { timeout: 60000, appName: "Contacts" }
146
+ );
147
+ if (!result.ok) return { ok: false, message: failure(action, summary, result) };
148
+
149
+ return {
150
+ ok: true,
151
+ message: writeSuccessMessage(action, "contact created", {
152
+ contact_id: result.output,
153
+ name: displayName,
154
+ emails: emails.emails.map((e) => e.value).join(", ") || undefined,
155
+ phones: phones.phones.map((p) => p.value).join(", ") || undefined
156
+ })
157
+ };
158
+ }
159
+
160
+ export function buildEditContactScript({ contactId, properties, emails, phones, replaceEmails, replacePhones }) {
161
+ const lines = Object.entries(properties).map(([key, value]) => ` set ${key} of thePerson to ${asString(value)}`);
162
+ if (replaceEmails) {
163
+ lines.push(` try
164
+ delete every email of thePerson
165
+ end try`);
166
+ }
167
+ if (replacePhones) {
168
+ lines.push(` try
169
+ delete every phone of thePerson
170
+ end try`);
171
+ }
172
+
173
+ return `${findPersonHandler()}
174
+
175
+ set thePerson to atmFindPerson(${asString(contactId)})
176
+ tell application "Contacts"
177
+ ${lines.join("\n")}
178
+ ${emails.length ? `${childLines(emails, "email", "thePerson")}\n` : ""}${phones.length ? `${childLines(phones, "phone", "thePerson")}\n` : ""} save
179
+ set editedName to name of thePerson
180
+ end tell
181
+ return editedName`;
182
+ }
183
+
184
+ export function contactsEdit(args = {}) {
185
+ const action = "contacts_edit";
186
+
187
+ const contactId = validateContactId(args.contact_id);
188
+ if (!contactId) {
189
+ return { ok: false, message: `${action} refused: contact_id is required (the "Contact ID" from contacts_search or contacts_lookup). This tool will not guess which contact you meant.` };
190
+ }
191
+
192
+ const properties = {};
193
+ const changed = [];
194
+
195
+ const fieldMap = {
196
+ first_name: "first name",
197
+ last_name: "last name",
198
+ organization: "organization",
199
+ job_title: "job title"
200
+ };
201
+ for (const [arg, applescriptProp] of Object.entries(fieldMap)) {
202
+ if (args[arg] === undefined) continue;
203
+ const validated = validateSubject(args[arg]);
204
+ if (validated.error) return { ok: false, message: `${action} refused: ${validated.error.replace("subject", arg)}` };
205
+ properties[applescriptProp] = validated.text;
206
+ changed.push(arg);
207
+ }
208
+
209
+ const emails = validateContactEmails(args.emails, args.email_label);
210
+ if (emails.error) return { ok: false, message: `${action} refused: ${emails.error}` };
211
+ const phones = validateContactPhones(args.phones, args.phone_label);
212
+ if (phones.error) return { ok: false, message: `${action} refused: ${phones.error}` };
213
+
214
+ const replaceEmails = isFlagTrue(args.replace_emails);
215
+ const replacePhones = isFlagTrue(args.replace_phones);
216
+ if (emails.emails.length) changed.push(`${replaceEmails ? "replace" : "add"} ${emails.emails.length} email(s)`);
217
+ if (phones.phones.length) changed.push(`${replacePhones ? "replace" : "add"} ${phones.phones.length} phone(s)`);
218
+ if (replaceEmails && emails.emails.length === 0) changed.push("remove all emails");
219
+ if (replacePhones && phones.phones.length === 0) changed.push("remove all phones");
220
+
221
+ if (changed.length === 0) {
222
+ return { ok: false, message: `${action} refused: nothing to change. Pass at least one of first_name, last_name, organization, job_title, emails, phones.` };
223
+ }
224
+
225
+ const summary = `update contact ${contactId}: ${changed.join(", ")}`;
226
+ // Dropping every email or phone is destructive, so it needs confirmation.
227
+ const destructive = (replaceEmails && emails.emails.length === 0) || (replacePhones && phones.phones.length === 0);
228
+ const plan = planWrite({
229
+ action,
230
+ summary,
231
+ destructive,
232
+ dryRun: isFlagTrue(args.dry_run),
233
+ confirm: isFlagTrue(args.confirm)
234
+ });
235
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
236
+
237
+ const result = runAppleScript(
238
+ buildEditContactScript({
239
+ contactId,
240
+ properties,
241
+ emails: emails.emails,
242
+ phones: phones.phones,
243
+ replaceEmails,
244
+ replacePhones
245
+ }),
246
+ { timeout: 60000, appName: "Contacts" }
247
+ );
248
+ if (!result.ok) return { ok: false, message: failure(action, summary, result) };
249
+
250
+ return {
251
+ ok: true,
252
+ message: writeSuccessMessage(action, "contact updated", {
253
+ contact_id: contactId,
254
+ name: truncate(result.output, 120) || undefined,
255
+ changed: changed.join(", ")
256
+ })
257
+ };
258
+ }
259
+
260
+ export function buildRemoveContactScript(contactId) {
261
+ return `${findPersonHandler()}
262
+
263
+ set thePerson to atmFindPerson(${asString(contactId)})
264
+ tell application "Contacts"
265
+ set removedName to name of thePerson
266
+ delete thePerson
267
+ save
268
+ end tell
269
+ return removedName`;
270
+ }
271
+
272
+ export function contactsRemove(args = {}) {
273
+ const action = "contacts_remove";
274
+
275
+ const contactId = validateContactId(args.contact_id);
276
+ if (!contactId) {
277
+ return { ok: false, message: `${action} refused: contact_id is required (the "Contact ID" from contacts_search or contacts_lookup). Deletes never run on a guessed id.` };
278
+ }
279
+
280
+ const summary = `delete contact ${contactId}`;
281
+ const plan = planWrite({
282
+ action,
283
+ summary,
284
+ destructive: true,
285
+ dryRun: isFlagTrue(args.dry_run),
286
+ confirm: isFlagTrue(args.confirm)
287
+ });
288
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
289
+
290
+ const result = runAppleScript(buildRemoveContactScript(contactId), { timeout: 60000, appName: "Contacts" });
291
+ if (!result.ok) return { ok: false, message: failure(action, summary, result) };
292
+
293
+ return {
294
+ ok: true,
295
+ message: writeSuccessMessage(action, "contact deleted", {
296
+ contact_id: contactId,
297
+ name: truncate(result.output, 120) || undefined
298
+ })
299
+ };
300
+ }