mcp-icloud 2.1.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,320 @@
1
+ /**
2
+ * CalDAV client for iCloud Calendar
3
+ */
4
+
5
+ const { DAVClient } = require('tsdav');
6
+ const ICAL = require('ical.js');
7
+ const config = require('../config');
8
+ const { getCredentials } = require('../auth');
9
+
10
+ let cachedClient = null;
11
+
12
+ /**
13
+ * Get or create CalDAV client
14
+ */
15
+ async function getClient() {
16
+ if (cachedClient) {
17
+ return cachedClient;
18
+ }
19
+
20
+ const creds = getCredentials();
21
+
22
+ const client = new DAVClient({
23
+ serverUrl: config.CALDAV.SERVER_URL,
24
+ credentials: {
25
+ username: creds.email,
26
+ password: creds.password
27
+ },
28
+ authMethod: 'Basic',
29
+ defaultAccountType: 'caldav'
30
+ });
31
+
32
+ try {
33
+ await client.login();
34
+ cachedClient = client;
35
+ return client;
36
+ } catch (error) {
37
+ if (error.message?.includes('401') || error.message?.includes('auth')) {
38
+ throw new Error('UNAUTHORIZED');
39
+ }
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Clear cached client (for re-auth)
46
+ */
47
+ function clearClient() {
48
+ cachedClient = null;
49
+ }
50
+
51
+ /**
52
+ * Get all calendars
53
+ */
54
+ async function getCalendars() {
55
+ const client = await getClient();
56
+ const calendars = await client.fetchCalendars();
57
+ return calendars.map(cal => ({
58
+ url: cal.url,
59
+ displayName: cal.displayName || 'Unnamed Calendar',
60
+ ctag: cal.ctag,
61
+ syncToken: cal.syncToken
62
+ }));
63
+ }
64
+
65
+ /**
66
+ * Parse iCalendar event to simple object
67
+ */
68
+ function parseEvent(icalData, url) {
69
+ try {
70
+ const jcalData = ICAL.parse(icalData);
71
+ const comp = new ICAL.Component(jcalData);
72
+ const vevent = comp.getFirstSubcomponent('vevent');
73
+
74
+ if (!vevent) return null;
75
+
76
+ const event = new ICAL.Event(vevent);
77
+
78
+ return {
79
+ url,
80
+ uid: event.uid,
81
+ summary: event.summary || '(No title)',
82
+ description: event.description || '',
83
+ location: event.location || '',
84
+ start: event.startDate?.toJSDate(),
85
+ end: event.endDate?.toJSDate(),
86
+ isAllDay: event.startDate?.isDate || false,
87
+ organizer: vevent.getFirstPropertyValue('organizer'),
88
+ attendees: vevent.getAllProperties('attendee').map(a => a.getFirstValue()),
89
+ status: event.status,
90
+ created: vevent.getFirstPropertyValue('created')?.toJSDate(),
91
+ lastModified: vevent.getFirstPropertyValue('last-modified')?.toJSDate()
92
+ };
93
+ } catch (error) {
94
+ console.error('Error parsing event:', error.message);
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * List events from all calendars
101
+ */
102
+ async function listEvents(count = 25, daysAhead = 30) {
103
+ const client = await getClient();
104
+ const calendars = await client.fetchCalendars();
105
+
106
+ const now = new Date();
107
+ const endDate = new Date();
108
+ endDate.setDate(endDate.getDate() + daysAhead);
109
+
110
+ const allEvents = [];
111
+
112
+ for (const calendar of calendars) {
113
+ try {
114
+ const calendarObjects = await client.fetchCalendarObjects({
115
+ calendar,
116
+ timeRange: {
117
+ start: now.toISOString(),
118
+ end: endDate.toISOString()
119
+ }
120
+ });
121
+
122
+ for (const obj of calendarObjects) {
123
+ const event = parseEvent(obj.data, obj.url);
124
+ if (event) {
125
+ event.calendarName = calendar.displayName || 'Calendar';
126
+ allEvents.push(event);
127
+ }
128
+ }
129
+ } catch (error) {
130
+ console.error(`Error fetching from calendar ${calendar.displayName}:`, error.message);
131
+ }
132
+ }
133
+
134
+ // Sort by start date
135
+ allEvents.sort((a, b) => (a.start || 0) - (b.start || 0));
136
+
137
+ return allEvents.slice(0, count);
138
+ }
139
+
140
+ /**
141
+ * Create a new event
142
+ */
143
+ async function createEvent({ summary, start, end, description, location, calendarUrl }) {
144
+ const client = await getClient();
145
+
146
+ // Get calendars if URL not provided
147
+ let targetCalendar;
148
+ if (calendarUrl) {
149
+ const calendars = await client.fetchCalendars();
150
+ targetCalendar = calendars.find(c => c.url === calendarUrl);
151
+ }
152
+
153
+ if (!targetCalendar) {
154
+ const calendars = await client.fetchCalendars();
155
+ targetCalendar = calendars[0]; // Use first calendar
156
+ }
157
+
158
+ if (!targetCalendar) {
159
+ throw new Error('No calendar found');
160
+ }
161
+
162
+ // Create iCalendar data
163
+ const uid = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}@icloud-mcp`;
164
+
165
+ const startDate = new Date(start);
166
+ const endDate = new Date(end);
167
+
168
+ const icalData = `BEGIN:VCALENDAR
169
+ VERSION:2.0
170
+ PRODID:-//iCloud MCP//EN
171
+ BEGIN:VEVENT
172
+ UID:${uid}
173
+ DTSTAMP:${formatICalDate(new Date())}
174
+ DTSTART:${formatICalDate(startDate)}
175
+ DTEND:${formatICalDate(endDate)}
176
+ SUMMARY:${escapeICalText(summary)}${description ? `\nDESCRIPTION:${escapeICalText(description)}` : ''}${location ? `\nLOCATION:${escapeICalText(location)}` : ''}
177
+ END:VEVENT
178
+ END:VCALENDAR`;
179
+
180
+ const result = await client.createCalendarObject({
181
+ calendar: targetCalendar,
182
+ filename: `${uid}.ics`,
183
+ iCalString: icalData
184
+ });
185
+
186
+ return {
187
+ success: true,
188
+ uid,
189
+ url: result?.url,
190
+ calendar: targetCalendar.displayName
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Rewrite individual properties of an existing VEVENT.
196
+ *
197
+ * Deliberately a line-level edit rather than rebuilding the iCalendar from
198
+ * scratch: a rebuild would silently drop RRULE, ATTENDEE, VALARM and any other
199
+ * property this server does not model, which on a real calendar means losing
200
+ * recurrence and invitees on every edit.
201
+ *
202
+ * @param {string} ical - Existing iCalendar text
203
+ * @param {Object} changes - Properties to set; undefined values are left alone
204
+ * @returns {string} - Updated iCalendar text
205
+ */
206
+ function applyICalChanges(ical, { summary, start, end, description, location }) {
207
+ const updates = [];
208
+ if (summary !== undefined) updates.push(['SUMMARY', escapeICalText(summary)]);
209
+ if (start !== undefined) updates.push(['DTSTART', formatICalDate(new Date(start))]);
210
+ if (end !== undefined) updates.push(['DTEND', formatICalDate(new Date(end))]);
211
+ if (description !== undefined) updates.push(['DESCRIPTION', escapeICalText(description)]);
212
+ if (location !== undefined) updates.push(['LOCATION', escapeICalText(location)]);
213
+ updates.push(['DTSTAMP', formatICalDate(new Date())]);
214
+
215
+ const lines = ical.split(/\r?\n/);
216
+
217
+ for (const [key, value] of updates) {
218
+ // A property may carry parameters, e.g. DTSTART;TZID=Europe/Madrid:...
219
+ const idx = lines.findIndex(l => {
220
+ const upper = l.toUpperCase();
221
+ return upper.startsWith(key + ':') || upper.startsWith(key + ';');
222
+ });
223
+
224
+ if (idx !== -1) {
225
+ lines[idx] = `${key}:${value}`;
226
+ } else {
227
+ const endIdx = lines.findIndex(l => l.toUpperCase().startsWith('END:VEVENT'));
228
+ lines.splice(endIdx === -1 ? lines.length : endIdx, 0, `${key}:${value}`);
229
+ }
230
+ }
231
+
232
+ return lines.join('\r\n');
233
+ }
234
+
235
+ /**
236
+ * Update an existing event, preserving every property not being changed.
237
+ */
238
+ async function updateEvent(eventUrl, changes) {
239
+ const client = await getClient();
240
+ const calendars = await client.fetchCalendars();
241
+
242
+ let existing = null;
243
+ for (const calendar of calendars) {
244
+ try {
245
+ const objects = await client.fetchCalendarObjects({
246
+ calendar,
247
+ objectUrls: [eventUrl]
248
+ });
249
+ if (objects && objects.length && objects[0].data) {
250
+ existing = objects[0];
251
+ break;
252
+ }
253
+ } catch (error) {
254
+ // Wrong calendar for this URL; keep looking.
255
+ }
256
+ }
257
+
258
+ if (!existing) {
259
+ throw new Error(`Event not found: ${eventUrl}`);
260
+ }
261
+
262
+ const updated = applyICalChanges(existing.data, changes);
263
+
264
+ await client.updateCalendarObject({
265
+ calendarObject: {
266
+ url: eventUrl,
267
+ data: updated,
268
+ etag: existing.etag || ''
269
+ }
270
+ });
271
+
272
+ return { success: true, url: eventUrl };
273
+ }
274
+
275
+ /**
276
+ * Delete an event
277
+ */
278
+ async function deleteEvent(eventUrl) {
279
+ const client = await getClient();
280
+
281
+ await client.deleteCalendarObject({
282
+ calendarObject: {
283
+ url: eventUrl,
284
+ etag: '' // Will be fetched
285
+ }
286
+ });
287
+
288
+ return { success: true };
289
+ }
290
+
291
+ /**
292
+ * Format date for iCalendar
293
+ */
294
+ function formatICalDate(date) {
295
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
296
+ }
297
+
298
+ /**
299
+ * Escape text for iCalendar
300
+ */
301
+ function escapeICalText(text) {
302
+ if (!text) return '';
303
+ return text
304
+ .replace(/\\/g, '\\\\')
305
+ .replace(/;/g, '\\;')
306
+ .replace(/,/g, '\\,')
307
+ .replace(/\n/g, '\\n');
308
+ }
309
+
310
+ module.exports = {
311
+ getClient,
312
+ clearClient,
313
+ getCalendars,
314
+ listEvents,
315
+ createEvent,
316
+ updateEvent,
317
+ deleteEvent,
318
+ parseEvent,
319
+ applyICalChanges
320
+ };
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Calendar module for iCloud MCP
3
+ * Provides calendar tools via CalDAV
4
+ */
5
+
6
+ const { z } = require('zod');
7
+ const cloudClient = require('./caldav-client');
8
+ const localClient = require('./local-client');
9
+ const { isLocalMode } = require('../mode');
10
+ const { formatSuccess, formatError, withErrorHandler } = require('../utils/error-handler');
11
+ const { listOutput, listResult } = require('../utils/schemas');
12
+ const { formatDate } = require('../utils/date-utils');
13
+ const config = require('../config');
14
+
15
+ /**
16
+ * Normalize an event to one shape regardless of mode.
17
+ *
18
+ * Local (Calendar.app) keys events by UID and returns ISO strings; cloud
19
+ * (CalDAV) keys them by URL and returns Date objects. Everything downstream
20
+ * sees `ref` (the handle you pass back to update/delete), `start`/`end` as
21
+ * Dates, and `calendarName`.
22
+ */
23
+ function normalizeEvent(event, local) {
24
+ if (!local) return { ...event, ref: event.url };
25
+
26
+ return {
27
+ summary: event.summary,
28
+ start: event.startDate ? new Date(event.startDate) : null,
29
+ end: event.endDate ? new Date(event.endDate) : null,
30
+ location: event.location || '',
31
+ calendarName: event.calendar,
32
+ isAllDay: false,
33
+ ref: event.id,
34
+ uid: event.id
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Normalize a calendar entry to one shape regardless of mode.
40
+ */
41
+ function normalizeCalendar(calendar, local) {
42
+ if (!local) return { displayName: calendar.displayName, ref: calendar.url, writable: true };
43
+ return { displayName: calendar.name, ref: calendar.id, writable: calendar.writable };
44
+ }
45
+
46
+ /**
47
+ * Handler: List events
48
+ */
49
+ async function handleListEvents(args) {
50
+ const count = Math.min(args.count || 25, config.DEFAULTS.MAX_RESULTS);
51
+ const daysAhead = args.daysAhead || 30;
52
+
53
+ const local = isLocalMode();
54
+ const client = local ? localClient : cloudClient;
55
+
56
+ const raw = await client.listEvents(count, daysAhead);
57
+ const events = raw.map(e => normalizeEvent(e, local));
58
+
59
+ if (events.length === 0) {
60
+ return formatSuccess(`No upcoming events in the next ${daysAhead} days.`, listResult([]));
61
+ }
62
+
63
+ const lines = events.map((event, i) => {
64
+ const dateStr = event.isAllDay
65
+ ? `All day: ${formatDate(event.start, { hour: undefined, minute: undefined })}`
66
+ : `${formatDate(event.start)} - ${formatDate(event.end, { year: undefined, month: undefined, day: undefined })}`;
67
+
68
+ let line = `${i + 1}. ${event.summary}\n ${dateStr}`;
69
+ if (event.location) line += `\n Location: ${event.location}`;
70
+ if (event.calendarName) line += `\n Calendar: ${event.calendarName}`;
71
+ line += `\n ${local ? 'ID' : 'URL'}: ${event.ref}`;
72
+
73
+ return line;
74
+ });
75
+
76
+ return formatSuccess(`Upcoming events (${events.length}):\n\n${lines.join('\n\n')}`, listResult(events));
77
+ }
78
+
79
+ /**
80
+ * Handler: Create event
81
+ */
82
+ async function handleCreateEvent(args) {
83
+ if (!args.summary) {
84
+ return formatError(new Error('Event summary/title is required'));
85
+ }
86
+ if (!args.start) {
87
+ return formatError(new Error('Start date/time is required (ISO 8601 format)'));
88
+ }
89
+ if (!args.end) {
90
+ return formatError(new Error('End date/time is required (ISO 8601 format)'));
91
+ }
92
+
93
+ const local = isLocalMode();
94
+ const client = local ? localClient : cloudClient;
95
+
96
+ const result = await client.createEvent({
97
+ summary: args.summary,
98
+ start: args.start,
99
+ end: args.end,
100
+ description: args.description,
101
+ location: args.location,
102
+ calendarUrl: args.calendarUrl, // cloud
103
+ calendarName: args.calendarName // local
104
+ });
105
+
106
+ return formatSuccess(
107
+ `Event created successfully!\n\nTitle: ${args.summary}\nStart: ${formatDate(new Date(args.start))}\nEnd: ${formatDate(new Date(args.end))}${args.location ? `\nLocation: ${args.location}` : ''}${result.calendar ? `\nCalendar: ${result.calendar}` : ''}\n${local ? 'ID' : 'UID'}: ${result.uid || result.id}`
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Handler: Update event
113
+ */
114
+ async function handleUpdateEvent(args) {
115
+ if (!args.eventUrl) {
116
+ return formatError(new Error('Event URL is required (from list-events output)'));
117
+ }
118
+
119
+ const changes = {};
120
+ for (const field of ['summary', 'start', 'end', 'description', 'location']) {
121
+ if (args[field] !== undefined) changes[field] = args[field];
122
+ }
123
+
124
+ if (Object.keys(changes).length === 0) {
125
+ return formatError(new Error('Nothing to update. Provide at least one of: summary, start, end, description, location.'));
126
+ }
127
+
128
+ for (const field of ['start', 'end']) {
129
+ if (changes[field] !== undefined && Number.isNaN(new Date(changes[field]).getTime())) {
130
+ return formatError(new Error(`Invalid ${field} date. Use ISO 8601, e.g. 2026-01-15T10:00:00`));
131
+ }
132
+ }
133
+
134
+ const local = isLocalMode();
135
+ const client = local ? localClient : cloudClient;
136
+
137
+ await client.updateEvent(args.eventUrl, changes);
138
+
139
+ const summary = Object.entries(changes)
140
+ .map(([k, v]) => `${k}: ${v}`)
141
+ .join('\n');
142
+
143
+ return formatSuccess(`Event updated successfully.\n\n${summary}`);
144
+ }
145
+
146
+ /**
147
+ * Handler: Delete event
148
+ */
149
+ async function handleDeleteEvent(args) {
150
+ if (!args.eventUrl) {
151
+ return formatError(new Error('Event URL is required'));
152
+ }
153
+
154
+ const client = isLocalMode() ? localClient : cloudClient;
155
+ await client.deleteEvent(args.eventUrl);
156
+
157
+ return formatSuccess(`Event deleted successfully.`);
158
+ }
159
+
160
+ /**
161
+ * Handler: List calendars
162
+ */
163
+ async function handleListCalendars() {
164
+ const local = isLocalMode();
165
+ const raw = local ? await localClient.listCalendars() : await cloudClient.getCalendars();
166
+ const calendars = raw.map(c => normalizeCalendar(c, local));
167
+
168
+ if (calendars.length === 0) {
169
+ return formatSuccess('No calendars found.', listResult([]));
170
+ }
171
+
172
+ const lines = calendars.map((cal, i) =>
173
+ `${i + 1}. ${cal.displayName}\n ${local ? 'ID' : 'URL'}: ${cal.ref}`
174
+ );
175
+
176
+ return formatSuccess(`Calendars (${calendars.length}):\n\n${lines.join('\n\n')}`, listResult(calendars));
177
+ }
178
+
179
+ // Tool definitions
180
+ const calendarTools = [
181
+ {
182
+ name: 'list-events',
183
+ outputSchema: listOutput('Calendar events'),
184
+ title: 'List Events',
185
+ description: 'Lists upcoming calendar events',
186
+ inputSchema: {
187
+ count: z.number().int().min(1).max(50).optional().describe('Number of events to retrieve (default: 25, max: 50)'),
188
+ daysAhead: z.number().int().min(1).max(365).optional().describe('Number of days to look ahead (default: 30)')
189
+ },
190
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":true},
191
+ handler: withErrorHandler(handleListEvents, 'list-events')
192
+ },
193
+ {
194
+ name: 'create-event',
195
+ title: 'Create Event',
196
+ description: 'Creates a new calendar event',
197
+ inputSchema: {
198
+ summary: z.string().describe('Event title/summary'),
199
+ start: z.string().describe('Start date/time in ISO 8601 format (e.g., 2026-01-15T10:00:00)'),
200
+ end: z.string().describe('End date/time in ISO 8601 format'),
201
+ description: z.string().optional().describe('Event description (optional)'),
202
+ location: z.string().optional().describe('Event location (optional)'),
203
+ calendarUrl: z.string().optional().describe('Cloud mode: URL of the calendar to add the event to (optional, uses the first calendar)'),
204
+ calendarName: z.string().optional().describe('Local mode: name of the calendar to add the event to (optional, uses the default)')
205
+ },
206
+ annotations: {"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},
207
+ handler: withErrorHandler(handleCreateEvent, 'create-event')
208
+ },
209
+ {
210
+ name: 'update-event',
211
+ title: 'Update Event',
212
+ description: 'Updates an existing calendar event. Only the fields you pass are changed. In cloud mode the CalDAV property-merge preserves recurrence, invitees and alarms (experimental: the live round-trip is not yet verified).',
213
+ inputSchema: {
214
+ eventUrl: z.string().describe('Event handle from list-events: the URL in cloud mode, the UID in local mode'),
215
+ summary: z.string().optional().describe('New event title (optional)'),
216
+ start: z.string().optional().describe('New start date/time in ISO 8601 format (optional)'),
217
+ end: z.string().optional().describe('New end date/time in ISO 8601 format (optional)'),
218
+ description: z.string().optional().describe('New description (optional)'),
219
+ location: z.string().optional().describe('New location (optional)')
220
+ },
221
+ annotations: {"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":true},
222
+ handler: withErrorHandler(handleUpdateEvent, 'update-event')
223
+ },
224
+ {
225
+ name: 'delete-event',
226
+ title: 'Delete Event',
227
+ description: 'Deletes a calendar event',
228
+ inputSchema: {
229
+ eventUrl: z.string().describe('Event handle from list-events: the URL in cloud mode, the UID in local mode')
230
+ },
231
+ annotations: {"readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":true},
232
+ handler: withErrorHandler(handleDeleteEvent, 'delete-event')
233
+ },
234
+ {
235
+ name: 'list-calendars',
236
+ outputSchema: listOutput('Calendars'),
237
+ title: 'List Calendars',
238
+ description: 'Lists all available calendars',
239
+ inputSchema: {},
240
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":true},
241
+ handler: withErrorHandler(handleListCalendars, 'list-calendars')
242
+ }
243
+ ];
244
+
245
+ module.exports = {
246
+ calendarTools,
247
+ handleListEvents,
248
+ handleCreateEvent,
249
+ handleUpdateEvent,
250
+ handleDeleteEvent,
251
+ handleListCalendars
252
+ };