apple-tools-mcp 1.2.1 → 2.0.1

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/indexer.js CHANGED
@@ -1741,7 +1741,9 @@ export function localDayToMacBounds(startMs, endMs) {
1741
1741
  }
1742
1742
 
1743
1743
  // Date-bounded OccurrenceCache query: one row per occurrence, no GROUP BY ci.ROWID
1744
- export function buildEventsOnDateQuery(startMac, endMac) {
1744
+ // includeUid adds the iCal UID that the calendar write tools address events by.
1745
+ // Older Calendar schemas may lack the column, so callers can retry without it.
1746
+ export function buildEventsOnDateQuery(startMac, endMac, { includeUid = true } = {}) {
1745
1747
  const startBound = Math.floor(Number(startMac));
1746
1748
  const endBound = Math.floor(Number(endMac));
1747
1749
  if (!Number.isFinite(startBound) || !Number.isFinite(endBound)) {
@@ -1755,6 +1757,7 @@ export function buildEventsOnDateQuery(startMac, endMac) {
1755
1757
  return `
1756
1758
  SELECT DISTINCT
1757
1759
  ci.ROWID as itemId,
1760
+ ${includeUid ? "ci.unique_identifier as uid," : "'' as uid,"}
1758
1761
  ci.summary as title,
1759
1762
  datetime(CASE WHEN ci.all_day THEN ${allDayStartMac} ELSE ${timedStart} END + 978307200, 'unixepoch', 'localtime') as start,
1760
1763
  datetime(${occEnd} + 978307200, 'unixepoch', 'localtime') as end,
@@ -1821,9 +1824,11 @@ export function getEventsOnDate(startMs, endMs) {
1821
1824
  return { events: [], error: "Calendar database not found" };
1822
1825
  }
1823
1826
  const { startMac, endMac } = localDayToMacBounds(startMs, endMs);
1824
- const query = buildEventsOnDateQuery(startMac, endMac);
1825
1827
  let lastError;
1826
1828
  for (let attempt = 0; attempt < 3; attempt++) {
1829
+ // Drop the UID column after a failed first attempt: a schema without
1830
+ // unique_identifier must still return events for calendar_date.
1831
+ const query = buildEventsOnDateQuery(startMac, endMac, { includeUid: attempt === 0 });
1827
1832
  try {
1828
1833
  const events = withCalendarCopy((dbPath) => {
1829
1834
  return safeSqlite3Json(dbPath, query, { timeout: 15000 });
@@ -0,0 +1,416 @@
1
+ /**
2
+ * AppleScript execution helpers for the write tools.
3
+ *
4
+ * Scripts are always built from escaped literals (never string-concatenated
5
+ * shell) and run through `safeOsascript` (`spawnSync`, `shell: false`).
6
+ *
7
+ * This module also classifies macOS TCC (privacy) denials. TCC attributes an
8
+ * Apple event to the *responsible process*, which for a stdio MCP server is
9
+ * the app that launched node - not node itself. A host app without the
10
+ * Contacts/Calendars entitlements therefore makes the child's Apple events
11
+ * fail no matter what permissions node has. `lib/writeRouting.js` uses this
12
+ * classification to hand the work to the launchd-started indexer daemon,
13
+ * where node is the responsible process.
14
+ */
15
+
16
+ import fs from "fs";
17
+ import { safeOsascript } from "./shell.js";
18
+ import { escapeAppleScript } from "./validators.js";
19
+
20
+ export const DEFAULT_SCRIPT_TIMEOUT_MS = 30000;
21
+
22
+ // Sentinels raised by our scripts so callers can map them to clean errors
23
+ // instead of surfacing raw AppleScript text.
24
+ export const NOT_FOUND_SENTINELS = [
25
+ "MESSAGE_NOT_FOUND",
26
+ "EVENT_NOT_FOUND",
27
+ "EVENTKIT_NOT_FOUND",
28
+ "CONTACT_NOT_FOUND",
29
+ "CALENDAR_NOT_FOUND",
30
+ "CHAT_NOT_FOUND",
31
+ "ARCHIVE_MAILBOX_NOT_FOUND",
32
+ "ATTENDEE_NOT_FOUND",
33
+ "RSVP_NOT_SUPPORTED"
34
+ ];
35
+
36
+ // osascript / TCC denial signatures. Apple reports these as Apple event
37
+ // errors (-1743, -10004) or as "not permitted" / "not authorized" text.
38
+ const TCC_SIGNATURES = [
39
+ "-1743",
40
+ "-10004",
41
+ "-25211",
42
+ "not authorized to send apple events",
43
+ "not allowed to send apple events",
44
+ "is not allowed assistive access",
45
+ "operation not permitted",
46
+ "not permitted to access",
47
+ "access to contacts",
48
+ "access to calendars",
49
+ "privacy settings",
50
+ "errae eventnotpermitted",
51
+ "errAEEventNotPermitted".toLowerCase()
52
+ ];
53
+
54
+ /**
55
+ * These read like "the app is missing", but macOS also emits them when the
56
+ * responsible process may not drive the app at all: a denied Automation
57
+ * grant frequently surfaces as -1728 / "can't get application" rather than a
58
+ * clean -1743. When the app is installed, treat them as attribution
59
+ * failures, not as a missing app.
60
+ */
61
+ const APP_MISSING_SIGNATURES = [
62
+ "application isn't running",
63
+ "can't get application",
64
+ "can't get every application",
65
+ "application is not running",
66
+ "-600",
67
+ "-1728",
68
+ "-10810"
69
+ ];
70
+
71
+ // osascript itself is missing: not macOS, or a stripped PATH. Never an
72
+ // attribution problem, because nothing ran.
73
+ //
74
+ // Do **not** match a bare "spawnSync osascript" here. A TCC-denied Mail
75
+ // compose hangs until spawnSync times out (`spawnSync osascript ETIMEDOUT`);
76
+ // that string also contains "spawnSync osascript" and must not be reported
77
+ // as "Mail.app could not be reached".
78
+ const OSASCRIPT_MISSING_SIGNATURES = [
79
+ "spawnsync osascript enoent",
80
+ "enoent"
81
+ ];
82
+
83
+ // Hung Apple events. Mini diagnosis: `tell Mail to get name` returns, but
84
+ // `make new outgoing message` blocks until timeout when node → Mail
85
+ // Automation is denied. That hang is a TCC deny, not a missing app.
86
+ const TIMEOUT_SIGNATURES = [
87
+ "etimedout",
88
+ "timed out after",
89
+ "-1712",
90
+ "appleevent timed out",
91
+ "apple event timed out"
92
+ ];
93
+
94
+ /**
95
+ * Standard install locations for the apps this package automates.
96
+ * Ventura and later keep the first-party apps in /System/Applications.
97
+ */
98
+ const APP_SEARCH_DIRS = [
99
+ "/System/Applications",
100
+ "/Applications",
101
+ "/System/Applications/Utilities"
102
+ ];
103
+
104
+ /**
105
+ * @param {string} appName - e.g. "Contacts"
106
+ * @returns {boolean|null} null when we cannot tell (no app name given)
107
+ */
108
+ export function appBundleInstalled(appName, existsFn = fs.existsSync) {
109
+ if (!appName || typeof appName !== "string") return null;
110
+ if (!/^[A-Za-z ]{1,40}$/.test(appName)) return null;
111
+ return APP_SEARCH_DIRS.some((dir) => existsFn(`${dir}/${appName}.app`));
112
+ }
113
+
114
+ /**
115
+ * @param {string} message
116
+ * @param {object} [context]
117
+ * @param {boolean|null} [context.appInstalled] - whether the target app exists
118
+ * @returns {"tcc"|"timeout"|"not_found"|"attribution"|"app_unavailable"|"unknown"}
119
+ */
120
+ export function classifyAppleScriptError(message, context = {}) {
121
+ const text = String(message || "").toLowerCase();
122
+ for (const sentinel of NOT_FOUND_SENTINELS) {
123
+ if (text.includes(sentinel.toLowerCase())) return "not_found";
124
+ }
125
+ if (OSASCRIPT_MISSING_SIGNATURES.some((sig) => text.includes(sig))) return "app_unavailable";
126
+ // ETIMEDOUT / -1712 is a hang, not a TCC deny. Mail/Messages still map
127
+ // this kind to Automation guidance (compose hang = grant missing).
128
+ // calendar_remove must never print Calendar-denied copy for ETIMEDOUT alone.
129
+ if (TIMEOUT_SIGNATURES.some((sig) => text.includes(sig))) return "timeout";
130
+ if (TCC_SIGNATURES.some((sig) => text.includes(sig))) return "tcc";
131
+ // Calendar delete of a detached event specifier often returns
132
+ // "Can't get event … (-1728)". That is a missing object, not a missing app.
133
+ // Check before the generic -1728 / "can't get application" path.
134
+ if (
135
+ text.includes("can't get event") ||
136
+ text.includes("can't get calendar") ||
137
+ text.includes("can't get theevent")
138
+ ) {
139
+ return "not_found";
140
+ }
141
+ if (APP_MISSING_SIGNATURES.some((sig) => text.includes(sig))) {
142
+ // The app is on disk, so "can't get application" means macOS refused to
143
+ // let the responsible process drive it - an Automation / attribution
144
+ // problem, not a missing app.
145
+ return context.appInstalled === true ? "attribution" : "app_unavailable";
146
+ }
147
+ return "unknown";
148
+ }
149
+
150
+ export function isTccDenial(message) {
151
+ return classifyAppleScriptError(message) === "tcc";
152
+ }
153
+
154
+ /**
155
+ * Real Automation / privacy deny codes — not a hung Calendar.app delete.
156
+ *
157
+ * Mini calendar_remove: add/edit succeed (Automation granted), then delete
158
+ * hits spawnSync ETIMEDOUT / AppleEvent -1712. That is an iCloud/CalDAV
159
+ * hang or a confirmation dialog, not -1743 / -10004. Callers must not
160
+ * rewrite that as CALENDAR_TCC_GUIDANCE.
161
+ */
162
+ const HARD_TCC_SIGNATURES = [
163
+ "-1743",
164
+ "-10004",
165
+ "-25211",
166
+ "not authorized to send apple events",
167
+ "not allowed to send apple events",
168
+ "is not allowed assistive access",
169
+ "operation not permitted",
170
+ "not permitted to access",
171
+ "access to contacts",
172
+ "access to calendars",
173
+ "privacy settings",
174
+ "errae eventnotpermitted",
175
+ "errAEEventNotPermitted".toLowerCase()
176
+ ];
177
+
178
+ export function isHardTccDenial(message) {
179
+ const text = String(message || "").toLowerCase();
180
+ return HARD_TCC_SIGNATURES.some((sig) => text.includes(sig));
181
+ }
182
+
183
+ const APPLEEVENT_CODES = [
184
+ "-1743",
185
+ "-10004",
186
+ "-25211",
187
+ "-1712",
188
+ "-1728",
189
+ "-600",
190
+ "-10810",
191
+ "-1708",
192
+ "-1719",
193
+ "-10025",
194
+ "-2700"
195
+ ];
196
+
197
+ /**
198
+ * Pull known AppleEvent / spawn codes out of osascript stderr so smoke
199
+ * can print them instead of guessing TCC.
200
+ */
201
+ export function extractAppleEventCodes(message) {
202
+ const text = String(message || "");
203
+ const found = [];
204
+ for (const code of APPLEEVENT_CODES) {
205
+ if (text.includes(code) && !found.includes(code)) found.push(code);
206
+ }
207
+ if (/etimedout/i.test(text) && !found.includes("ETIMEDOUT")) found.push("ETIMEDOUT");
208
+ return found;
209
+ }
210
+
211
+ /**
212
+ * One-line osascript diagnostic. Always include this on calendar_remove
213
+ * failure so Mini --apply stops looking like a TCC deny.
214
+ */
215
+ export function formatOsascriptDiagnostic(result, source = "osascript") {
216
+ const raw = String(result && result.error ? result.error : "").replace(/\s+/g, " ").trim();
217
+ const clipped = raw.length > 360 ? `${raw.slice(0, 360)}...` : raw;
218
+ const kind = (result && result.kind) || "unknown";
219
+ const codes = extractAppleEventCodes(raw);
220
+ return `${source} kind=${kind} error=${clipped || "(empty)"}${codes.length ? ` codes=${codes.join(",")}` : ""}`;
221
+ }
222
+
223
+ /**
224
+ * Guidance attached to TCC denials. Names the host constraint without
225
+ * claiming the package can grant another app's entitlements.
226
+ */
227
+ export const TCC_GUIDANCE =
228
+ "macOS denied this automation. TCC attributes Apple events to the process responsible for the MCP server, " +
229
+ "so a host app without the matching automation grant blocks the write even when node has Full Disk Access. " +
230
+ "Run the indexer daemon (apple-tools-indexer / the LaunchAgent) so writes execute under node, or run this server from a host that can be granted Automation access.";
231
+
232
+ /**
233
+ * Contacts writes are the sharpest case, and worth separating from contacts
234
+ * reads: reads query the AddressBook sqlite file directly and only need Full
235
+ * Disk Access on the responsible process, while writes go through
236
+ * Contacts.app / CNContactStore, which is gated by the AddressBook privacy
237
+ * class. A host app built without the AddressBook entitlement is refused
238
+ * there with no prompt, and no package-side change can alter that.
239
+ */
240
+ export const CONTACTS_TCC_GUIDANCE =
241
+ "macOS denied Contacts access for this write. Contacts writes go through Contacts.app (CNContactStore), which is gated by the AddressBook privacy class " +
242
+ "and attributed to the process responsible for this MCP server - not to node. A host app that was built without the AddressBook entitlement " +
243
+ "(com.apple.security.personal-information.addressbook) is denied with no prompt, and that cannot be fixed with Full Disk Access or tccutil. " +
244
+ "Contacts *reads* are unaffected: they query the AddressBook database directly and only need Full Disk Access. " +
245
+ "Run the indexer daemon (apple-tools-indexer / the LaunchAgent) so Contacts writes execute under node, which macOS can grant AddressBook access to.";
246
+
247
+ /**
248
+ * Calendar has the same split as Contacts: reads in this package are sqlite
249
+ * queries against Calendar.sqlitedb (Full Disk Access), while writes go
250
+ * through Calendar.app / EventKit, gated by the calendars privacy class.
251
+ */
252
+ export const CALENDAR_TCC_GUIDANCE =
253
+ "macOS denied Calendar access for this write. Calendar writes go through Calendar.app (EventKit), which is gated by the calendars privacy class " +
254
+ "(com.apple.security.personal-information.calendars) and attributed to the process responsible for this MCP server - not to node. " +
255
+ "A host app built without that entitlement is denied with no prompt, and Full Disk Access or tccutil cannot change it. " +
256
+ "Calendar *reads* are unaffected: they query Calendar.sqlitedb directly and only need Full Disk Access. " +
257
+ "Run the indexer daemon (apple-tools-indexer / the LaunchAgent) so Calendar writes execute under node, which macOS can grant Calendars access to.";
258
+
259
+ /**
260
+ * Shown when the target app is installed but macOS still refused the Apple
261
+ * event. That is the signature of running under a parent process that
262
+ * cannot be granted Automation - a foreign shell, an embedded terminal, or
263
+ * a host app - rather than of a broken install.
264
+ */
265
+ export const ATTRIBUTION_GUIDANCE =
266
+ "The app is installed, so this is an Automation / responsible-process problem rather than a missing app: macOS refused to let the process " +
267
+ "responsible for this server drive it. Start the indexer daemon (apple-tools-indexer / the LaunchAgent) so writes run through the write bridge " +
268
+ "under launchd-owned node, or run this server from a parent that can hold Automation access (Terminal.app) and approve the prompt.";
269
+
270
+ /**
271
+ * Mail writes are gated by Automation → Mail for the responsible process.
272
+ * A hang or timeout on compose/send is that deny — not "Mail.app missing".
273
+ * dry_run never sends Apple events to Mail, so it cannot detect the grant.
274
+ */
275
+ export const MAIL_TCC_GUIDANCE =
276
+ "macOS denied Mail automation (Apple Events to Mail.app). A hang or timeout on compose or send is a TCC / Automation deny for node → Mail, " +
277
+ "not Mail.app missing or unavailable. dry_run never talks to Mail, so it cannot detect this grant. " +
278
+ "Allow node in System Settings → Privacy & Security → Automation for Mail (same Allow-via-prompt as Contacts and Calendar — do not add node via + in a privacy list). " +
279
+ "A Contacts or Calendar grant does not include Mail. " +
280
+ "Run the indexer daemon (apple-tools-indexer / the LaunchAgent) so Mail writes execute under node.";
281
+
282
+ /**
283
+ * Messages writes are a separate Automation target from Mail / Contacts / Calendar.
284
+ */
285
+ export const MESSAGES_TCC_GUIDANCE =
286
+ "macOS denied Messages automation (Apple Events to Messages.app). A hang or timeout on send is a TCC / Automation deny for node → Messages, " +
287
+ "not Messages.app missing or unavailable. dry_run never talks to Messages, so it cannot detect this grant. " +
288
+ "Allow node in System Settings → Privacy & Security → Automation for Messages (same Allow-via-prompt as Mail, Contacts, and Calendar — do not add node via + in a privacy list). " +
289
+ "A Contacts or Calendar grant does not include Messages. " +
290
+ "Run the indexer daemon (apple-tools-indexer / the LaunchAgent) so Messages writes execute under node.";
291
+
292
+ /**
293
+ * @param {"contacts"|"calendar"|"mail"|"messages"|string} source
294
+ */
295
+ export function tccGuidanceFor(source) {
296
+ if (source === "contacts") return CONTACTS_TCC_GUIDANCE;
297
+ if (source === "calendar") return CALENDAR_TCC_GUIDANCE;
298
+ if (source === "mail") return MAIL_TCC_GUIDANCE;
299
+ if (source === "messages") return MESSAGES_TCC_GUIDANCE;
300
+ return TCC_GUIDANCE;
301
+ }
302
+
303
+ /**
304
+ * Run a script and normalize the result.
305
+ *
306
+ * Pass `appName` so a refusal can be told apart from a missing app.
307
+ *
308
+ * @returns {{ ok: boolean, output: string, error: string|null, kind: string|null }}
309
+ */
310
+ export function runAppleScript(script, options = {}) {
311
+ const { timeout = DEFAULT_SCRIPT_TIMEOUT_MS, appName = null, language = null } = options;
312
+ try {
313
+ const output = safeOsascript(script, { timeout, language });
314
+ return { ok: true, output: (output || "").trim(), error: null, kind: null };
315
+ } catch (e) {
316
+ const message = e && e.message ? e.message : String(e);
317
+ return {
318
+ ok: false,
319
+ output: "",
320
+ error: message,
321
+ kind: classifyAppleScriptError(message, { appInstalled: appBundleInstalled(appName) })
322
+ };
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Quote a value as an AppleScript string literal.
328
+ */
329
+ export function asString(value) {
330
+ return `"${escapeAppleScript(value === undefined || value === null ? "" : String(value))}"`;
331
+ }
332
+
333
+ /**
334
+ * Emit a validated integer. Throws rather than interpolating unchecked input.
335
+ */
336
+ export function asInteger(value, { min = -2147483648, max = 2147483647, field = "value" } = {}) {
337
+ const n = Number(value);
338
+ if (!Number.isInteger(n) || n < min || n > max) {
339
+ throw new Error(`${field} must be an integer between ${min} and ${max}`);
340
+ }
341
+ return String(n);
342
+ }
343
+
344
+ export function asBoolean(value) {
345
+ return value ? "true" : "false";
346
+ }
347
+
348
+ /**
349
+ * AppleScript handler that builds a local `date` from validated integers.
350
+ * Day is reset to 1 first so setting month never rolls the date forward
351
+ * (e.g. Jan 31 -> "Feb 31").
352
+ */
353
+ export const DATE_HANDLER = `on atmMakeDate(y, mo, d, hh, mi)
354
+ set dt to current date
355
+ set day of dt to 1
356
+ set year of dt to y
357
+ set month of dt to mo
358
+ set day of dt to d
359
+ set hours of dt to hh
360
+ set minutes of dt to mi
361
+ set seconds of dt to 0
362
+ return dt
363
+ end atmMakeDate`;
364
+
365
+ /**
366
+ * Build the `atmMakeDate(...)` call for a parsed date.
367
+ * @param {{year:number,month:number,day:number,hour:number,minute:number}} parts
368
+ */
369
+ export function dateCall(parts) {
370
+ return `atmMakeDate(${asInteger(parts.year, { min: 1970, max: 2200, field: "year" })}, ` +
371
+ `${asInteger(parts.month, { min: 1, max: 12, field: "month" })}, ` +
372
+ `${asInteger(parts.day, { min: 1, max: 31, field: "day" })}, ` +
373
+ `${asInteger(parts.hour, { min: 0, max: 23, field: "hour" })}, ` +
374
+ `${asInteger(parts.minute, { min: 0, max: 59, field: "minute" })})`;
375
+ }
376
+
377
+ /**
378
+ * Strict datetime parsing for writes.
379
+ *
380
+ * Writes never guess: only explicit local datetimes are accepted, so an agent
381
+ * cannot silently schedule "next tuesday" against the wrong day.
382
+ * Accepted: "YYYY-MM-DD", "YYYY-MM-DD HH:MM", "YYYY-MM-DDTHH:MM[:SS]".
383
+ *
384
+ * @returns {{ parts: object|null, error: string|null, dateOnly: boolean }}
385
+ */
386
+ export function parseWriteDateTime(value, field = "start") {
387
+ if (typeof value !== "string" || value.trim() === "") {
388
+ return { parts: null, error: `${field} is required (use YYYY-MM-DD HH:MM local time)`, dateOnly: false };
389
+ }
390
+ const trimmed = value.trim();
391
+ const match = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::\d{2})?)?$/);
392
+ if (!match) {
393
+ return {
394
+ parts: null,
395
+ error: `${field} must be an explicit local datetime such as 2026-09-20 14:30 (YYYY-MM-DD or YYYY-MM-DD HH:MM). Natural language is not accepted for writes.`,
396
+ dateOnly: false
397
+ };
398
+ }
399
+ const year = Number(match[1]);
400
+ const month = Number(match[2]);
401
+ const day = Number(match[3]);
402
+ const dateOnly = match[4] === undefined;
403
+ const hour = dateOnly ? 0 : Number(match[4]);
404
+ const minute = dateOnly ? 0 : Number(match[5]);
405
+
406
+ if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59) {
407
+ return { parts: null, error: `${field} is not a valid date/time: ${trimmed}`, dateOnly };
408
+ }
409
+ // Reject impossible calendar days (e.g. 2026-02-31).
410
+ const probe = new Date(year, month - 1, day, hour, minute, 0, 0);
411
+ if (probe.getFullYear() !== year || probe.getMonth() !== month - 1 || probe.getDate() !== day) {
412
+ return { parts: null, error: `${field} is not a valid calendar date: ${trimmed}`, dateOnly };
413
+ }
414
+
415
+ return { parts: { year, month, day, hour, minute, iso: trimmed }, error: null, dateOnly };
416
+ }