apple-tools-mcp 2.1.5 → 3.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
@@ -85,10 +85,10 @@ let db = null;
85
85
  async function getEmbedder() {
86
86
  if (!embeddingPipeline) {
87
87
  console.error("Loading embedding model (first time may take a minute)...");
88
- // Lazy import: @xenova/transformers loads sharp at import time. A static
88
+ // Lazy import: @huggingface/transformers loads sharp at import time. A static
89
89
  // import crashes Linux (and any host without the platform sharp native)
90
90
  // before search.js formatters / pronoun helpers can load.
91
- const { pipeline } = await import("@xenova/transformers");
91
+ const { pipeline } = await import("@huggingface/transformers");
92
92
  embeddingPipeline = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2");
93
93
  console.error("Embedding model loaded.");
94
94
  }
@@ -44,6 +44,17 @@ export const RECURRENCE_FREQUENCIES = ["daily", "weekly", "monthly", "yearly"];
44
44
  export const RECURRENCE_DAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
45
45
  export const RSVP_RESPONSES = ["accept", "decline", "tentative"];
46
46
 
47
+ // Regression (2026-09-23): calendar_edit's classic-AppleScript fallback and
48
+ // calendar_rsvp both ran atmFindEvent (see findEventHandler below) under a
49
+ // flat 60000ms runAppleScript timeout -- exactly matching the MCP client
50
+ // SDK's own default 60s CallTool timeout, so the client could give up with
51
+ // a raw "Request timed out" before the server ever got to answer, even
52
+ // though the underlying operation kept running and generally succeeded
53
+ // (same class of bug already fixed for mail_send/reply/forward). Trimmed
54
+ // under that ceiling to leave real margin, including for atmFindEvent's own
55
+ // retry loop (~8s worst case).
56
+ export const CALENDAR_FIND_TIMEOUT_MS = 45000;
57
+
47
58
  const RSVP_STATUS = {
48
59
  accept: "accepted",
49
60
  decline: "declined",
@@ -163,16 +174,36 @@ export function defaultEndParts(start, allDay) {
163
174
  };
164
175
  }
165
176
 
177
+ /**
178
+ * calendar_edit / calendar_rsvp's classic-AppleScript fallback both resolve
179
+ * an event by uid through this handler. Regression (2026-09-23): RSVPing to
180
+ * an event created moments earlier through the daemon's separate EventKit
181
+ * session ("no event with that id was found") failed on the first lookup
182
+ * even though the event genuinely existed -- Calendar.app's classic
183
+ * AppleScript object model can lag behind a write made through EventKit.
184
+ * An EventKit-based fix isn't available here: writeOnly EventKit can only
185
+ * re-query an event via the exact in-memory EKEvent object from the
186
+ * session that created it (see describeEventKitWriteFailure below), which
187
+ * doesn't exist for a real invitation synced in from elsewhere. Retrying
188
+ * the classic lookup a few times gives Calendar.app's own model a chance
189
+ * to catch up, well inside callers' existing 60s budget.
190
+ */
166
191
  function findEventHandler() {
167
192
  return `on atmFindEvent(theUid)
168
- tell application "Calendar"
169
- repeat with cal in calendars
170
- try
171
- set hits to (every event of cal whose uid is theUid)
172
- if (count of hits) > 0 then return item 1 of hits
173
- end try
174
- end repeat
175
- end tell
193
+ set attemptsLeft to 5
194
+ repeat
195
+ tell application "Calendar"
196
+ repeat with cal in calendars
197
+ try
198
+ set hits to (every event of cal whose uid is theUid)
199
+ if (count of hits) > 0 then return item 1 of hits
200
+ end try
201
+ end repeat
202
+ end tell
203
+ set attemptsLeft to attemptsLeft - 1
204
+ if attemptsLeft is 0 then exit repeat
205
+ delay 2
206
+ end repeat
176
207
  error "EVENT_NOT_FOUND"
177
208
  end atmFindEvent`;
178
209
  }
@@ -503,11 +534,19 @@ function addRow(c) {
503
534
  var local = type === 0 ? "yes" : "no";
504
535
  rows.push(name + "<<>>" + writable + "<<>>" + local + "<<>>" + type + "<<>>" + srcName);
505
536
  }
537
+ var listError = "";
506
538
  try {
507
539
  var cals = store.calendarsForEntityType($.EKEntityTypeEvent);
508
540
  for (var i = 0; i < cals.count; i++) addRow(cals.objectAtIndex(i));
509
- } catch (e) {}
510
- try { addRow(store.defaultCalendarForNewEvents); } catch (e) {}
541
+ } catch (e) {
542
+ listError = String(e);
543
+ }
544
+ try { addRow(store.defaultCalendarForNewEvents); } catch (e) {
545
+ if (!listError) listError = String(e);
546
+ }
547
+ if (rows.length === 0 && listError) {
548
+ throw new Error("EVENTKIT_LIST_FAILED: " + listError);
549
+ }
511
550
  rows.join("|||");`;
512
551
  }
513
552
 
@@ -659,26 +698,37 @@ set AppleScript's text item delimiters to "|||"
659
698
  return outputList as string`;
