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.
- package/README.md +261 -7
- package/contacts.js +71 -19
- package/index.js +80 -4
- package/indexer.js +7 -2
- package/lib/appleScript.js +292 -0
- package/lib/calendarWrite.js +594 -0
- package/lib/contactsWrite.js +300 -0
- package/lib/mailWrite.js +464 -0
- package/lib/messagesWrite.js +281 -0
- package/lib/writeBridge.js +214 -0
- package/lib/writeGuards.js +250 -0
- package/lib/writeRouting.js +69 -0
- package/lib/writeTools.js +396 -0
- package/package.json +4 -2
- package/scripts/smoke-writes.js +313 -0
- package/search.js +3 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confirm / dry-run policy, identifier validation, and error redaction for the
|
|
3
|
+
* write tools.
|
|
4
|
+
*
|
|
5
|
+
* Every write tool routes its arguments through this module before any
|
|
6
|
+
* AppleScript runs:
|
|
7
|
+
* - destructive actions (trash mail, remove event, remove contact) and
|
|
8
|
+
* multi-recipient sends never execute without an explicit `confirm`
|
|
9
|
+
* - `dry_run` always wins and reports what would happen
|
|
10
|
+
* - failures name the action, ids, and recipients but never echo bodies
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Hard ceiling on a single send. Larger blasts must be split by the caller.
|
|
14
|
+
export const MAX_RECIPIENTS = 25;
|
|
15
|
+
|
|
16
|
+
// Longest body/content we accept in one write. Keeps AppleScript arguments
|
|
17
|
+
// bounded and avoids pathological osascript payloads.
|
|
18
|
+
export const MAX_BODY_LENGTH = 20000;
|
|
19
|
+
export const MAX_SUBJECT_LENGTH = 500;
|
|
20
|
+
|
|
21
|
+
export function isFlagTrue(value) {
|
|
22
|
+
return value === true || value === "true";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Accept either an array of strings or a comma-separated string.
|
|
27
|
+
* @param {string|string[]|null|undefined} value
|
|
28
|
+
* @returns {string[]}
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeList(value) {
|
|
31
|
+
if (value === undefined || value === null) return [];
|
|
32
|
+
const raw = Array.isArray(value) ? value : String(value).split(",");
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const entry of raw) {
|
|
35
|
+
if (entry === undefined || entry === null) continue;
|
|
36
|
+
const trimmed = String(entry).trim();
|
|
37
|
+
if (trimmed.length > 0) out.push(trimmed);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Linear-time patterns only (no nested quantifiers) - these run on tool input.
|
|
43
|
+
const EMAIL_RE = /^[A-Za-z0-9._%+'-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,63}$/;
|
|
44
|
+
const PHONE_RE = /^[+(]?[0-9(][0-9 ().-]{4,24}$/;
|
|
45
|
+
const MESSAGE_ID_RE = /^<?[A-Za-z0-9!#$%&'*+/=?^_{|}~.@-]{1,500}>?$/;
|
|
46
|
+
const EVENT_UID_RE = /^[A-Za-z0-9._:@+-]{1,255}$/;
|
|
47
|
+
const CONTACT_ID_RE = /^[A-Za-z0-9._:-]{1,255}$/;
|
|
48
|
+
const CHAT_GUID_RE = /^[A-Za-z0-9;:+._@-]{1,255}$/;
|
|
49
|
+
const LABEL_RE = /^[A-Za-z][A-Za-z ]{0,19}$/;
|
|
50
|
+
const CALENDAR_NAME_RE = /^[^\u0000-\u001f"\\]{1,100}$/;
|
|
51
|
+
|
|
52
|
+
export function isEmailAddress(value) {
|
|
53
|
+
return typeof value === "string" && value.length <= 254 && EMAIL_RE.test(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isPhoneNumber(value) {
|
|
57
|
+
if (typeof value !== "string" || !PHONE_RE.test(value)) return false;
|
|
58
|
+
const digits = value.replace(/\D/g, "");
|
|
59
|
+
return digits.length >= 5 && digits.length <= 15;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Messages accepts a phone number or an Apple ID email as a handle.
|
|
64
|
+
*/
|
|
65
|
+
export function isMessagesHandle(value) {
|
|
66
|
+
return isEmailAddress(value) || isPhoneNumber(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function validateMessageId(value) {
|
|
70
|
+
if (typeof value !== "string") return null;
|
|
71
|
+
const trimmed = value.trim();
|
|
72
|
+
if (!MESSAGE_ID_RE.test(trimmed)) return null;
|
|
73
|
+
// Mail's `message id` property has no angle brackets.
|
|
74
|
+
return trimmed.replace(/^</, "").replace(/>$/, "");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function validateEventId(value) {
|
|
78
|
+
if (typeof value !== "string") return null;
|
|
79
|
+
const trimmed = value.trim();
|
|
80
|
+
return EVENT_UID_RE.test(trimmed) ? trimmed : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function validateContactId(value) {
|
|
84
|
+
if (typeof value !== "string") return null;
|
|
85
|
+
const trimmed = value.trim();
|
|
86
|
+
return CONTACT_ID_RE.test(trimmed) ? trimmed : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function validateChatGuid(value) {
|
|
90
|
+
if (typeof value !== "string") return null;
|
|
91
|
+
const trimmed = value.trim();
|
|
92
|
+
return CHAT_GUID_RE.test(trimmed) ? trimmed : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validateCalendarName(value) {
|
|
96
|
+
if (typeof value !== "string") return null;
|
|
97
|
+
const trimmed = value.trim();
|
|
98
|
+
return CALENDAR_NAME_RE.test(trimmed) ? trimmed : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function validateLabel(value, fallback = "home") {
|
|
102
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
103
|
+
if (typeof value !== "string") return null;
|
|
104
|
+
const trimmed = value.trim();
|
|
105
|
+
return LABEL_RE.test(trimmed) ? trimmed : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate a recipient list for a mail field.
|
|
110
|
+
* @returns {{ addresses: string[], error: string|null }}
|
|
111
|
+
*/
|
|
112
|
+
export function validateEmailList(value, field) {
|
|
113
|
+
const list = normalizeList(value);
|
|
114
|
+
const addresses = [];
|
|
115
|
+
for (const entry of list) {
|
|
116
|
+
// Accept "Display Name <addr@host>" and bare addresses.
|
|
117
|
+
const angle = entry.match(/<([^<>]{1,254})>\s*$/);
|
|
118
|
+
const address = angle ? angle[1].trim() : entry;
|
|
119
|
+
if (!isEmailAddress(address)) {
|
|
120
|
+
return { addresses: [], error: `${field} contains an invalid email address: ${truncate(address, 80)}` };
|
|
121
|
+
}
|
|
122
|
+
addresses.push(address);
|
|
123
|
+
}
|
|
124
|
+
if (addresses.length > MAX_RECIPIENTS) {
|
|
125
|
+
return { addresses: [], error: `${field} has ${addresses.length} recipients; the per-call limit is ${MAX_RECIPIENTS}` };
|
|
126
|
+
}
|
|
127
|
+
return { addresses, error: null };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function validateBody(value, { required = false, field = "body" } = {}) {
|
|
131
|
+
if (value === undefined || value === null || value === "") {
|
|
132
|
+
if (required) return { text: null, error: `${field} is required` };
|
|
133
|
+
return { text: "", error: null };
|
|
134
|
+
}
|
|
135
|
+
if (typeof value !== "string") return { text: null, error: `${field} must be text` };
|
|
136
|
+
if (value.length > MAX_BODY_LENGTH) {
|
|
137
|
+
return { text: null, error: `${field} is ${value.length} characters; the limit is ${MAX_BODY_LENGTH}` };
|
|
138
|
+
}
|
|
139
|
+
return { text: value, error: null };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function validateSubject(value, { required = false } = {}) {
|
|
143
|
+
if (value === undefined || value === null || value === "") {
|
|
144
|
+
if (required) return { text: null, error: "subject is required" };
|
|
145
|
+
return { text: "", error: null };
|
|
146
|
+
}
|
|
147
|
+
if (typeof value !== "string") return { text: null, error: "subject must be text" };
|
|
148
|
+
if (value.length > MAX_SUBJECT_LENGTH) {
|
|
149
|
+
return { text: null, error: `subject is ${value.length} characters; the limit is ${MAX_SUBJECT_LENGTH}` };
|
|
150
|
+
}
|
|
151
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(value)) {
|
|
152
|
+
return { text: null, error: "subject contains control characters" };
|
|
153
|
+
}
|
|
154
|
+
return { text: value, error: null };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Decide whether a write may execute.
|
|
159
|
+
*
|
|
160
|
+
* `dry_run` always wins. Destructive actions and multi-recipient sends require
|
|
161
|
+
* `confirm: true`; everything else runs on a single call.
|
|
162
|
+
*
|
|
163
|
+
* @param {object} opts
|
|
164
|
+
* @param {string} opts.action - Tool name, e.g. "mail_send"
|
|
165
|
+
* @param {string} opts.summary - Human sentence naming ids/recipients/titles
|
|
166
|
+
* @param {boolean} [opts.destructive]
|
|
167
|
+
* @param {number} [opts.recipientCount]
|
|
168
|
+
* @param {boolean} [opts.dryRun]
|
|
169
|
+
* @param {boolean} [opts.confirm]
|
|
170
|
+
* @returns {{ decision: "dry_run"|"needs_confirm"|"execute", proceed: boolean, requiresConfirm: boolean, message: string|null }}
|
|
171
|
+
*/
|
|
172
|
+
export function planWrite({
|
|
173
|
+
action,
|
|
174
|
+
summary,
|
|
175
|
+
destructive = false,
|
|
176
|
+
recipientCount = 0,
|
|
177
|
+
dryRun = false,
|
|
178
|
+
confirm = false
|
|
179
|
+
}) {
|
|
180
|
+
const multiRecipient = recipientCount > 1;
|
|
181
|
+
const requiresConfirm = Boolean(destructive || multiRecipient);
|
|
182
|
+
|
|
183
|
+
if (dryRun) {
|
|
184
|
+
const tail = requiresConfirm
|
|
185
|
+
? " Re-run with dry_run=false and confirm=true to apply."
|
|
186
|
+
: " Re-run with dry_run=false to apply.";
|
|
187
|
+
return {
|
|
188
|
+
decision: "dry_run",
|
|
189
|
+
proceed: false,
|
|
190
|
+
requiresConfirm,
|
|
191
|
+
message: `DRY RUN (${action}): would ${summary}. Nothing was changed.${tail}`
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (requiresConfirm && !confirm) {
|
|
196
|
+
const why = destructive
|
|
197
|
+
? "This is a destructive action"
|
|
198
|
+
: `This send has ${recipientCount} recipients`;
|
|
199
|
+
return {
|
|
200
|
+
decision: "needs_confirm",
|
|
201
|
+
proceed: false,
|
|
202
|
+
requiresConfirm,
|
|
203
|
+
message: `CONFIRMATION REQUIRED (${action}): would ${summary}. ${why}, so nothing was changed. Re-run with confirm=true to apply, or dry_run=true for a preview.`
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { decision: "execute", proceed: true, requiresConfirm, message: null };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function truncate(text, max = 200) {
|
|
211
|
+
if (typeof text !== "string") return "";
|
|
212
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
213
|
+
return oneLine.length > max ? `${oneLine.slice(0, max)}...` : oneLine;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Remove caller-supplied content (bodies, subjects) from a string so error
|
|
218
|
+
* paths cannot leak message contents back to the client or the logs.
|
|
219
|
+
*/
|
|
220
|
+
export function scrubValues(text, values = []) {
|
|
221
|
+
let out = typeof text === "string" ? text : "";
|
|
222
|
+
for (const value of values) {
|
|
223
|
+
if (typeof value !== "string" || value.length < 4) continue;
|
|
224
|
+
while (out.includes(value)) {
|
|
225
|
+
out = out.replace(value, "[redacted]");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Build a failure line that names the action and target without echoing bodies.
|
|
233
|
+
*/
|
|
234
|
+
export function writeErrorMessage(action, summary, error, secrets = []) {
|
|
235
|
+
const raw = error && error.message ? error.message : String(error || "unknown error");
|
|
236
|
+
const reason = truncate(scrubValues(raw, secrets), 300);
|
|
237
|
+
return `${action} failed — attempted to ${summary}. Reason: ${reason}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Build the success line. Names what happened; never includes the body.
|
|
242
|
+
*/
|
|
243
|
+
export function writeSuccessMessage(action, summary, details = {}) {
|
|
244
|
+
const parts = [`${action}: ${summary}.`];
|
|
245
|
+
for (const [key, value] of Object.entries(details)) {
|
|
246
|
+
if (value === undefined || value === null || value === "") continue;
|
|
247
|
+
parts.push(`${key}: ${truncate(String(value), 200)}`);
|
|
248
|
+
}
|
|
249
|
+
return parts.join(" ");
|
|
250
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a write executes: in this process, or in the indexer daemon.
|
|
3
|
+
*
|
|
4
|
+
* macOS attributes Apple events and Contacts/Calendar access to the process
|
|
5
|
+
* *responsible* for the sender. A stdio MCP server inherits that
|
|
6
|
+
* responsibility from the host app that launched it, so Contacts and Calendar
|
|
7
|
+
* writes can be denied even when node has Full Disk Access. The indexer
|
|
8
|
+
* daemon is launched by launchd, so node is responsible for its own events.
|
|
9
|
+
*
|
|
10
|
+
* Policy:
|
|
11
|
+
* - The indexer daemon always executes locally (it is the privileged host).
|
|
12
|
+
* - An MCP stdio process delegates to the daemon when the write bridge is up.
|
|
13
|
+
* - If delegation is impossible, it runs locally and, on a TCC denial,
|
|
14
|
+
* explains the host constraint instead of reporting a generic failure.
|
|
15
|
+
*
|
|
16
|
+
* These helpers are pure so the routing policy is testable off-macOS.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Writes that macOS gates behind per-app privacy (TCC) rather than plain
|
|
21
|
+
* file permissions. All of them benefit from running in the daemon.
|
|
22
|
+
*/
|
|
23
|
+
export const TCC_SENSITIVE_PREFIXES = ["contacts_", "calendar_", "mail_", "messages_"];
|
|
24
|
+
|
|
25
|
+
export function isTccSensitiveWrite(toolName) {
|
|
26
|
+
return TCC_SENSITIVE_PREFIXES.some((prefix) => String(toolName || "").startsWith(prefix));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} state
|
|
31
|
+
* @param {boolean} state.indexerMode - true when this process is the daemon
|
|
32
|
+
* @param {boolean} state.bridgeAvailable - true when the daemon socket answered
|
|
33
|
+
* @param {string} state.toolName
|
|
34
|
+
* @returns {{ target: "local"|"daemon", reason: string }}
|
|
35
|
+
*/
|
|
36
|
+
export function planWriteRoute({ indexerMode, bridgeAvailable, toolName }) {
|
|
37
|
+
if (indexerMode) {
|
|
38
|
+
return { target: "local", reason: "this process is the indexer daemon" };
|
|
39
|
+
}
|
|
40
|
+
if (bridgeAvailable && isTccSensitiveWrite(toolName)) {
|
|
41
|
+
return { target: "daemon", reason: "the indexer daemon owns the privacy-approved automation context" };
|
|
42
|
+
}
|
|
43
|
+
return { target: "local", reason: bridgeAvailable ? "tool is not privacy-gated" : "no indexer daemon is listening" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Decide what to do after a delegated attempt.
|
|
48
|
+
* A bridge that is down or broken must not block the write: fall back to
|
|
49
|
+
* local execution so a single-host setup keeps working.
|
|
50
|
+
*
|
|
51
|
+
* @returns {{ fallbackLocal: boolean }}
|
|
52
|
+
*/
|
|
53
|
+
export function planAfterDelegation({ delivered, response }) {
|
|
54
|
+
if (!delivered) return { fallbackLocal: true };
|
|
55
|
+
if (!response || typeof response !== "object") return { fallbackLocal: true };
|
|
56
|
+
if (response.unsupported) return { fallbackLocal: true };
|
|
57
|
+
return { fallbackLocal: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Advice appended when a local write is denied by TCC and no daemon was
|
|
62
|
+
* available to take over.
|
|
63
|
+
*/
|
|
64
|
+
export function tccFallbackAdvice({ bridgeAvailable }) {
|
|
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.";
|
|
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.";
|
|
69
|
+
}
|