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,292 @@
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
+ "CONTACT_NOT_FOUND",
28
+ "CALENDAR_NOT_FOUND",
29
+ "CHAT_NOT_FOUND",
30
+ "ARCHIVE_MAILBOX_NOT_FOUND",
31
+ "ATTENDEE_NOT_FOUND",
32
+ "RSVP_NOT_SUPPORTED"
33
+ ];
34
+
35
+ // osascript / TCC denial signatures. Apple reports these as Apple event
36
+ // errors (-1743, -10004) or as "not permitted" / "not authorized" text.
37
+ const TCC_SIGNATURES = [
38
+ "-1743",
39
+ "-10004",
40
+ "-25211",
41
+ "not authorized to send apple events",
42
+ "not allowed to send apple events",
43
+ "is not allowed assistive access",
44
+ "operation not permitted",
45
+ "not permitted to access",
46
+ "access to contacts",
47
+ "access to calendars",
48
+ "privacy settings",
49
+ "errae eventnotpermitted",
50
+ "errAEEventNotPermitted".toLowerCase()
51
+ ];
52
+
53
+ /**
54
+ * These read like "the app is missing", but macOS also emits them when the
55
+ * responsible process may not drive the app at all: a denied Automation
56
+ * grant frequently surfaces as -1728 / "can't get application" rather than a
57
+ * clean -1743. When the app is installed, treat them as attribution
58
+ * failures, not as a missing app.
59
+ */
60
+ const APP_MISSING_SIGNATURES = [
61
+ "application isn't running",
62
+ "can't get application",
63
+ "can't get every application",
64
+ "application is not running",
65
+ "-600",
66
+ "-1728",
67
+ "-10810"
68
+ ];
69
+
70
+ // osascript itself is missing: not macOS, or a stripped PATH. Never an
71
+ // attribution problem, because nothing ran.
72
+ const OSASCRIPT_MISSING_SIGNATURES = [
73
+ "spawnsync osascript",
74
+ "enoent"
75
+ ];
76
+
77
+ /**
78
+ * Standard install locations for the apps this package automates.
79
+ * Ventura and later keep the first-party apps in /System/Applications.
80
+ */
81
+ const APP_SEARCH_DIRS = [
82
+ "/System/Applications",
83
+ "/Applications",
84
+ "/System/Applications/Utilities"
85
+ ];
86
+
87
+ /**
88
+ * @param {string} appName - e.g. "Contacts"
89
+ * @returns {boolean|null} null when we cannot tell (no app name given)
90
+ */
91
+ export function appBundleInstalled(appName, existsFn = fs.existsSync) {
92
+ if (!appName || typeof appName !== "string") return null;
93
+ if (!/^[A-Za-z ]{1,40}$/.test(appName)) return null;
94
+ return APP_SEARCH_DIRS.some((dir) => existsFn(`${dir}/${appName}.app`));
95
+ }
96
+
97
+ /**
98
+ * @param {string} message
99
+ * @param {object} [context]
100
+ * @param {boolean|null} [context.appInstalled] - whether the target app exists
101
+ * @returns {"tcc"|"not_found"|"attribution"|"app_unavailable"|"unknown"}
102
+ */
103
+ export function classifyAppleScriptError(message, context = {}) {
104
+ const text = String(message || "").toLowerCase();
105
+ for (const sentinel of NOT_FOUND_SENTINELS) {
106
+ if (text.includes(sentinel.toLowerCase())) return "not_found";
107
+ }
108
+ if (OSASCRIPT_MISSING_SIGNATURES.some((sig) => text.includes(sig))) return "app_unavailable";
109
+ if (TCC_SIGNATURES.some((sig) => text.includes(sig))) return "tcc";
110
+ if (APP_MISSING_SIGNATURES.some((sig) => text.includes(sig))) {
111
+ // The app is on disk, so "can't get application" means macOS refused to
112
+ // let the responsible process drive it - an Automation / attribution
113
+ // problem, not a missing app.
114
+ return context.appInstalled === true ? "attribution" : "app_unavailable";
115
+ }
116
+ return "unknown";
117
+ }
118
+
119
+ export function isTccDenial(message) {
120
+ return classifyAppleScriptError(message) === "tcc";
121
+ }
122
+
123
+ /**
124
+ * Guidance attached to TCC denials. Names the host constraint without
125
+ * claiming the package can grant another app's entitlements.
126
+ */
127
+ export const TCC_GUIDANCE =
128
+ "macOS denied this automation. TCC attributes Apple events to the process responsible for the MCP server, " +
129
+ "so a host app without the matching automation grant blocks the write even when node has Full Disk Access. " +
130
+ "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.";
131
+
132
+ /**
133
+ * Contacts writes are the sharpest case, and worth separating from contacts
134
+ * reads: reads query the AddressBook sqlite file directly and only need Full
135
+ * Disk Access on the responsible process, while writes go through
136
+ * Contacts.app / CNContactStore, which is gated by the AddressBook privacy
137
+ * class. A host app built without the AddressBook entitlement is refused
138
+ * there with no prompt, and no package-side change can alter that.
139
+ */
140
+ export const CONTACTS_TCC_GUIDANCE =
141
+ "macOS denied Contacts access for this write. Contacts writes go through Contacts.app (CNContactStore), which is gated by the AddressBook privacy class " +
142
+ "and attributed to the process responsible for this MCP server - not to node. A host app that was built without the AddressBook entitlement " +
143
+ "(com.apple.security.personal-information.addressbook) is denied with no prompt, and that cannot be fixed with Full Disk Access or tccutil. " +
144
+ "Contacts *reads* are unaffected: they query the AddressBook database directly and only need Full Disk Access. " +
145
+ "Run the indexer daemon (apple-tools-indexer / the LaunchAgent) so Contacts writes execute under node, which macOS can grant AddressBook access to.";
146
+
147
+ /**
148
+ * Calendar has the same split as Contacts: reads in this package are sqlite
149
+ * queries against Calendar.sqlitedb (Full Disk Access), while writes go
150
+ * through Calendar.app / EventKit, gated by the calendars privacy class.
151
+ */
152
+ export const CALENDAR_TCC_GUIDANCE =
153
+ "macOS denied Calendar access for this write. Calendar writes go through Calendar.app (EventKit), which is gated by the calendars privacy class " +
154
+ "(com.apple.security.personal-information.calendars) and attributed to the process responsible for this MCP server - not to node. " +
155
+ "A host app built without that entitlement is denied with no prompt, and Full Disk Access or tccutil cannot change it. " +
156
+ "Calendar *reads* are unaffected: they query Calendar.sqlitedb directly and only need Full Disk Access. " +
157
+ "Run the indexer daemon (apple-tools-indexer / the LaunchAgent) so Calendar writes execute under node, which macOS can grant Calendars access to.";
158
+
159
+ /**
160
+ * Shown when the target app is installed but macOS still refused the Apple
161
+ * event. That is the signature of running under a parent process that
162
+ * cannot be granted Automation - a foreign shell, an embedded terminal, or
163
+ * a host app - rather than of a broken install.
164
+ */
165
+ export const ATTRIBUTION_GUIDANCE =
166
+ "The app is installed, so this is an Automation / responsible-process problem rather than a missing app: macOS refused to let the process " +
167
+ "responsible for this server drive it. Start the indexer daemon (apple-tools-indexer / the LaunchAgent) so writes run through the write bridge " +
168
+ "under launchd-owned node, or run this server from a parent that can hold Automation access (Terminal.app) and approve the prompt.";
169
+
170
+ /**
171
+ * @param {"contacts"|"calendar"|string} source
172
+ */
173
+ export function tccGuidanceFor(source) {
174
+ if (source === "contacts") return CONTACTS_TCC_GUIDANCE;
175
+ if (source === "calendar") return CALENDAR_TCC_GUIDANCE;
176
+ return TCC_GUIDANCE;
177
+ }
178
+
179
+ /**
180
+ * Run a script and normalize the result.
181
+ *
182
+ * Pass `appName` so a refusal can be told apart from a missing app.
183
+ *
184
+ * @returns {{ ok: boolean, output: string, error: string|null, kind: string|null }}
185
+ */
186
+ export function runAppleScript(script, options = {}) {
187
+ const { timeout = DEFAULT_SCRIPT_TIMEOUT_MS, appName = null } = options;
188
+ try {
189
+ const output = safeOsascript(script, { timeout });
190
+ return { ok: true, output: (output || "").trim(), error: null, kind: null };
191
+ } catch (e) {
192
+ const message = e && e.message ? e.message : String(e);
193
+ return {
194
+ ok: false,
195
+ output: "",
196
+ error: message,
197
+ kind: classifyAppleScriptError(message, { appInstalled: appBundleInstalled(appName) })
198
+ };
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Quote a value as an AppleScript string literal.
204
+ */
205
+ export function asString(value) {
206
+ return `"${escapeAppleScript(value === undefined || value === null ? "" : String(value))}"`;
207
+ }
208
+
209
+ /**
210
+ * Emit a validated integer. Throws rather than interpolating unchecked input.
211
+ */
212
+ export function asInteger(value, { min = -2147483648, max = 2147483647, field = "value" } = {}) {
213
+ const n = Number(value);
214
+ if (!Number.isInteger(n) || n < min || n > max) {
215
+ throw new Error(`${field} must be an integer between ${min} and ${max}`);
216
+ }
217
+ return String(n);
218
+ }
219
+
220
+ export function asBoolean(value) {
221
+ return value ? "true" : "false";
222
+ }
223
+
224
+ /**
225
+ * AppleScript handler that builds a local `date` from validated integers.
226
+ * Day is reset to 1 first so setting month never rolls the date forward
227
+ * (e.g. Jan 31 -> "Feb 31").
228
+ */
229
+ export const DATE_HANDLER = `on atmMakeDate(y, mo, d, hh, mi)
230
+ set dt to current date
231
+ set day of dt to 1
232
+ set year of dt to y
233
+ set month of dt to mo
234
+ set day of dt to d
235
+ set hours of dt to hh
236
+ set minutes of dt to mi
237
+ set seconds of dt to 0
238
+ return dt
239
+ end atmMakeDate`;
240
+
241
+ /**
242
+ * Build the `atmMakeDate(...)` call for a parsed date.
243
+ * @param {{year:number,month:number,day:number,hour:number,minute:number}} parts
244
+ */
245
+ export function dateCall(parts) {
246
+ return `atmMakeDate(${asInteger(parts.year, { min: 1970, max: 2200, field: "year" })}, ` +
247
+ `${asInteger(parts.month, { min: 1, max: 12, field: "month" })}, ` +
248
+ `${asInteger(parts.day, { min: 1, max: 31, field: "day" })}, ` +
249
+ `${asInteger(parts.hour, { min: 0, max: 23, field: "hour" })}, ` +
250
+ `${asInteger(parts.minute, { min: 0, max: 59, field: "minute" })})`;
251
+ }
252
+
253
+ /**
254
+ * Strict datetime parsing for writes.
255
+ *
256
+ * Writes never guess: only explicit local datetimes are accepted, so an agent
257
+ * cannot silently schedule "next tuesday" against the wrong day.
258
+ * Accepted: "YYYY-MM-DD", "YYYY-MM-DD HH:MM", "YYYY-MM-DDTHH:MM[:SS]".
259
+ *
260
+ * @returns {{ parts: object|null, error: string|null, dateOnly: boolean }}
261
+ */
262
+ export function parseWriteDateTime(value, field = "start") {
263
+ if (typeof value !== "string" || value.trim() === "") {
264
+ return { parts: null, error: `${field} is required (use YYYY-MM-DD HH:MM local time)`, dateOnly: false };
265
+ }
266
+ const trimmed = value.trim();
267
+ const match = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::\d{2})?)?$/);
268
+ if (!match) {
269
+ return {
270
+ parts: null,
271
+ 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.`,
272
+ dateOnly: false
273
+ };
274
+ }
275
+ const year = Number(match[1]);
276
+ const month = Number(match[2]);
277
+ const day = Number(match[3]);
278
+ const dateOnly = match[4] === undefined;
279
+ const hour = dateOnly ? 0 : Number(match[4]);
280
+ const minute = dateOnly ? 0 : Number(match[5]);
281
+
282
+ if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59) {
283
+ return { parts: null, error: `${field} is not a valid date/time: ${trimmed}`, dateOnly };
284
+ }
285
+ // Reject impossible calendar days (e.g. 2026-02-31).
286
+ const probe = new Date(year, month - 1, day, hour, minute, 0, 0);
287
+ if (probe.getFullYear() !== year || probe.getMonth() !== month - 1 || probe.getDate() !== day) {
288
+ return { parts: null, error: `${field} is not a valid calendar date: ${trimmed}`, dateOnly };
289
+ }
290
+
291
+ return { parts: { year, month, day, hour, minute, iso: trimmed }, error: null, dateOnly };
292
+ }