660
699
 
661
700
  const result = runAppleScript(script, { timeout: 30000, appName: "Calendar" });
662
- if (!result.ok) {
663
- return { ok: false, message: failure(action, "list calendars", result) };
664
- }
665
-
666
- let calendars = result.output
667
- .split("|||")
668
- .map((entry) => entry.trim())
669
- .filter((entry) => entry.length > 0)
670
- .map((entry) => {
671
- const [name, writable] = entry.split("<<>>");
672
- return { name: name || "", writable: writable !== "no" };
673
- });
674
701
 
702
+ // EventKit does not require Calendar.app to be running, so a real EventKit
703
+ // list can stand in when the classic AppleScript path fails (app closed).
704
+ // An empty EventKit string is not that list: the JXA helper used to swallow
705
+ // every error and still exit 0, which hid TCC / timeout / not-running.
675
706
  const ekList = runAppleScript(buildEventKitListCalendarsScript(), {
676
707
  timeout: 15000,
677
708
  appName: "Calendar",
678
709
  language: "JavaScript"
679
710
  });
680
- if (ekList.ok) {
681
- calendars = mergeCalendarSources(calendars, parseEventKitCalendarList(ekList.output));
711
+ const ekCalendars = ekList.ok ? parseEventKitCalendarList(ekList.output) : [];
712
+
713
+ if (!result.ok && ekCalendars.length === 0) {
714
+ return { ok: false, message: failure(action, "list calendars", result) };
715
+ }
716
+
717
+ let calendars = result.ok
718
+ ? result.output
719
+ .split("|||")
720
+ .map((entry) => entry.trim())
721
+ .filter((entry) => entry.length > 0)
722
+ .map((entry) => {
723
+ const [name, writable] = entry.split("<<>>");
724
+ return { name: name || "", writable: writable !== "no" };
725
+ })
726
+ : [];
727
+
728
+ if (ekCalendars.length > 0) {
729
+ calendars = result.ok
730
+ ? mergeCalendarSources(calendars, ekCalendars)
731
+ : ekCalendars;
682
732
  }
683
733
 
684
734
  if (calendars.length === 0) {
@@ -1115,7 +1165,7 @@ export function calendarEdit(args = {}) {
1115
1165
  return { ok: false, message: `${action} refused: ${e.message}` };
1116
1166
  }
1117
1167
 
1118
- const result = runAppleScript(script, { timeout: 60000, appName: "Calendar" });
1168
+ const result = runAppleScript(script, { timeout: CALENDAR_FIND_TIMEOUT_MS, appName: "Calendar" });
1119
1169
  if (!result.ok) return { ok: false, message: failure(action, summary, result, [updates.description || ""]) };
1120
1170
 
1121
1171
  return {
@@ -1284,10 +1334,16 @@ export function calendarRemove(args = {}) {
1284
1334
  }
1285
1335
 
1286
1336
  export function buildRsvpScript({ eventId, status, attendeeEmail }) {
1337
+ // Calendar.app's AppleScript dictionary defines `participation status` as
1338
+ // one of unknown/accepted/declined/tentative (confirmed via `sdef`/aete
1339
+ // dump) -- there is no "needs action" term, so that bare two-word phrase
1340
+ // was a syntax error (AppleScript parsed "needs" as an identifier and
1341
+ // choked on "action" where it expected "then"). `unknown` is the correct
1342
+ // term for "no answer yet".
1287
1343
  const match = attendeeEmail
1288
1344
  ? ` if (email of att) is ${asString(attendeeEmail)} then set theAttendee to att`
1289
1345
  : ` try
1290
- if (participation status of att) is needs action then set theAttendee to att
1346
+ if (participation status of att) is unknown then set theAttendee to att
1291
1347
  end try`;
1292
1348
 
1293
1349
  return `${findEventHandler()}
@@ -1344,7 +1400,7 @@ export function calendarRsvp(args = {}) {
1344
1400
 
1345
1401
  const result = runAppleScript(
1346
1402
  buildRsvpScript({ eventId, status: RSVP_STATUS[responseRaw], attendeeEmail }),
1347
- { timeout: 60000, appName: "Calendar" }
1403
+ { timeout: CALENDAR_FIND_TIMEOUT_MS, appName: "Calendar" }
1348
1404
  );
1349
1405
 
1350
1406
  if (!result.ok) {
package/lib/config.js CHANGED
@@ -32,7 +32,11 @@ export const MAX_INDEX_INTERVAL_MS = 6 * 60 * 60 * 1000;
32
32
  /** Recommended Mini always-on value (set in config.json, not the product default). */
33
33
  export const MINI_RECOMMENDED_INDEX_INTERVAL_MS = 60 * 1000;
34
34
 
35
- const KNOWN_CONFIG_KEYS = new Set(["indexInterval", "indexIntervalMs"]);
35
+ /** --transport=http bind address/port defaults. Network exposure is opt-in. */
36
+ export const DEFAULT_HTTP_HOST = "127.0.0.1";
37
+ export const DEFAULT_HTTP_PORT = 8421;
38
+
39
+ const KNOWN_CONFIG_KEYS = new Set(["indexInterval", "indexIntervalMs", "httpHost", "httpPort"]);
36
40
 
37
41
  const MAX_DURATION_STRING_LENGTH = 32;
38
42
 
@@ -310,3 +314,27 @@ export function logResolvedInterval(resolved, options = {}) {
310
314
  const clampedNote = resolved.clamped ? ", clamped" : "";
311
315
  log(`Effective index refresh interval: ${resolved.human} (${resolved.ms} ms) [source=${resolved.source}${clampedNote}]`);
312
316
  }
317
+
318
+ /**
319
+ * Resolve --transport=http bind host/port.
320
+ * Precedence (highest wins): env vars > config.json > product default.
321
+ *
322
+ * @param {{ env?: NodeJS.ProcessEnv, configPath?: string, fileData?: Record<string, unknown>, warn?: (msg: string) => void }} [options]
323
+ * @returns {{ host: string, port: number }}
324
+ */
325
+ export function resolveHttpServerConfig(options = {}) {
326
+ const env = options.env || process.env;
327
+ const warn = options.warn || defaultWarn;
328
+ const data =
329
+ options.fileData !== undefined
330
+ ? options.fileData
331
+ : loadConfigFile({ configPath: options.configPath, env, warn }).data;
332
+
333
+ const host = env.APPLE_TOOLS_HTTP_HOST || (typeof data.httpHost === "string" ? data.httpHost : undefined) || DEFAULT_HTTP_HOST;
334
+
335
+ const rawPort = env.APPLE_TOOLS_HTTP_PORT !== undefined ? env.APPLE_TOOLS_HTTP_PORT : data.httpPort;
336
+ const parsedPort = typeof rawPort === "number" ? rawPort : Number(rawPort);
337
+ const port = Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort < 65536 ? parsedPort : DEFAULT_HTTP_PORT;
338
+
339
+ return { host, port };
340
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Shared-secret auth for the HTTP transport (--transport=http).
3
+ *
4
+ * stdio mode needs no auth — a locally spawned child process is already
5
+ * trusted by whoever spawned it. HTTP mode is reachable over the LAN and,
6
+ * via Tailscale, from outside it, and this server exposes write tools
7
+ * (send iMessage, edit contacts/calendar), so every HTTP request must carry
8
+ * a bearer token.
9
+ *
10
+ * The token is a random secret generated once and stored in the macOS
11
+ * Keychain — never in a config file, the repo, or logs after the first
12
+ * (one-time, clearly labeled) print. There is no external account, no
13
+ * OAuth flow, and no cost: it's a self-issued password, like an SSH key.
14
+ */
15
+
16
+ import crypto from "crypto";
17
+ import { execFileSync as defaultExecFileSync } from "child_process";
18
+
19
+ export const KEYCHAIN_SERVICE = "apple-tools-mcp-http";
20
+ export const KEYCHAIN_ACCOUNT = "http-auth-token";
21
+
22
+ function readFromKeychain(exec) {
23
+ try {
24
+ const out = exec(
25
+ "/usr/bin/security",
26
+ ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w"],
27
+ { stdio: ["ignore", "pipe", "ignore"] }
28
+ );
29
+ const token = out.toString("utf8").trim();
30
+ return token.length > 0 ? token : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function writeToKeychain(exec, token) {
37
+ exec(
38
+ "/usr/bin/security",
39
+ ["add-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w", token, "-U"],
40
+ { stdio: ["ignore", "ignore", "pipe"] }
41
+ );
42
+ }
43
+
44
+ function generateToken(randomBytes) {
45
+ return randomBytes(32).toString("hex");
46
+ }
47
+
48
+ /**
49
+ * Loads the HTTP auth token from Keychain, generating and storing one the
50
+ * first time this runs on a given machine. Returns the token either way.
51
+ *
52
+ * @param {{
53
+ * log?: (msg: string) => void,
54
+ * forceNew?: boolean,
55
+ * execFileSync?: typeof defaultExecFileSync,
56
+ * randomBytes?: typeof crypto.randomBytes
57
+ * }} [options]
58
+ * @returns {{ token: string, generated: boolean }}
59
+ */
60
+ export function loadOrCreateHttpAuthToken(options = {}) {
61
+ const log = options.log || ((msg) => console.error(msg));
62
+ const exec = options.execFileSync || defaultExecFileSync;
63
+ const randomBytes = options.randomBytes || crypto.randomBytes;
64
+
65
+ if (!options.forceNew) {
66
+ const existing = readFromKeychain(exec);
67
+ if (existing) {
68
+ return { token: existing, generated: false };
69
+ }
70
+ }
71
+
72
+ const token = generateToken(randomBytes);
73
+ writeToKeychain(exec, token);
74
+ log("============================================================");
75
+ log("Generated a new HTTP auth token (shown once, stored in Keychain):");
76
+ log(token);
77
+ log("Use this as a Bearer token when configuring remote MCP clients,");
78
+ log("e.g. claude mcp add ... -H \"Authorization: Bearer " + token + "\"");
79
+ log("Retrieve it again later with: apple-tools-mcp http-token");
80
+ log("============================================================");
81
+ return { token, generated: true };
82
+ }
83
+
84
+ /**
85
+ * Constant-time check of an incoming `Authorization: Bearer <token>` header
86
+ * against the expected token. Never throws.
87
+ *
88
+ * @param {string | undefined | null} headerValue
89
+ * @param {string} expectedToken
90
+ * @returns {boolean}
91
+ */
92
+ export function verifyAuthHeader(headerValue, expectedToken) {
93
+ if (typeof headerValue !== "string") {
94
+ return false;
95
+ }
96
+ const match = /^Bearer\s+(.+)$/i.exec(headerValue.trim());
97
+ if (!match) {
98
+ return false;
99
+ }
100
+ const provided = Buffer.from(match[1], "utf8");
101
+ const expected = Buffer.from(expectedToken, "utf8");
102
+ if (provided.length !== expected.length) {
103
+ return false;
104
+ }
105
+ return crypto.timingSafeEqual(provided, expected);
106
+ }
@@ -0,0 +1,45 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
+
7
+ /** Forward the tool interface from an HTTP MCP server to a local stdio client. */
8
+ export function createProxyServer(remoteClient) {
9
+ const server = new Server(
10
+ { name: "apple-tools-http-proxy", version: "3.0.0" },
11
+ { capabilities: { tools: {} } },
12
+ );
13
+
14
+ server.setRequestHandler(ListToolsRequestSchema, (request) =>
15
+ remoteClient.listTools(request.params));
16
+ server.setRequestHandler(CallToolRequestSchema, (request) =>
17
+ remoteClient.callTool(request.params));
18
+
19
+ return server;
20
+ }
21
+
22
+ export async function runHttpStdioProxy({ url, token, input = process.stdin, output = process.stdout }) {
23
+ if (!token) throw new Error("APPLE_TOOLS_MCP_TOKEN is required");
24
+ const endpoint = new URL(url);
25
+ if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:") {
26
+ throw new Error("MCP URL must use http or https");
27
+ }
28
+
29
+ const remoteClient = new Client({ name: "apple-tools-http-proxy", version: "3.0.0" });
30
+ const remoteTransport = new StreamableHTTPClientTransport(endpoint, {
31
+ requestInit: { headers: { Authorization: `Bearer ${token}` } },
32
+ });
33
+ await remoteClient.connect(remoteTransport);
34
+
35
+ const server = createProxyServer(remoteClient);
36
+ try {
37
+ await server.connect(new StdioServerTransport(input, output));
38
+ } catch (error) {
39
+ await remoteClient.close();
40
+ throw error;
41
+ }
42
+
43
+ server.onclose = () => { void remoteClient.close(); };
44
+ return server;
45
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Authenticated request handler for the Streamable HTTP transport.
3
+ *
4
+ * Keep this separate from index.js so request parsing and authorization can
5
+ * be exercised without starting the MCP server or touching the Keychain.
6
+ */
7
+
8
+ /**
9
+ * @param {{
10
+ * token: string,
11
+ * verifyAuthHeader: (header: string | string[] | undefined, token: string) => boolean,
12
+ * createServer: () => { connect: (transport: unknown) => Promise<void>, close: () => Promise<void> },
13
+ * StreamableHTTPServerTransport: new (options: { sessionIdGenerator: undefined }) => { handleRequest: (req: unknown, res: unknown) => Promise<void>, close: () => Promise<void> },
14
+ * packageVersion: string,
15
+ * log?: (message: string) => void
16
+ * }} options
17
+ */
18
+ export function createHttpRequestHandler(options) {
19
+ const { token, verifyAuthHeader, createServer, StreamableHTTPServerTransport, packageVersion } = options;
20
+ const log = options.log || ((message) => console.error(message));
21
+
22
+ return (req, res) => {
23
+ if (!verifyAuthHeader(req.headers["authorization"], token)) {
24
+ res.writeHead(401, { "Content-Type": "application/json", "WWW-Authenticate": "Bearer" });
25
+ res.end(JSON.stringify({ error: "Unauthorized: missing or invalid bearer token" }));
26
+ return;
27
+ }
28
+
29
+ let url;
30
+ try {
31
+ // The request target is relative. Do not derive a URL base from the
32
+ // user-controlled Host header, which can itself be malformed.
33
+ url = new URL(req.url || "/", "http://localhost");
34
+ } catch {
35
+ res.writeHead(400, { "Content-Type": "application/json" });
36
+ res.end(JSON.stringify({ error: "bad request" }));
37
+ return;
38
+ }
39
+
40
+ if (url.pathname === "/health") {
41
+ res.writeHead(200, { "Content-Type": "application/json" });
42
+ res.end(JSON.stringify({ ok: true, version: packageVersion }));
43
+ return;
44
+ }
45
+
46
+ if (url.pathname !== "/mcp" && url.pathname !== "/") {
47
+ res.writeHead(404, { "Content-Type": "application/json" });
48
+ res.end(JSON.stringify({ error: "not found" }));
49
+ return;
50
+ }
51
+
52
+ void (async () => {
53
+ const requestServer = createServer();
54
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
55
+ try {
56
+ await requestServer.connect(transport);
57
+ await transport.handleRequest(req, res);
58
+ res.on("close", () => {
59
+ void transport.close();
60
+ void requestServer.close();
61
+ });
62
+ } catch (err) {
63
+ log(`MCP HTTP request error: ${err.message}`);
64
+ if (!res.headersSent) {
65
+ res.writeHead(500, { "Content-Type": "application/json" });
66
+ res.end(JSON.stringify({ error: "internal error" }));
67
+ }
68
+ }
69
+ })();
70
+ };
71
+ }