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.
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Messages (iMessage / SMS) write operations.
3
+ *
4
+ * Supported identifiers:
5
+ * - `to`: a phone number in E.164 form (+15551234567) or an Apple ID email.
6
+ * One or more; more than one requires confirmation.
7
+ * - `chat_id`: an existing chat GUID from chat.db (for example
8
+ * "iMessage;-;+15551234567" for a 1:1 chat or "iMessage;+;chat123456789"
9
+ * for a group). The GUID is verified against chat.db before sending, so a
10
+ * made-up chat id is refused rather than silently delivered elsewhere.
11
+ */
12
+
13
+ import fs from "fs";
14
+ import path from "path";
15
+ import { safeSqlite3Json } from "./shell.js";
16
+ import { escapeSQL } from "./validators.js";
17
+ import { runAppleScript, asString, MESSAGES_TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
18
+ import {
19
+ planWrite,
20
+ normalizeList,
21
+ isMessagesHandle,
22
+ validateChatGuid,
23
+ validateBody,
24
+ writeErrorMessage,
25
+ writeSuccessMessage,
26
+ isFlagTrue,
27
+ truncate,
28
+ MAX_RECIPIENTS
29
+ } from "./writeGuards.js";
30
+
31
+ const CHAT_DB = path.join(process.env.HOME || "", "Library", "Messages", "chat.db");
32
+
33
+ export const SERVICE_IMESSAGE = "imessage";
34
+ export const SERVICE_SMS = "sms";
35
+ export const SERVICE_AUTO = "auto";
36
+
37
+ /**
38
+ * Look up a chat GUID in chat.db and count its participants.
39
+ * @returns {{ found: boolean, participantCount: number, displayName: string, error: string|null }}
40
+ */
41
+ export function lookupChat(guid, { queryFn = safeSqlite3Json, dbPath = CHAT_DB } = {}) {
42
+ try {
43
+ if (!fs.existsSync(dbPath)) {
44
+ return { found: false, participantCount: 0, displayName: "", error: "Messages database not found" };
45
+ }
46
+ } catch {
47
+ return { found: false, participantCount: 0, displayName: "", error: "Messages database not readable" };
48
+ }
49
+
50
+ const query = `
51
+ SELECT c.guid AS guid,
52
+ COALESCE(c.display_name, '') AS displayName,
53
+ COUNT(chj.handle_id) AS participantCount
54
+ FROM chat c
55
+ LEFT JOIN chat_handle_join chj ON chj.chat_id = c.ROWID
56
+ WHERE c.guid = '${escapeSQL(guid)}'
57
+ GROUP BY c.ROWID
58
+ LIMIT 1
59
+ `;
60
+
61
+ try {
62
+ const rows = queryFn(dbPath, query, { timeout: 15000 });
63
+ if (!rows || rows.length === 0) {
64
+ return { found: false, participantCount: 0, displayName: "", error: null };
65
+ }
66
+ return {
67
+ found: true,
68
+ participantCount: Number(rows[0].participantCount) || 1,
69
+ displayName: rows[0].displayName || "",
70
+ error: null
71
+ };
72
+ } catch (e) {
73
+ return { found: false, participantCount: 0, displayName: "", error: e.message };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Validate an attachment path supplied by the caller.
79
+ * The file must already exist on this host; the tool never creates files.
80
+ */
81
+ export function validateAttachmentPath(value) {
82
+ if (value === undefined || value === null || value === "") return { filePath: null, error: null };
83
+ if (typeof value !== "string") return { filePath: null, error: "attachment_path must be a string" };
84
+ if (!path.isAbsolute(value)) return { filePath: null, error: "attachment_path must be an absolute path on this Mac" };
85
+ if (value.includes("\u0000")) return { filePath: null, error: "attachment_path contains invalid characters" };
86
+
87
+ let resolved;
88
+ try {
89
+ resolved = fs.realpathSync(value);
90
+ const stat = fs.statSync(resolved);
91
+ if (!stat.isFile()) return { filePath: null, error: "attachment_path must point to a file" };
92
+ } catch {
93
+ return { filePath: null, error: "attachment_path does not exist on this Mac" };
94
+ }
95
+ return { filePath: resolved, error: null };
96
+ }
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
+
130
+ function serviceHandler() {
131
+ return `on atmService(kind)
132
+ tell application "Messages"
133
+ repeat with acc in accounts
134
+ try
135
+ if kind is "sms" then
136
+ if (service type of acc) is SMS then return acc
137
+ else
138
+ if (service type of acc) is iMessage then return acc
139
+ end if
140
+ end try
141
+ end repeat
142
+ end tell
143
+ if kind is "sms" then
144
+ error "SMS_SERVICE_NOT_FOUND"
145
+ else
146
+ error "IMESSAGE_SERVICE_NOT_FOUND"
147
+ end if
148
+ end atmService`;
149
+ }
150
+
151
+ export function buildSendToHandlesScript({ handles, text, service, attachmentPath }) {
152
+ const sends = handles
153
+ .map((handle) => {
154
+ const lines = [` set theTarget to participant ${asString(handle)} of targetService`];
155
+ if (text) lines.push(` send ${asString(text)} to theTarget`);
156
+ if (attachmentPath) lines.push(` send (POSIX file ${asString(attachmentPath)}) to theTarget`);
157
+ return lines.join("\n");
158
+ })
159
+ .join("\n");
160
+
161
+ const primary = service === SERVICE_SMS ? "sms" : "imessage";
162
+ const fallback = service === SERVICE_AUTO
163
+ ? `if targetService is missing value then
164
+ set targetService to atmService("sms")
165
+ end if`
166
+ : "";
167
+
168
+ return `${serviceHandler()}
169
+
170
+ set targetService to missing value
171
+ try
172
+ set targetService to atmService(${asString(primary)})
173
+ end try
174
+ ${fallback}
175
+ if targetService is missing value then error "IMESSAGE_SERVICE_NOT_FOUND"
176
+ tell application "Messages"
177
+ ${sends}
178
+ end tell
179
+ return "OK"`;
180
+ }
181
+
182
+ export function buildSendToChatScript({ chatGuid, text, attachmentPath }) {
183
+ const lines = [` set theChat to chat id ${asString(chatGuid)}`];
184
+ if (text) lines.push(` send ${asString(text)} to theChat`);
185
+ if (attachmentPath) lines.push(` send (POSIX file ${asString(attachmentPath)}) to theChat`);
186
+
187
+ return `tell application "Messages"
188
+ ${lines.join("\n")}
189
+ end tell
190
+ return "OK"`;
191
+ }
192
+
193
+ function failure(action, summary, result, secrets) {
194
+ if (result.kind === "tcc" || result.kind === "timeout") {
195
+ return `${action} failed — attempted to ${summary}. ${MESSAGES_TCC_GUIDANCE}`;
196
+ }
197
+ if (result.kind === "attribution") {
198
+ return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
199
+ }
200
+ if (result.kind === "app_unavailable") {
201
+ return `${action} failed — attempted to ${summary}. Messages.app could not be reached on this host.`;
202
+ }
203
+ const raw = String(result.error || "");
204
+ if (raw.includes("SMS_SERVICE_NOT_FOUND")) {
205
+ return `${action} failed — attempted to ${summary}. No SMS relay service is configured in Messages (Text Message Forwarding).`;
206
+ }
207
+ if (raw.includes("IMESSAGE_SERVICE_NOT_FOUND")) {
208
+ return `${action} failed — attempted to ${summary}. No enabled iMessage account was found in Messages.`;
209
+ }
210
+ return writeErrorMessage(action, summary, new Error(raw || "unknown error"), secrets);
211
+ }
212
+
213
+ /**
214
+ * Send an iMessage/SMS to one or more handles, or into an existing chat.
215
+ */
216
+ export function messagesSend(args = {}, deps = {}) {
217
+ const action = "messages_send";
218
+
219
+ const body = validateBody(args.text, { required: false, field: "text" });
220
+ if (body.error) return { ok: false, message: `${action} refused: ${body.error}` };
221
+
222
+ const attachment = validateAttachmentPath(args.attachment_path);
223
+ if (attachment.error) return { ok: false, message: `${action} refused: ${attachment.error}` };
224
+
225
+ if (!body.text && !attachment.filePath) {
226
+ return { ok: false, message: `${action} refused: provide text, attachment_path, or both.` };
227
+ }
228
+
229
+ const serviceRaw = args.service === undefined ? SERVICE_AUTO : String(args.service).toLowerCase();
230
+ if (![SERVICE_AUTO, SERVICE_IMESSAGE, SERVICE_SMS].includes(serviceRaw)) {
231
+ return { ok: false, message: `${action} refused: service must be "auto", "imessage", or "sms"` };
232
+ }
233
+
234
+ const dryRun = isFlagTrue(args.dry_run);
235
+ const confirm = isFlagTrue(args.confirm);
236
+
237
+ if (args.chat_id) {
238
+ const guid = validateChatGuid(args.chat_id);
239
+ if (!guid) return { ok: false, message: `${action} refused: chat_id is not a valid chat GUID` };
240
+
241
+ const chat = lookupChat(guid, deps);
242
+ if (chat.error) {
243
+ return { ok: false, message: `${action} refused: could not verify chat_id against the Messages database (${truncate(chat.error, 120)})` };
244
+ }
245
+ if (!chat.found) {
246
+ return { ok: false, message: `${action} refused: chat_id ${truncate(guid, 80)} was not found in the Messages database. Use a chat GUID from an existing conversation; this tool does not invent chats.` };
247
+ }
248
+
249
+ const isGroup = chat.participantCount > 1;
250
+ const label = chat.displayName ? `"${truncate(chat.displayName, 60)}"` : guid;
251
+ const summary = `send a message to chat ${label} (${chat.participantCount} participant${chat.participantCount === 1 ? "" : "s"})${attachment.filePath ? " with an attachment" : ""}`;
252
+
253
+ const plan = planWrite({
254
+ action,
255
+ summary,
256
+ recipientCount: isGroup ? chat.participantCount : 1,
257
+ dryRun,
258
+ confirm
259
+ });
260
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
261
+
262
+ const result = runAppleScript(
263
+ buildSendToChatScript({ chatGuid: guid, text: body.text, attachmentPath: attachment.filePath }),
264
+ { timeout: 60000, appName: "Messages" }
265
+ );
266
+ if (!result.ok) return { ok: false, message: failure(action, summary, result, [body.text]) };
267
+
268
+ return {
269
+ ok: true,
270
+ message: writeSuccessMessage(action, "message sent", {
271
+ chat_id: guid,
272
+ participants: String(chat.participantCount),
273
+ attachment: attachment.filePath ? path.basename(attachment.filePath) : undefined
274
+ })
275
+ };
276
+ }
277
+
278
+ const handles = normalizeList(args.to);
279
+ if (handles.length === 0) {
280
+ return { ok: false, message: `${action} refused: "to" (phone number or Apple ID email) or "chat_id" is required. This tool never invents recipients.` };
281
+ }
282
+ if (handles.length > MAX_RECIPIENTS) {
283
+ return { ok: false, message: `${action} refused: ${handles.length} recipients exceeds the per-call limit of ${MAX_RECIPIENTS}` };
284
+ }
285
+ const invalid = handles.filter((h) => !isMessagesHandle(h));
286
+ if (invalid.length > 0) {
287
+ return { ok: false, message: `${action} refused: invalid recipient(s): ${invalid.map((h) => truncate(h, 40)).join(", ")}. Use E.164 phone numbers (+15551234567) or Apple ID emails.` };
288
+ }
289
+
290
+ const summary = `send a message to ${handles.join(", ")} over ${serviceRaw}${attachment.filePath ? " with an attachment" : ""}`;
291
+ const plan = planWrite({ action, summary, recipientCount: handles.length, dryRun, confirm });
292
+ if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
293
+
294
+ const result = runAppleScript(
295
+ buildSendToHandlesScript({
296
+ handles,
297
+ text: body.text,
298
+ service: serviceRaw,
299
+ attachmentPath: attachment.filePath
300
+ }),
301
+ { timeout: 60000, appName: "Messages" }
302
+ );
303
+ if (!result.ok) return { ok: false, message: failure(action, summary, result, [body.text]) };
304
+
305
+ return {
306
+ ok: true,
307
+ message: writeSuccessMessage(action, "message sent", {
308
+ to: handles.join(", "),
309
+ service: serviceRaw,
310
+ attachment: attachment.filePath ? path.basename(attachment.filePath) : undefined
311
+ })
312
+ };
313
+ }
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
 
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Local write bridge between a short-lived MCP stdio process and the
3
+ * long-lived indexer daemon.
4
+ *
5
+ * Why this exists: macOS attributes an Apple event (and Contacts/Calendar
6
+ * access) to the *responsible process* of whoever sends it. When a host app
7
+ * spawns this server over stdio, the host - not node - is responsible, so a
8
+ * host without the Contacts/Calendars automation entitlement makes those
9
+ * writes fail regardless of node's own Full Disk Access. The indexer daemon
10
+ * is started by launchd, so node is responsible for its Apple events.
11
+ *
12
+ * The daemon therefore listens on a user-only unix socket and performs writes
13
+ * on behalf of stdio clients. Nothing leaves the machine: AF_UNIX socket,
14
+ * 0600, inside ~/.apple-tools-mcp/.
15
+ */
16
+
17
+ import net from "net";
18
+ import fs from "fs";
19
+ import path from "path";
20
+
21
+ export const WRITE_SOCKET_NAME = "writer.sock";
22
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 90000;
23
+ const MAX_FRAME_BYTES = 1024 * 1024;
24
+
25
+ export function defaultSocketPath(home = process.env.HOME || "") {
26
+ return path.join(home, ".apple-tools-mcp", WRITE_SOCKET_NAME);
27
+ }
28
+
29
+ /**
30
+ * Start the bridge server. Safe to call when a stale socket file is left over
31
+ * from a crash: an unconnectable socket file is removed first.
32
+ *
33
+ * @param {object} opts
34
+ * @param {string} opts.socketPath
35
+ * @param {(tool: string, args: object) => Promise<object>|object} opts.handler
36
+ * @param {(msg: string) => void} [opts.log]
37
+ * @returns {Promise<{ socketPath: string, close: () => void }>}
38
+ */
39
+ export function startWriteBridgeServer({ socketPath, handler, log = () => {} }) {
40
+ return new Promise((resolve, reject) => {
41
+ const dir = path.dirname(socketPath);
42
+ try {
43
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
44
+ } catch (e) {
45
+ reject(e);
46
+ return;
47
+ }
48
+
49
+ const server = net.createServer({ allowHalfOpen: false }, (socket) => {
50
+ socket.setEncoding("utf8");
51
+ let buffer = "";
52
+ let handled = false;
53
+
54
+ socket.on("data", async (chunk) => {
55
+ if (handled) return;
56
+ buffer += chunk;
57
+ if (buffer.length > MAX_FRAME_BYTES) {
58
+ socket.end(JSON.stringify({ ok: false, message: "Request too large" }) + "\n");
59
+ return;
60
+ }
61
+ const newline = buffer.indexOf("\n");
62
+ if (newline === -1) return;
63
+ handled = true;
64
+
65
+ let response;
66
+ try {
67
+ const request = JSON.parse(buffer.slice(0, newline));
68
+ response = await handler(request.tool, request.args || {});
69
+ } catch (e) {
70
+ response = { ok: false, message: `Write bridge error: ${e.message}` };
71
+ }
72
+ socket.end(JSON.stringify(response) + "\n");
73
+ });
74
+
75
+ socket.on("error", () => socket.destroy());
76
+ });
77
+
78
+ server.on("error", (e) => {
79
+ if (e.code === "EADDRINUSE") {
80
+ // Either a live daemon or a stale file. Probe it: a refused connect
81
+ // means nothing is listening, so the file can be replaced.
82
+ probeSocket(socketPath)
83
+ .then((alive) => {
84
+ if (alive) {
85
+ reject(new Error("Another apple-tools-mcp write bridge is already listening"));
86
+ return;
87
+ }
88
+ try {
89
+ fs.unlinkSync(socketPath);
90
+ } catch {
91
+ // best effort; listen will fail again below if it is still there
92
+ }
93
+ server.listen(socketPath, () => finish());
94
+ })
95
+ .catch(reject);
96
+ return;
97
+ }
98
+ reject(e);
99
+ });
100
+
101
+ const finish = () => {
102
+ try {
103
+ fs.chmodSync(socketPath, 0o600);
104
+ } catch {
105
+ // Socket files on some filesystems reject chmod; the parent dir is 0700.
106
+ }
107
+ log(`Write bridge listening at ${socketPath}`);
108
+ resolve({
109
+ socketPath,
110
+ close: () => {
111
+ try {
112
+ server.close();
113
+ } catch {
114
+ // already closed
115
+ }
116
+ try {
117
+ fs.unlinkSync(socketPath);
118
+ } catch {
119
+ // already gone
120
+ }
121
+ }
122
+ });
123
+ };
124
+
125
+ server.listen(socketPath, finish);
126
+ });
127
+ }
128
+
129
+ /**
130
+ * @returns {Promise<boolean>} true when something is listening on the socket
131
+ */
132
+ export function probeSocket(socketPath, timeoutMs = 1000) {
133
+ return new Promise((resolve) => {
134
+ let settled = false;
135
+ const done = (value) => {
136
+ if (settled) return;
137
+ settled = true;
138
+ try {
139
+ client.destroy();
140
+ } catch {
141
+ // ignore
142
+ }
143
+ resolve(value);
144
+ };
145
+
146
+ const client = net.connect(socketPath);
147
+ client.setTimeout(timeoutMs);
148
+ client.on("connect", () => done(true));
149
+ client.on("error", () => done(false));
150
+ client.on("timeout", () => done(false));
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Ask the daemon to run a write.
156
+ *
157
+ * @returns {Promise<{ delivered: boolean, response: object|null, error: string|null }>}
158
+ */
159
+ export function requestWriteViaBridge({ socketPath, tool, args, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS }) {
160
+ return new Promise((resolve) => {
161
+ if (!socketPath) {
162
+ resolve({ delivered: false, response: null, error: "no socket path" });
163
+ return;
164
+ }
165
+
166
+ let settled = false;
167
+ let buffer = "";
168
+ const finish = (value) => {
169
+ if (settled) return;
170
+ settled = true;
171
+ try {
172
+ client.destroy();
173
+ } catch {
174
+ // ignore
175
+ }
176
+ resolve(value);
177
+ };
178
+
179
+ const client = net.connect(socketPath);
180
+ client.setEncoding("utf8");
181
+ client.setTimeout(timeoutMs);
182
+
183
+ client.on("connect", () => {
184
+ client.write(JSON.stringify({ tool, args: args || {} }) + "\n");
185
+ });
186
+
187
+ client.on("data", (chunk) => {
188
+ buffer += chunk;
189
+ const newline = buffer.indexOf("\n");
190
+ if (newline === -1) return;
191
+ try {
192
+ finish({ delivered: true, response: JSON.parse(buffer.slice(0, newline)), error: null });
193
+ } catch (e) {
194
+ finish({ delivered: false, response: null, error: `malformed bridge response: ${e.message}` });
195
+ }
196
+ });
197
+
198
+ client.on("end", () => {
199
+ if (settled) return;
200
+ if (buffer.trim().length > 0) {
201
+ try {
202
+ finish({ delivered: true, response: JSON.parse(buffer.trim()), error: null });
203
+ return;
204
+ } catch {
205
+ // fall through to the generic failure below
206
+ }
207
+ }
208
+ finish({ delivered: false, response: null, error: "write bridge closed without a response" });
209
+ });
210
+
211
+ client.on("timeout", () => finish({ delivered: false, response: null, error: "write bridge timed out" }));
212
+ client.on("error", (e) => finish({ delivered: false, response: null, error: e.code || e.message }));
213
+ });
214
+ }