apple-tools-mcp 2.0.0 → 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.
@@ -76,7 +76,7 @@ function childLines(items, kind, personVar) {
76
76
  }
77
77
 
78
78
  function failure(action, summary, result, secrets = []) {
79
- if (result.kind === "tcc") {
79
+ if (result.kind === "tcc" || result.kind === "timeout") {
80
80
  return `${action} failed — attempted to ${summary}. ${CONTACTS_TCC_GUIDANCE}`;
81
81
  }
82
82
  const raw = String(result.error || "");
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Long-lived EventKit JXA worker.
3
+ *
4
+ * writeOnly (EKAuthorizationStatus = 4) can create an EKEvent and read
5
+ * identifiers off that in-memory object, but cannot re-query via
6
+ * eventWithIdentifier / calendarItemsWithExternalIdentifier — Mini 2cdf44d
7
+ * failed add on a post-save lookup even though create returned ids.
8
+ *
9
+ * The indexer daemon therefore keeps one osascript process around and
10
+ * caches the EKEvent objects from create so edit/remove can call
11
+ * saveEvent / removeEvent without a fetch.
12
+ *
13
+ * stdin/stdout: one JSON object per line. Never interpolates commands
14
+ * into a shell (`spawn`, `shell: false`).
15
+ */
16
+
17
+ import { spawn } from "child_process";
18
+ import fs from "fs";
19
+ import os from "os";
20
+ import path from "path";
21
+
22
+ let singleton = null;
23
+
24
+ export function setEventKitSession(session) {
25
+ singleton = session || null;
26
+ }
27
+
28
+ export function getEventKitSession() {
29
+ return singleton;
30
+ }
31
+
32
+ export function shouldStartEventKitSession({
33
+ platform = process.platform,
34
+ env = process.env
35
+ } = {}) {
36
+ if (platform !== "darwin") return false;
37
+ if (env.VITEST) return false;
38
+ if (env.APPLE_TOOLS_EVENTKIT_SESSION === "0") return false;
39
+ return true;
40
+ }
41
+
42
+ /**
43
+ * JXA worker: create caches the EKEvent; update/remove use the cache.
44
+ * create does not call findEventByIds.
45
+ */
46
+ export function sessionPaths(home = process.env.HOME || "") {
47
+ const root = home ? path.join(home, ".apple-tools-mcp") : path.join(os.tmpdir(), "atm-eventkit");
48
+ const dir = path.join(root, "eventkit-session");
49
+ return {
50
+ root,
51
+ dir,
52
+ scriptPath: path.join(root, "eventkit-worker.jxa"),
53
+ cmdPath: path.join(dir, "cmd.json"),
54
+ rspPath: path.join(dir, "rsp.json")
55
+ };
56
+ }
57
+
58
+ export function buildEventKitWorkerScript(helpers, paths = {}) {
59
+ const cmdPath = JSON.stringify(paths.cmdPath || "");
60
+ const rspPath = JSON.stringify(paths.rspPath || "");
61
+ return `ObjC.import("EventKit");
62
+ ObjC.import("Foundation");
63
+ ${helpers}
64
+
65
+ var store = $.EKEventStore.alloc.init;
66
+ var cache = {};
67
+
68
+ function remember(ev) {
69
+ if (!ev) return;
70
+ var keys = [
71
+ jsString(ev.eventIdentifier),
72
+ jsString(ev.calendarItemIdentifier),
73
+ jsString(ev.calendarItemExternalIdentifier)
74
+ ];
75
+ for (var i = 0; i < keys.length; i++) {
76
+ if (keys[i]) cache[keys[i]] = ev;
77
+ }
78
+ }
79
+
80
+ function forget(ev) {
81
+ var keys = [];
82
+ for (var k in cache) keys.push(k);
83
+ for (var i = 0; i < keys.length; i++) {
84
+ if (cache[keys[i]] === ev) delete cache[keys[i]];
85
+ }
86
+ }
87
+
88
+ function resolve(ids) {
89
+ if (!ids) return null;
90
+ for (var i = 0; i < ids.length; i++) {
91
+ var id = ids[i];
92
+ if (id && cache[id]) return cache[id];
93
+ }
94
+ return findEventByIds(store, ids);
95
+ }
96
+
97
+ var cmdPath = ${cmdPath};
98
+ var rspPath = ${rspPath};
99
+
100
+ function writeReply(s) {
101
+ $.NSString.stringWithString(String(s) + "\\n").writeToFileAtomicallyEncodingError(rspPath, true, $.NSUTF8StringEncoding, null);
102
+ }
103
+
104
+ function fileExists(p) {
105
+ try { return !!$.NSFileManager.defaultManager.fileExistsAtPath(p); } catch (e) { return false; }
106
+ }
107
+
108
+ function readCmd() {
109
+ var str = $.NSString.stringWithContentsOfFileEncodingError(cmdPath, $.NSUTF8StringEncoding, null);
110
+ return jsString(str);
111
+ }
112
+
113
+ function removeCmd() {
114
+ try { $.NSFileManager.defaultManager.removeItemAtPathError(cmdPath, null); } catch (e) {}
115
+ }
116
+
117
+ function pickCalendar(calendarName) {
118
+ var defaultCal = null;
119
+ try { defaultCal = store.defaultCalendarForNewEvents; } catch (e) {}
120
+ var cals = null;
121
+ var count = 0;
122
+ try {
123
+ cals = store.calendarsForEntityType($.EKEntityTypeEvent);
124
+ count = cals ? Number(cals.count) : 0;
125
+ } catch (e) {}
126
+ var target = null;
127
+ var writables = [];
128
+ var wanted = String(calendarName || "").toLowerCase();
129
+ function matchesWanted(c) {
130
+ var t = titleOf(c).toLowerCase();
131
+ var id = identifierOf(c);
132
+ return (t && t === wanted) || (id && (id === calendarName || id.toLowerCase() === wanted));
133
+ }
134
+ for (var i = 0; i < count; i++) {
135
+ var c = cals.objectAtIndex(i);
136
+ var writable = true;
137
+ try { writable = !!c.allowsContentModifications; } catch (e) {}
138
+ if (!writable) continue;
139
+ writables.push(c);
140
+ if (matchesWanted(c)) {
141
+ target = c;
142
+ break;
143
+ }
144
+ }
145
+ if (!target && defaultCal && matchesWanted(defaultCal)) target = defaultCal;
146
+ if (!target && writables.length === 1) target = writables[0];
147
+ if (!target && defaultCal) target = defaultCal;
148
+ var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
149
+ if (!target) {
150
+ throw new Error("CALENDAR_NOT_FOUND status=" + status + " eventKitCalendars=" + count + " default=" + titleOf(defaultCal) + " defaultId=" + identifierOf(defaultCal));
151
+ }
152
+ return target;
153
+ }
154
+
155
+ function createEvent(cmd) {
156
+ var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
157
+ if (status === 1 || status === 2) throw new Error("EVENTKIT_DENIED status=" + status);
158
+ var target = pickCalendar(cmd.calendarName);
159
+ var event = null;
160
+ try { event = $.EKEvent.eventWithEventStore(store); } catch (e) {}
161
+ if (!event) {
162
+ try { event = $.EKEvent.alloc.initWithEventStore(store); } catch (e2) {}
163
+ }
164
+ if (!event) throw new Error("EVENTKIT_NO_EVENT status=" + status);
165
+ event.title = String(cmd.title || "");
166
+ event.startDate = $.NSDate.dateWithTimeIntervalSince1970(Number(cmd.startSec));
167
+ event.endDate = $.NSDate.dateWithTimeIntervalSince1970(Number(cmd.endSec));
168
+ event.allDay = !!cmd.allDay;
169
+ try { event.calendar = target; } catch (e) {}
170
+ try { if (event.setCalendar) event.setCalendar(target); } catch (e) {}
171
+ if (cmd.location) event.location = String(cmd.location);
172
+ if (cmd.notes) event.notes = String(cmd.notes);
173
+ var alerts = cmd.alerts || [];
174
+ if (alerts && alerts.length) {
175
+ var alarms = $.NSMutableArray.array;
176
+ for (var a = 0; a < alerts.length; a++) {
177
+ alarms.addObject($.EKAlarm.alarmWithRelativeOffset(-Number(alerts[a]) * 60));
178
+ }
179
+ event.alarms = alarms;
180
+ }
181
+ var err = Ref();
182
+ var ok = false;
183
+ try {
184
+ ok = store.saveEventSpanCommitError(event, $.EKSpanThisEvent, true, err);
185
+ } catch (e) {
186
+ throw new Error("EVENTKIT_SAVE_FAILED: " + String(e));
187
+ }
188
+ if (!ok) {
189
+ var desc = "unknown";
190
+ try { desc = String(err[0]); } catch (e) {}
191
+ throw new Error("EVENTKIT_SAVE_FAILED: " + desc + " status=" + status);
192
+ }
193
+ remember(event);
194
+ var externalId = "";
195
+ var localId = "";
196
+ var itemId = "";
197
+ try { externalId = jsString(event.calendarItemExternalIdentifier); } catch (e) {}
198
+ try { localId = jsString(event.eventIdentifier); } catch (e) {}
199
+ try { itemId = jsString(event.calendarItemIdentifier); } catch (e) {}
200
+ if (!externalId && !localId && !itemId) throw new Error("EVENTKIT_NO_ID status=" + status);
201
+ return externalId + "<<>>" + localId + "<<>>" + titleOf(target) + "<<>>" + itemId;
202
+ }
203
+
204
+ function updateEvent(cmd) {
205
+ var event = resolve(cmd.ids);
206
+ if (!event) {
207
+ var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
208
+ throw new Error("EVENTKIT_NOT_FOUND status=" + status + (status === 4 ? " writeOnly" : "") + " sessionMiss tried=" + (cmd.ids || []).join(","));
209
+ }
210
+ if (cmd.title !== undefined && cmd.title !== null) event.title = String(cmd.title);
211
+ if (cmd.location !== undefined && cmd.location !== null) event.location = String(cmd.location);
212
+ if (cmd.notes !== undefined && cmd.notes !== null) event.notes = String(cmd.notes);
213
+ if (cmd.startSec !== undefined && cmd.startSec !== null) {
214
+ event.startDate = $.NSDate.dateWithTimeIntervalSince1970(Number(cmd.startSec));
215
+ }
216
+ if (cmd.endSec !== undefined && cmd.endSec !== null) {
217
+ event.endDate = $.NSDate.dateWithTimeIntervalSince1970(Number(cmd.endSec));
218
+ }
219
+ if (cmd.clearAlerts) {
220
+ try { event.alarms = $.NSMutableArray.array; } catch (e) {}
221
+ }
222
+ var alerts = cmd.alerts || [];
223
+ if (alerts && alerts.length) {
224
+ var alarms = $.NSMutableArray.array;
225
+ for (var a = 0; a < alerts.length; a++) {
226
+ alarms.addObject($.EKAlarm.alarmWithRelativeOffset(-Number(alerts[a]) * 60));
227
+ }
228
+ event.alarms = alarms;
229
+ }
230
+ var err = Ref();
231
+ var ok = store.saveEventSpanCommitError(event, $.EKSpanThisEvent, true, err);
232
+ if (!ok) {
233
+ var desc = "unknown";
234
+ try { desc = String(err[0]); } catch (e) {}
235
+ throw new Error("EVENTKIT_SAVE_FAILED: " + desc);
236
+ }
237
+ remember(event);
238
+ return jsString(event.eventIdentifier) || jsString(event.calendarItemExternalIdentifier) || "1";
239
+ }
240
+
241
+ function removeEvent(cmd) {
242
+ var event = resolve(cmd.ids);
243
+ if (!event) {
244
+ var status = $.EKEventStore.authorizationStatusForEntityType($.EKEntityTypeEvent);
245
+ throw new Error("EVENTKIT_NOT_FOUND status=" + status + (status === 4 ? " writeOnly" : "") + " sessionMiss tried=" + (cmd.ids || []).join(","));
246
+ }
247
+ var err = Ref();
248
+ var ok = store.removeEventSpanCommitError(event, $.EKSpanThisEvent, true, err);
249
+ if (!ok) {
250
+ var desc = "unknown";
251
+ try { desc = String(err[0]); } catch (e) {}
252
+ throw new Error("EVENTKIT_REMOVE_FAILED: " + desc);
253
+ }
254
+ forget(event);
255
+ return "1";
256
+ }
257
+
258
+ function handle(cmd) {
259
+ if (!cmd || !cmd.op) throw new Error("EVENTKIT_BAD_CMD");
260
+ if (cmd.op === "ping") return "pong";
261
+ if (cmd.op === "create") return createEvent(cmd);
262
+ if (cmd.op === "update") return updateEvent(cmd);
263
+ if (cmd.op === "remove") return removeEvent(cmd);
264
+ if (cmd.op === "quit") return "bye";
265
+ throw new Error("EVENTKIT_BAD_OP");
266
+ }
267
+
268
+ while (true) {
269
+ if (!fileExists(cmdPath)) {
270
+ delay(0.05);
271
+ continue;
272
+ }
273
+ var line = readCmd();
274
+ removeCmd();
275
+ line = String(line || "").replace(/^\\s+|\\s+$/g, "");
276
+ if (!line) continue;
277
+ try {
278
+ var cmd = JSON.parse(line);
279
+ var output = handle(cmd);
280
+ writeReply(JSON.stringify({ ok: true, output: String(output == null ? "" : output) }));
281
+ if (cmd.op === "quit") break;
282
+ } catch (e) {
283
+ writeReply(JSON.stringify({ ok: false, error: String(e) }));
284
+ }
285
+ }
286
+ `;
287
+ }
288
+
289
+ export function workerScriptPath(home = process.env.HOME || "") {
290
+ return sessionPaths(home).scriptPath;
291
+ }
292
+
293
+ export function startEventKitSession({
294
+ helpers,
295
+ spawnImpl = spawn,
296
+ home = process.env.HOME || "",
297
+ timeoutMs = 30000,
298
+ sleep = (ms) => {
299
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
300
+ }
301
+ } = {}) {
302
+ if (!helpers) throw new Error("EventKit worker helpers are required");
303
+ const paths = sessionPaths(home);
304
+ fs.mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
305
+ try { fs.unlinkSync(paths.cmdPath); } catch { /* ignore */ }
306
+ try { fs.unlinkSync(paths.rspPath); } catch { /* ignore */ }
307
+ fs.writeFileSync(paths.scriptPath, buildEventKitWorkerScript(helpers, paths), { mode: 0o600 });
308
+
309
+ const child = spawnImpl("osascript", ["-l", "JavaScript", paths.scriptPath], {
310
+ stdio: ["ignore", "ignore", "pipe"],
311
+ shell: false
312
+ });
313
+
314
+ let dead = false;
315
+ if (child.on) {
316
+ child.on("exit", () => {
317
+ dead = true;
318
+ });
319
+ }
320
+
321
+ function request(cmd, waitMs = timeoutMs) {
322
+ if (dead) throw new Error("EVENTKIT_SESSION_DEAD");
323
+ try { fs.unlinkSync(paths.rspPath); } catch { /* ignore */ }
324
+ fs.writeFileSync(paths.cmdPath, JSON.stringify(cmd), { mode: 0o600 });
325
+ const deadline = Date.now() + waitMs;
326
+ while (Date.now() < deadline) {
327
+ if (fs.existsSync(paths.rspPath)) {
328
+ const text = fs.readFileSync(paths.rspPath, "utf8").trim();
329
+ try { fs.unlinkSync(paths.rspPath); } catch { /* ignore */ }
330
+ return JSON.parse(text);
331
+ }
332
+ sleep(20);
333
+ }
334
+ throw new Error("EVENTKIT_SESSION_TIMEOUT");
335
+ }
336
+
337
+ const pong = request({ op: "ping" });
338
+ if (!pong || pong.ok !== true || pong.output !== "pong") {
339
+ try { child.kill(); } catch { /* ignore */ }
340
+ throw new Error("EVENTKIT_SESSION_HANDSHAKE");
341
+ }
342
+
343
+ return {
344
+ request,
345
+ close() {
346
+ try { request({ op: "quit" }, 2000); } catch { /* ignore */ }
347
+ try { child.kill(); } catch { /* ignore */ }
348
+ dead = true;
349
+ }
350
+ };
351
+ }
352
+
353
+ export function ensureEventKitSession(opts = {}) {
354
+ if (singleton) return singleton;
355
+ if (!shouldStartEventKitSession(opts)) return null;
356
+ try {
357
+ singleton = startEventKitSession(opts);
358
+ return singleton;
359
+ } catch {
360
+ singleton = null;
361
+ return null;
362
+ }
363
+ }
364
+
365
+ export function closeEventKitSession() {
366
+ if (!singleton) return;
367
+ try { singleton.close(); } catch { /* ignore */ }
368
+ singleton = null;
369
+ }
package/lib/mailWrite.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import fs from "fs";
12
12
  import path from "path";
13
13
  import { validateEmailPath, unfoldRfc822Headers, safeMatch, stripHtmlTags } from "./validators.js";
14
- import { runAppleScript, asString, TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
14
+ import { runAppleScript, asString, MAIL_TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
15
15
  import {
16
16
  planWrite,
17
17
  validateEmailList,
@@ -98,6 +98,42 @@ function recipientLines(addresses, kind) {
98
98
  .join("\n");
99
99
  }
100
100
 
101
+ /**
102
+ * Ship-gate / first-run probe: the compose verb that hangs when node → Mail
103
+ * Automation is denied. `tell Mail to get name` is not enough — Mini
104
+ * diagnosis showed that returns while `make new outgoing message` blocks.
105
+ * Nothing is sent; the outgoing message is deleted immediately.
106
+ */
107
+ export const MAIL_AUTOMATION_PROBE_SUBJECT = "ATM Mail Automation probe";
108
+
109
+ export function buildMailAutomationProbeScript() {
110
+ return `tell application "Mail"
111
+ set probe to make new outgoing message with properties {subject:${asString(MAIL_AUTOMATION_PROBE_SUBJECT)}, content:"", visible:false}
112
+ delete probe
113
+ end tell
114
+ return "OK"`;
115
+ }
116
+
117
+ /**
118
+ * Live Mail Apple Events check. Not a dry_run: that path never talks to Mail.
119
+ * @returns {{ ok: boolean, message: string }}
120
+ */
121
+ export function probeMailAutomation() {
122
+ const action = "mail_automation_probe";
123
+ const summary = "compose a temporary outgoing message in Mail (Automation / Apple Events check; nothing is sent)";
124
+ const result = runAppleScript(buildMailAutomationProbeScript(), { timeout: 30000, appName: "Mail" });
125
+ if (!result.ok) {
126
+ return { ok: false, message: failure(action, summary, result, []) };
127
+ }
128
+ return {
129
+ ok: true,
130
+ message: writeSuccessMessage(
131
+ action,
132
+ "Mail Automation allowed (composed and discarded a temporary outgoing message; nothing was sent)"
133
+ )
134
+ };
135
+ }
136
+
101
137
  /**
102
138
  * Build the outgoing-message script shared by send and draft.
103
139
  *
@@ -136,8 +172,8 @@ function summarizeRecipients(to, cc, bcc) {
136
172
  }
137
173
 
138
174
  function failure(action, summary, result, secrets) {
139
- if (result.kind === "tcc") {
140
- return `${action} failed — attempted to ${summary}. ${TCC_GUIDANCE}`;
175
+ if (result.kind === "tcc" || result.kind === "timeout") {
176
+ return `${action} failed — attempted to ${summary}. ${MAIL_TCC_GUIDANCE}`;
141
177
  }
142
178
  if (result.kind === "not_found") {
143
179
  return `${action} failed — attempted to ${summary}. The message could not be found in Mail. Pass a message_id from a current mail_search result.`;
@@ -14,7 +14,7 @@ import fs from "fs";
14
14
  import path from "path";
15
15
  import { safeSqlite3Json } from "./shell.js";
16
16
  import { escapeSQL } from "./validators.js";
17
- import { runAppleScript, asString, TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
17
+ import { runAppleScript, asString, MESSAGES_TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
18
18
  import {
19
19
  planWrite,
20
20
  normalizeList,
@@ -95,6 +95,38 @@ export function validateAttachmentPath(value) {
95
95
  return { filePath: resolved, error: null };
96
96
  }
97
97
 
98
+ /**
99
+ * Ship-gate / first-run probe: the Messages Apple Events used before send
100
+ * (enumerate accounts / service type). `messages_send` dry_run never talks
101
+ * to Messages. Nothing is sent.
102
+ */
103
+ export function buildMessagesAutomationProbeScript() {
104
+ return `tell application "Messages"
105
+ repeat with acc in accounts
106
+ try
107
+ get service type of acc
108
+ end try
109
+ end repeat
110
+ end tell
111
+ return "OK"`;
112
+ }
113
+
114
+ export function probeMessagesAutomation() {
115
+ const action = "messages_automation_probe";
116
+ const summary = "enumerate Messages accounts (Automation / Apple Events check; nothing is sent)";
117
+ const result = runAppleScript(buildMessagesAutomationProbeScript(), { timeout: 30000, appName: "Messages" });
118
+ if (!result.ok) {
119
+ return { ok: false, message: failure(action, summary, result, []) };
120
+ }
121
+ return {
122
+ ok: true,
123
+ message: writeSuccessMessage(
124
+ action,
125
+ "Messages Automation allowed (enumerated accounts; nothing was sent)"
126
+ )
127
+ };
128
+ }
129
+
98
130
  function serviceHandler() {
99
131
  return `on atmService(kind)
100
132
  tell application "Messages"
@@ -159,8 +191,8 @@ return "OK"`;
159
191
  }
160
192
 
161
193
  function failure(action, summary, result, secrets) {
162
- if (result.kind === "tcc") {
163
- return `${action} failed — attempted to ${summary}. ${TCC_GUIDANCE}`;
194
+ if (result.kind === "tcc" || result.kind === "timeout") {
195
+ return `${action} failed — attempted to ${summary}. ${MESSAGES_TCC_GUIDANCE}`;
164
196
  }
165
197
  if (result.kind === "attribution") {
166
198
  return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
package/lib/shell.js CHANGED
@@ -109,31 +109,49 @@ export function safeSqlite3Json(dbPath, query, options = {}) {
109
109
  * @returns {string} Output from AppleScript
110
110
  * @throws {Error} If execution fails
111
111
  */
112
+ const OSASCRIPT_LANGUAGES = new Set(["JavaScript"]);
113
+
112
114
  export function safeOsascript(script, options = {}) {
113
115
  const {
114
- timeout = 30000
116
+ timeout = 30000,
117
+ language = null
115
118
  } = options;
116
119
 
117
120
  if (!script || typeof script !== 'string') {
118
121
  throw new Error('AppleScript is required');
119
122
  }
120
123
 
121
- // Use -e flag with the script content passed as argument
122
- // This is safer than heredoc shell syntax
123
- const result = spawnSync('osascript', ['-e', script], {
124
+ const args = [];
125
+ if (language) {
126
+ if (!OSASCRIPT_LANGUAGES.has(language)) {
127
+ throw new Error(`Unsupported osascript language: ${language}`);
128
+ }
129
+ args.push("-l", language);
130
+ }
131
+ args.push("-e", script);
132
+
133
+ // Use -e with the script as an argument (never a shell string).
134
+ const result = spawnSync('osascript', args, {
124
135
  encoding: 'utf-8',
125
136
  timeout,
126
137
  shell: false
127
138
  });
128
139
 
140
+ const stderr = String(result.stderr || "").trim().slice(0, 500);
141
+
129
142
  if (result.error) {
143
+ if (stderr) {
144
+ const wrapped = new Error(`${result.error.message}; osascript stderr: ${stderr}`);
145
+ wrapped.code = result.error.code;
146
+ throw wrapped;
147
+ }
130
148
  throw result.error;
131
149
  }
132
150
 
133
151
  // osascript may return non-zero for certain operations
134
152
  // Return stdout if we have it, otherwise throw
135
153
  if (result.status !== 0 && !result.stdout) {
136
- const errorMsg = result.stderr || `osascript exited with code ${result.status}`;
154
+ const errorMsg = stderr || `osascript exited with code ${result.status}`;
137
155
  throw new Error(errorMsg);
138
156
  }
139
157
 
@@ -44,6 +44,7 @@ const EMAIL_RE = /^[A-Za-z0-9._%+'-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{
44
44
  const PHONE_RE = /^[+(]?[0-9(][0-9 ().-]{4,24}$/;
45
45
  const MESSAGE_ID_RE = /^<?[A-Za-z0-9!#$%&'*+/=?^_{|}~.@-]{1,500}>?$/;
46
46
  const EVENT_UID_RE = /^[A-Za-z0-9._:@+-]{1,255}$/;
47
+ const EVENTKIT_ID_RE = /^[A-Za-z0-9._:@+/=-]{1,500}$/;
47
48
  const CONTACT_ID_RE = /^[A-Za-z0-9._:-]{1,255}$/;
48
49
  const CHAT_GUID_RE = /^[A-Za-z0-9;:+._@-]{1,255}$/;
49
50
  const LABEL_RE = /^[A-Za-z][A-Za-z ]{0,19}$/;
@@ -80,6 +81,13 @@ export function validateEventId(value) {
80
81
  return EVENT_UID_RE.test(trimmed) ? trimmed : null;
81
82
  }
82
83
 
84
+ /** EventKit `eventIdentifier` (may include /RID= for recurrences). */
85
+ export function validateEventKitId(value) {
86
+ if (typeof value !== "string") return null;
87
+ const trimmed = value.trim();
88
+ return EVENTKIT_ID_RE.test(trimmed) ? trimmed : null;
89
+ }
90
+
83
91
  export function validateContactId(value) {
84
92
  if (typeof value !== "string") return null;
85
93
  const trimmed = value.trim();
@@ -63,7 +63,7 @@ export function planAfterDelegation({ delivered, response }) {
63
63
  */
64
64
  export function tccFallbackAdvice({ bridgeAvailable }) {
65
65
  if (bridgeAvailable) {
66
- return "The indexer daemon was reachable but the write was still denied; grant the daemon's node binary Full Disk Access (reads) and Allow node in System Settings > Privacy & Security > Automation for Contacts.app and Calendar.app. Do not add node via + in the Contacts or Calendars privacy lists — those panes often have no Add button.";
66
+ return "The indexer daemon was reachable but the write was still denied; grant the daemon's node binary Full Disk Access (reads) and Allow node in System Settings > Privacy & Security > Automation for Mail.app, Messages.app, Contacts.app, and Calendar.app. A hang or timeout on Mail compose is TCC / Automation denied, not Mail.app missing. Do not add node via + in the Contacts or Calendars privacy lists — those panes often have no Add button.";
67
67
  }
68
- return "No indexer daemon is running on this Mac. Start apple-tools-indexer (the LaunchAgent) so writes execute under node, which macOS can grant Contacts/Calendar access to directly.";
68
+ return "No indexer daemon is running on this Mac. Start apple-tools-indexer (the LaunchAgent) so writes execute under node, which macOS can grant Mail, Messages, Contacts, and Calendar Automation to directly.";
69
69
  }
package/lib/writeTools.js CHANGED
@@ -207,6 +207,7 @@ export const WRITE_TOOL_DEFINITIONS = [
207
207
  type: "object",
208
208
  properties: {
209
209
  event_id: { type: "string", description: "Event ID from calendar_date or calendar_add (required)" },
210
+ eventkit_id: { type: "string", description: "EventKit eventIdentifier from calendar_add (via EventKit). Prefer this over Calendar.app uid lookup." },
210
211
  title: { type: "string", description: "New title" },
211
212
  start: { type: "string", description: "New start as YYYY-MM-DD HH:MM local time" },
212
213
  end: { type: "string", description: "New end as YYYY-MM-DD HH:MM local time" },
@@ -231,7 +232,9 @@ export const WRITE_TOOL_DEFINITIONS = [
231
232
  inputSchema: {
232
233
  type: "object",
233
234
  properties: {
234
- event_id: { type: "string", description: "Event ID from calendar_date (required)" },
235
+ event_id: { type: "string", description: "Event ID from calendar_date or calendar_add (required)" },
236
+ eventkit_id: { type: "string", description: "Optional EventKit eventIdentifier returned by calendar_add (via EventKit). Needed so writeOnly EventKit can delete the event it created." },
237
+ calendar_name: { type: "string", description: "Optional calendar from calendar_list_calendars; narrows the AppleScript delete so Calendar does not scan every calendar" },
235
238
  ...CONFIRM_PROPS
236
239
  },
237
240
  required: ["event_id"]
@@ -327,6 +330,13 @@ export const WRITE_TOOL_HANDLERS = {
327
330
  contacts_remove: contactsRemove
328
331
  };
329
332
 
333
+ /**
334
+ * Smoke-only helpers, never MCP write tools. CallTool uses isWriteTool, so
335
+ * these names must stay off WRITE_TOOL_HANDLERS or a client can invoke them
336
+ * without dry_run / confirm.
337
+ */
338
+ export const SMOKE_ONLY_AUTOMATION_PROBES = ["mail_automation_probe", "messages_automation_probe"];
339
+
330
340
  export const WRITE_TOOL_NAMES = Object.keys(WRITE_TOOL_HANDLERS);
331
341
 
332
342
  export function isWriteTool(name) {
@@ -368,7 +378,7 @@ export async function dispatchWriteTool(name, args = {}, deps = {}) {
368
378
  log = () => {}
369
379
  } = deps;
370
380
 
371
- if (!isWriteTool(name)) {
381
+ if (SMOKE_ONLY_AUTOMATION_PROBES.includes(name) || !isWriteTool(name)) {
372
382
  return { ok: false, message: `Unknown write tool: ${name}` };
373
383
  }
374
384
 
@@ -389,7 +399,12 @@ export async function dispatchWriteTool(name, args = {}, deps = {}) {
389
399
  }
390
400
 
391
401
  const result = runLocally(name, args);
392
- if (result && result.ok === false && isTccDenial(result.message)) {
402
+ if (
403
+ result &&
404
+ result.ok === false &&
405
+ result.suppressTccAdvice !== true &&
406
+ isTccDenial(result.message)
407
+ ) {
393
408
  return { ...result, message: `${result.message} ${tccFallbackAdvice({ bridgeAvailable })}` };
394
409
  }
395
410
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "MCP server for semantic search and write actions across Apple Mail, Messages, Calendar, and Contacts",
5
5
  "type": "module",
6
6
  "main": "index.js",