icloud-mcp 2.2.0 → 2.4.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/lib/caldav.js ADDED
@@ -0,0 +1,502 @@
1
+ // ─── lib/caldav.js — iCloud CalDAV (Calendar) ────────────────────────────────
2
+ import { randomUUID } from 'crypto';
3
+
4
+ const CALDAV_HOST = 'https://caldav.icloud.com';
5
+ // Calendars to exclude from list_calendars (scheduling containers, not user calendars)
6
+ const EXCLUDED_NAMES = new Set(['inbox', 'outbox', 'notification', 'notification/']);
7
+
8
+ // ─── Credentials & HTTP ───────────────────────────────────────────────────────
9
+
10
+ function getCredentials() {
11
+ const user = process.env.IMAP_USER;
12
+ const pass = process.env.IMAP_PASSWORD;
13
+ if (!user || !pass) throw new Error('IMAP_USER and IMAP_PASSWORD are required');
14
+ return { user, auth: Buffer.from(`${user}:${pass}`).toString('base64') };
15
+ }
16
+
17
+ async function davRequest(method, url, opts = {}) {
18
+ const { auth } = getCredentials();
19
+ const headers = {
20
+ Authorization: `Basic ${auth}`,
21
+ ...(opts.depth !== undefined ? { Depth: String(opts.depth) } : {}),
22
+ ...(opts.contentType ? { 'Content-Type': opts.contentType } : {}),
23
+ ...(opts.etag ? { 'If-Match': opts.etag } : {}),
24
+ };
25
+ const res = await fetch(url, { method, headers, body: opts.body });
26
+ const text = await res.text();
27
+ return { status: res.status, etag: res.headers.get('etag'), body: text };
28
+ }
29
+
30
+ function propfindBody(props) {
31
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<A:propfind xmlns:A="DAV:"><A:prop>${props}</A:prop></A:propfind>`;
32
+ }
33
+
34
+ // ─── Discovery ────────────────────────────────────────────────────────────────
35
+
36
+ let _discoveryCache = null;
37
+
38
+ async function discover() {
39
+ if (_discoveryCache) return _discoveryCache;
40
+
41
+ // Step 1: well-known → principal
42
+ const wk = await davRequest('PROPFIND', `${CALDAV_HOST}/.well-known/caldav`, {
43
+ depth: 0,
44
+ contentType: 'application/xml; charset=utf-8',
45
+ body: propfindBody('<A:current-user-principal/>'),
46
+ });
47
+
48
+ let principalPath = extractHrefIn(wk.body, 'current-user-principal');
49
+ if (!principalPath) {
50
+ const root = await davRequest('PROPFIND', `${CALDAV_HOST}/`, {
51
+ depth: 0,
52
+ contentType: 'application/xml; charset=utf-8',
53
+ body: propfindBody('<A:current-user-principal/>'),
54
+ });
55
+ principalPath = extractHrefIn(root.body, 'current-user-principal');
56
+ }
57
+ if (!principalPath) throw new Error('CalDAV: could not discover principal URL');
58
+
59
+ // Step 2: principal → calendar-home-set
60
+ const principalUrl = principalPath.startsWith('http')
61
+ ? principalPath
62
+ : `${CALDAV_HOST}${principalPath}`;
63
+
64
+ const principalResp = await davRequest('PROPFIND', principalUrl, {
65
+ depth: 0,
66
+ contentType: 'application/xml; charset=utf-8',
67
+ body: propfindBody('<C:calendar-home-set xmlns:C="urn:ietf:params:xml:ns:caldav"/>'),
68
+ });
69
+
70
+ const homeHref = extractHrefIn(principalResp.body, 'calendar-home-set');
71
+ if (!homeHref) throw new Error('CalDAV: could not find calendar-home-set');
72
+
73
+ // homeHref includes partition host (e.g. https://p137-caldav.icloud.com:443/dsid/calendars/)
74
+ const dataHost = homeHref.startsWith('http') ? new URL(homeHref).origin : CALDAV_HOST;
75
+ const calendarsPath = homeHref.startsWith('http')
76
+ ? new URL(homeHref).pathname
77
+ : homeHref;
78
+
79
+ _discoveryCache = { dataHost, calendarsPath };
80
+ return _discoveryCache;
81
+ }
82
+
83
+ // ─── XML helpers ──────────────────────────────────────────────────────────────
84
+
85
+ function extractHrefIn(xml, parentTag) {
86
+ const re = new RegExp(
87
+ `<[^>:]*:?${parentTag}[\\s\\S]*?>[\\s\\S]*?<[^>:]*:?href[^>]*>([^<]+)<\\/[^>:]*:?href>`,
88
+ 'i'
89
+ );
90
+ const m = xml.match(re);
91
+ return m ? m[1].trim() : null;
92
+ }
93
+
94
+ function splitResponses(xml) {
95
+ return [...xml.matchAll(/<[^>:]*:?response[\s\S]*?<\/[^>:]*:?response>/g)].map(m => m[0]);
96
+ }
97
+
98
+ function xmlText(xml, tag) {
99
+ const re = new RegExp(`<[^>:]*:?${tag}[^>]*>([\\s\\S]*?)<\\/[^>:]*:?${tag}>`, 'i');
100
+ const m = xml.match(re);
101
+ return m ? m[1].trim() : null;
102
+ }
103
+
104
+ // ─── iCal text escaping ───────────────────────────────────────────────────────
105
+ // iCal property values must not contain raw newlines — escape as \n (literal backslash-n)
106
+
107
+ function icalEscape(str) {
108
+ if (!str) return str;
109
+ return str
110
+ .replace(/\\/g, '\\\\') // backslash → \\
111
+ .replace(/\n/g, '\\n') // newline → \n (literal)
112
+ .replace(/\r/g, ''); // strip carriage returns
113
+ }
114
+
115
+ function icalUnescape(str) {
116
+ if (!str) return str;
117
+ return str
118
+ .replace(/\\n/g, '\n')
119
+ .replace(/\\,/g, ',')
120
+ .replace(/\\;/g, ';')
121
+ .replace(/\\\\/g, '\\');
122
+ }
123
+
124
+ // ─── iCal date helpers ────────────────────────────────────────────────────────
125
+
126
+ function toIcalUtc(date) {
127
+ // YYYYMMDDTHHMMSSZ
128
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
129
+ }
130
+
131
+ function toIcalLocal(date) {
132
+ // YYYYMMDDTHHMMSS (no Z, for use with TZID=...)
133
+ const pad = n => String(n).padStart(2, '0');
134
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
135
+ `T${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
136
+ }
137
+
138
+ function parseIcalDate(val, fullKey = '') {
139
+ if (fullKey.includes('VALUE=DATE')) {
140
+ // YYYYMMDD → YYYY-MM-DD
141
+ const m = val.match(/^(\d{4})(\d{2})(\d{2})$/);
142
+ return m ? `${m[1]}-${m[2]}-${m[3]}` : val;
143
+ }
144
+ // YYYYMMDDTHHMMSS[Z] → YYYY-MM-DDTHH:MM:SS[Z]
145
+ const m = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/);
146
+ if (!m) return val;
147
+ return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${m[7]}`;
148
+ }
149
+
150
+ // ─── iCal parsing ─────────────────────────────────────────────────────────────
151
+
152
+ function parseVEvent(ical) {
153
+ // Unfold continuation lines
154
+ const unfolded = ical.replace(/\r?\n[ \t]/g, '');
155
+ const lines = unfolded.split(/\r?\n/);
156
+
157
+ let inEvent = false;
158
+ const event = {};
159
+
160
+ for (const line of lines) {
161
+ if (line === 'BEGIN:VEVENT') { inEvent = true; continue; }
162
+ if (line === 'END:VEVENT') { inEvent = false; continue; }
163
+ if (!inEvent) continue;
164
+
165
+ const colonIdx = line.indexOf(':');
166
+ if (colonIdx < 0) continue;
167
+ const fullKey = line.slice(0, colonIdx);
168
+ const val = line.slice(colonIdx + 1);
169
+ const key = fullKey.split(';')[0].toUpperCase();
170
+
171
+ switch (key) {
172
+ case 'UID': event.uid = val; break;
173
+ case 'SUMMARY': event.summary = icalUnescape(val); break;
174
+ case 'DESCRIPTION': event.description = icalUnescape(val); break;
175
+ case 'LOCATION': event.location = icalUnescape(val); break;
176
+ case 'STATUS': event.status = val; break;
177
+ case 'RRULE': event.recurrence = val; break;
178
+ case 'DTSTART': {
179
+ event.start = parseIcalDate(val, fullKey);
180
+ const tzM = fullKey.match(/TZID=([^;:]+)/);
181
+ if (tzM) event.timezone = tzM[1];
182
+ event.allDay = fullKey.includes('VALUE=DATE');
183
+ break;
184
+ }
185
+ case 'DTEND': {
186
+ event.end = parseIcalDate(val, fullKey);
187
+ break;
188
+ }
189
+ case 'CREATED': event.created = parseIcalDate(val, fullKey); break;
190
+ case 'LAST-MODIFIED': event.lastModified = parseIcalDate(val, fullKey); break;
191
+ case 'ORGANIZER': event.organizer = val.replace(/^mailto:/i, ''); break;
192
+ case 'ATTENDEE': {
193
+ if (!event.attendees) event.attendees = [];
194
+ const cn = fullKey.match(/CN=([^;:]+)/i)?.[1];
195
+ const email = val.replace(/^mailto:/i, '');
196
+ event.attendees.push(cn ? `${cn} <${email}>` : email);
197
+ break;
198
+ }
199
+ case 'EXDATE': {
200
+ if (!event.exDates) event.exDates = [];
201
+ event.exDates.push(parseIcalDate(val, fullKey));
202
+ break;
203
+ }
204
+ }
205
+ }
206
+
207
+ return event;
208
+ }
209
+
210
+ // ─── iCal serialization ───────────────────────────────────────────────────────
211
+
212
+ function serializeVEvent(fields, uid = null) {
213
+ const id = uid || randomUUID().toUpperCase();
214
+ const now = new Date();
215
+ const dtstamp = toIcalUtc(now);
216
+
217
+ const lines = [
218
+ 'BEGIN:VCALENDAR',
219
+ 'CALSCALE:GREGORIAN',
220
+ 'PRODID:-//icloud-mcp//EN',
221
+ 'VERSION:2.0',
222
+ 'BEGIN:VEVENT',
223
+ `DTSTAMP:${dtstamp}`,
224
+ `CREATED:${dtstamp}`,
225
+ `UID:${id}`,
226
+ `SUMMARY:${icalEscape(fields.summary || '(No title)')}`,
227
+ ];
228
+
229
+ if (fields.allDay) {
230
+ const start = (fields.start || '').replace(/-/g, '').slice(0, 8);
231
+ const end = (fields.end || fields.start || '').replace(/-/g, '').slice(0, 8);
232
+ lines.push(`DTSTART;VALUE=DATE:${start}`);
233
+ lines.push(`DTEND;VALUE=DATE:${end}`);
234
+ } else {
235
+ const tz = fields.timezone || 'UTC';
236
+ const startDate = fields.start ? new Date(fields.start) : now;
237
+ const endDate = fields.end ? new Date(fields.end) : new Date(startDate.getTime() + 3600_000);
238
+
239
+ if (tz === 'UTC') {
240
+ lines.push(`DTSTART:${toIcalUtc(startDate)}`);
241
+ lines.push(`DTEND:${toIcalUtc(endDate)}`);
242
+ } else {
243
+ lines.push(`DTSTART;TZID=${tz}:${toIcalLocal(startDate)}`);
244
+ lines.push(`DTEND;TZID=${tz}:${toIcalLocal(endDate)}`);
245
+ }
246
+ }
247
+
248
+ if (fields.description) lines.push(`DESCRIPTION:${icalEscape(fields.description)}`);
249
+ if (fields.location) lines.push(`LOCATION:${icalEscape(fields.location)}`);
250
+ if (fields.recurrence) lines.push(`RRULE:${fields.recurrence}`);
251
+ if (fields.status) lines.push(`STATUS:${fields.status}`);
252
+
253
+ // VALARM — reminder N minutes before (default: 30 min if not specified, 0 to disable)
254
+ const reminderMins = fields.reminder !== undefined ? Number(fields.reminder) : 30;
255
+ if (reminderMins > 0) {
256
+ lines.push(
257
+ 'BEGIN:VALARM',
258
+ 'ACTION:DISPLAY',
259
+ 'DESCRIPTION:Reminder',
260
+ `TRIGGER:-PT${reminderMins}M`,
261
+ 'END:VALARM'
262
+ );
263
+ }
264
+
265
+ lines.push('SEQUENCE:0', 'END:VEVENT', 'END:VCALENDAR');
266
+ return { ical: lines.join('\r\n') + '\r\n', uid: id };
267
+ }
268
+
269
+ // ─── Parse REPORT response blocks ────────────────────────────────────────────
270
+
271
+ function parseEventBlocks(xml) {
272
+ return splitResponses(xml).map(block => {
273
+ const hrefMatch = block.match(/<[^>:]*:?href[^>]*>([^<]+)<\/[^>:]*:?href>/);
274
+ const etagMatch = block.match(/<[^>:]*:?getetag[^>]*>"?([^"<]+)"?<\/[^>:]*:?getetag>/);
275
+
276
+ // Extract calendar-data — may be in CDATA or as plain text
277
+ let icalText = null;
278
+ const cdataMatch = block.match(/<!\[CDATA\[([\s\S]*?)\]\]>/);
279
+ if (cdataMatch) {
280
+ icalText = cdataMatch[1];
281
+ } else {
282
+ const dataMatch = block.match(/<[^>:]*:?calendar-data[^>]*>([\s\S]*?)<\/[^>:]*:?calendar-data>/i);
283
+ if (dataMatch) icalText = dataMatch[1];
284
+ }
285
+
286
+ if (!hrefMatch || !icalText) return null;
287
+
288
+ const href = hrefMatch[1];
289
+ const parts = href.split('/').filter(Boolean);
290
+ const filename = parts[parts.length - 1];
291
+ const eventId = filename.replace(/\.ics$/i, '');
292
+ // calendarId is the UUID segment before the filename
293
+ const calendarId = parts[parts.length - 2] || null;
294
+
295
+ const event = parseVEvent(icalText);
296
+ return { eventId, calendarId, etag: etagMatch?.[1] || null, href, ...event };
297
+ }).filter(Boolean);
298
+ }
299
+
300
+ function parseCalendarBlocks(xml) {
301
+ return splitResponses(xml).map(block => {
302
+ const hrefMatch = block.match(/<[^>:]*:?href[^>]*>([^<]+)<\/[^>:]*:?href>/);
303
+ if (!hrefMatch) return null;
304
+
305
+ const href = hrefMatch[1];
306
+ const parts = href.split('/').filter(Boolean);
307
+ const last = parts[parts.length - 1];
308
+
309
+ // Skip scheduling/system containers
310
+ if (EXCLUDED_NAMES.has(last)) return null;
311
+
312
+ // Must have resourcetype = calendar
313
+ if (!block.includes('calendar') || !block.includes('collection')) return null;
314
+ // Skip the home-set itself (no calendar element, just collection)
315
+ const resourceBlock = xmlText(block, 'resourcetype') || '';
316
+ if (!resourceBlock.includes('calendar')) return null;
317
+
318
+ const displayName = xmlText(block, 'displayname') || last;
319
+ const syncToken = xmlText(block, 'sync-token') || null;
320
+
321
+ // supported component types
322
+ const compMatches = [...block.matchAll(/comp\s+name=['"]([^'"]+)['"]/g)].map(m => m[1]);
323
+
324
+ // calendarId is the last non-empty path segment
325
+ const calendarId = last.replace(/\/$/, '');
326
+
327
+ return { calendarId, name: displayName, href, supportedTypes: compMatches, syncToken };
328
+ }).filter(Boolean);
329
+ }
330
+
331
+ // ─── Public API ───────────────────────────────────────────────────────────────
332
+
333
+ export async function listCalendars() {
334
+ const { dataHost, calendarsPath } = await discover();
335
+
336
+ const body = propfindBody(`
337
+ <A:resourcetype/>
338
+ <A:displayname/>
339
+ <A:sync-token/>
340
+ <C:supported-calendar-component-set xmlns:C="urn:ietf:params:xml:ns:caldav"/>
341
+ `);
342
+
343
+ const resp = await davRequest('PROPFIND', `${dataHost}${calendarsPath}`, {
344
+ depth: 1,
345
+ contentType: 'application/xml; charset=utf-8',
346
+ body,
347
+ });
348
+
349
+ const calendars = parseCalendarBlocks(resp.body);
350
+ return { calendars, count: calendars.length };
351
+ }
352
+
353
+ export async function listEvents(calendarId, since = null, before = null, limit = 50) {
354
+ const { dataHost, calendarsPath } = await discover();
355
+
356
+ const sinceDate = since ? new Date(since) : new Date(Date.now() - 30 * 86400_000);
357
+ const beforeDate = before ? new Date(before) : new Date(Date.now() + 30 * 86400_000);
358
+
359
+ const start = toIcalUtc(sinceDate);
360
+ const end = toIcalUtc(beforeDate);
361
+
362
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
363
+ <C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="DAV:">
364
+ <A:prop><A:getetag/><C:calendar-data/></A:prop>
365
+ <C:filter>
366
+ <C:comp-filter name="VCALENDAR">
367
+ <C:comp-filter name="VEVENT">
368
+ <C:time-range start="${start}" end="${end}"/>
369
+ </C:comp-filter>
370
+ </C:comp-filter>
371
+ </C:filter>
372
+ </C:calendar-query>`;
373
+
374
+ const url = `${dataHost}${calendarsPath}${calendarId}/`;
375
+ const resp = await davRequest('REPORT', url, {
376
+ depth: 1,
377
+ contentType: 'application/xml; charset=utf-8',
378
+ body,
379
+ });
380
+
381
+ if (resp.status === 403 || resp.status === 404) {
382
+ throw new Error(`Calendar not found or access denied: ${calendarId} (${resp.status})`);
383
+ }
384
+
385
+ const events = parseEventBlocks(resp.body).slice(0, limit);
386
+ return { events, count: events.length, calendarId, since: sinceDate.toISOString(), before: beforeDate.toISOString() };
387
+ }
388
+
389
+ export async function getEvent(calendarId, eventId) {
390
+ const { dataHost, calendarsPath } = await discover();
391
+ const url = `${dataHost}${calendarsPath}${calendarId}/${eventId}.ics`;
392
+ const resp = await davRequest('GET', url);
393
+
394
+ if (resp.status === 404) throw new Error(`Event not found: ${calendarId}/${eventId}`);
395
+ if (resp.status >= 400) throw new Error(`CalDAV GET failed: ${resp.status}`);
396
+
397
+ const event = parseVEvent(resp.body);
398
+ return { eventId, calendarId, etag: resp.etag, ...event };
399
+ }
400
+
401
+ export async function createEvent(calendarId, fields) {
402
+ const { dataHost, calendarsPath } = await discover();
403
+ const { ical, uid } = serializeVEvent(fields);
404
+ const eventId = uid;
405
+ const url = `${dataHost}${calendarsPath}${calendarId}/${eventId}.ics`;
406
+
407
+ const resp = await davRequest('PUT', url, {
408
+ contentType: 'text/calendar; charset=utf-8',
409
+ body: ical,
410
+ });
411
+
412
+ if (resp.status !== 201 && resp.status !== 204 && resp.status !== 200) {
413
+ throw new Error(`CalDAV PUT failed: ${resp.status} — ${resp.body.slice(0, 200)}`);
414
+ }
415
+
416
+ return { created: true, eventId, calendarId, etag: resp.etag };
417
+ }
418
+
419
+ export async function updateEvent(calendarId, eventId, fields) {
420
+ const { dataHost, calendarsPath } = await discover();
421
+ const url = `${dataHost}${calendarsPath}${calendarId}/${eventId}.ics`;
422
+
423
+ // Fetch current to get etag and existing fields
424
+ const existing = await davRequest('GET', url);
425
+ if (existing.status === 404) throw new Error(`Event not found: ${calendarId}/${eventId}`);
426
+
427
+ const current = parseVEvent(existing.body);
428
+ const merged = { ...current, ...fields };
429
+ const { ical } = serializeVEvent(merged, eventId);
430
+
431
+ const resp = await davRequest('PUT', url, {
432
+ contentType: 'text/calendar; charset=utf-8',
433
+ etag: existing.etag,
434
+ body: ical,
435
+ });
436
+
437
+ if (resp.status !== 204 && resp.status !== 200) {
438
+ throw new Error(`CalDAV PUT (update) failed: ${resp.status} — ${resp.body.slice(0, 200)}`);
439
+ }
440
+
441
+ return { updated: true, eventId, calendarId, etag: resp.etag };
442
+ }
443
+
444
+ export async function deleteEvent(calendarId, eventId) {
445
+ const { dataHost, calendarsPath } = await discover();
446
+ const url = `${dataHost}${calendarsPath}${calendarId}/${eventId}.ics`;
447
+
448
+ const resp = await davRequest('DELETE', url);
449
+ if (resp.status === 404) throw new Error(`Event not found: ${calendarId}/${eventId}`);
450
+ if (resp.status !== 204 && resp.status !== 200) {
451
+ throw new Error(`CalDAV DELETE failed: ${resp.status}`);
452
+ }
453
+
454
+ return { deleted: true, eventId, calendarId };
455
+ }
456
+
457
+ export async function searchEvents(query, since = null, before = null) {
458
+ const { dataHost, calendarsPath } = await discover();
459
+
460
+ const sinceDate = since ? new Date(since) : new Date(Date.now() - 365 * 86400_000);
461
+ const beforeDate = before ? new Date(before) : new Date(Date.now() + 365 * 86400_000);
462
+ const start = toIcalUtc(sinceDate);
463
+ const end = toIcalUtc(beforeDate);
464
+
465
+ // First list all calendars to search across all of them
466
+ const cals = await listCalendars();
467
+ const veventCals = cals.calendars.filter(c => c.supportedTypes.includes('VEVENT'));
468
+
469
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
470
+ <C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="DAV:">
471
+ <A:prop><A:getetag/><C:calendar-data/></A:prop>
472
+ <C:filter>
473
+ <C:comp-filter name="VCALENDAR">
474
+ <C:comp-filter name="VEVENT">
475
+ <C:time-range start="${start}" end="${end}"/>
476
+ <C:prop-filter name="SUMMARY">
477
+ <C:text-match collation="i;unicode-casemap" match-type="contains">${query}</C:text-match>
478
+ </C:prop-filter>
479
+ </C:comp-filter>
480
+ </C:comp-filter>
481
+ </C:filter>
482
+ </C:calendar-query>`;
483
+
484
+ const results = await Promise.allSettled(
485
+ veventCals.map(cal =>
486
+ davRequest('REPORT', `${dataHost}${calendarsPath}${cal.calendarId}/`, {
487
+ depth: 1,
488
+ contentType: 'application/xml; charset=utf-8',
489
+ body,
490
+ })
491
+ )
492
+ );
493
+
494
+ const events = [];
495
+ for (const r of results) {
496
+ if (r.status === 'fulfilled' && r.value.status === 207) {
497
+ events.push(...parseEventBlocks(r.value.body));
498
+ }
499
+ }
500
+
501
+ return { events, count: events.length, query };
502
+ }