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,223 @@
1
+ /**
2
+ * Local Calendar Client
3
+ * Accesses Calendar.app via AppleScript
4
+ */
5
+
6
+ const { runAppleScript, runJXA, escapeAppleScript, escapeJXA, asInt, formatAppleScriptDate } = require('../utils/applescript');
7
+ const config = require('../config');
8
+
9
+ /**
10
+ * List upcoming events from every calendar
11
+ * @param {number} count - Number of events to retrieve
12
+ * @param {number} daysAhead - Number of days to look ahead
13
+ * @returns {Promise<Array>} - List of events
14
+ */
15
+ async function listEvents(count = 25, daysAhead = 30) {
16
+ const now = new Date();
17
+ const future = new Date(now.getTime() + asInt(daysAhead, 30) * 24 * 60 * 60 * 1000);
18
+
19
+ // Batch property access: per-event JXA calls trigger AppleEvent errors.
20
+ // The window is filtered inside the script so a busy calendar cannot push
21
+ // upcoming events out of the result set before we ever see them.
22
+ const script = `
23
+ const calendar = Application('Calendar');
24
+ const cals = calendar.calendars;
25
+ const calNames = cals.name();
26
+ const rangeStart = new Date(${JSON.stringify(now.toISOString())});
27
+ const rangeEnd = new Date(${JSON.stringify(future.toISOString())});
28
+ let allEvents = [];
29
+
30
+ for (let i = 0; i < calNames.length; i++) {
31
+ try {
32
+ // Filter the window app-side: reading every property of every event
33
+ // of every calendar transfers whole subscribed-calendar histories
34
+ // and hangs on real accounts.
35
+ const evts = cals[i].events.whose({
36
+ _and: [
37
+ { startDate: { _greaterThan: rangeStart } },
38
+ { startDate: { _lessThan: rangeEnd } }
39
+ ]
40
+ });
41
+ const uids = evts.uid();
42
+ const summaries = evts.summary();
43
+ const starts = evts.startDate();
44
+ const ends = evts.endDate();
45
+ const locations = evts.location();
46
+
47
+ for (let j = 0; j < uids.length; j++) {
48
+ const start = starts[j];
49
+ if (!start) continue;
50
+ if (start < rangeStart || start > rangeEnd) continue;
51
+
52
+ allEvents.push({
53
+ id: uids[j],
54
+ summary: summaries[j] || '',
55
+ startDate: start.toISOString(),
56
+ endDate: ends[j] ? ends[j].toISOString() : null,
57
+ location: locations[j] || '',
58
+ calendar: calNames[i]
59
+ });
60
+ }
61
+ } catch (e) {
62
+ // One unreadable calendar must not hide the rest, but say which.
63
+ console.log('calendar skipped: ' + calNames[i] + ' (' + e + ')');
64
+ }
65
+ }
66
+
67
+ JSON.stringify(allEvents);
68
+ `;
69
+
70
+ const result = await runJXA(script);
71
+ if (!result) return [];
72
+
73
+ const events = JSON.parse(result);
74
+ events.sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
75
+ return events.slice(0, asInt(count, 25));
76
+ }
77
+
78
+ /**
79
+ * List all calendars
80
+ * @returns {Promise<Array>} - List of calendars
81
+ */
82
+ async function listCalendars() {
83
+ // Use batch property access - JXA iteration causes AppleEvent errors
84
+ const script = `
85
+ const calendar = Application('Calendar');
86
+ const cals = calendar.calendars;
87
+ const names = cals.name();
88
+ const writables = cals.writable();
89
+
90
+ let result = [];
91
+ for (let i = 0; i < names.length; i++) {
92
+ result.push({
93
+ name: names[i],
94
+ id: names[i], // Use name as ID since cal.id() causes errors
95
+ writable: writables[i]
96
+ });
97
+ }
98
+
99
+ JSON.stringify(result);
100
+ `;
101
+
102
+ const result = await runJXA(script);
103
+ return result ? JSON.parse(result) : [];
104
+ }
105
+
106
+ /**
107
+ * Create a new event
108
+ * @param {Object} options - Event options
109
+ * @returns {Promise<Object>} - Created event info
110
+ */
111
+ async function createEvent({ summary, start, end, location, description, calendarName, allDay = false }) {
112
+ const startDate = new Date(start);
113
+ const endDate = new Date(end);
114
+
115
+ // Use default calendar if none specified
116
+ const targetCalendar = calendarName || 'Calendar';
117
+
118
+ if (allDay) {
119
+ // All-day event
120
+ const script = `
121
+ tell application "Calendar"
122
+ tell calendar "${escapeAppleScript(targetCalendar)}"
123
+ set newEvent to make new event with properties {summary:"${escapeAppleScript(summary)}", start date:date "${formatAppleScriptDate(startDate)}", end date:date "${formatAppleScriptDate(endDate)}", allday event:true}
124
+ ${location ? `set location of newEvent to "${escapeAppleScript(location)}"` : ''}
125
+ ${description ? `set description of newEvent to "${escapeAppleScript(description)}"` : ''}
126
+ return uid of newEvent
127
+ end tell
128
+ end tell
129
+ `;
130
+
131
+ const uid = await runAppleScript(script);
132
+ return { success: true, id: uid, message: 'Event created successfully' };
133
+ } else {
134
+ const script = `
135
+ tell application "Calendar"
136
+ tell calendar "${escapeAppleScript(targetCalendar)}"
137
+ set newEvent to make new event with properties {summary:"${escapeAppleScript(summary)}", start date:date "${formatAppleScriptDate(startDate)}", end date:date "${formatAppleScriptDate(endDate)}"}
138
+ ${location ? `set location of newEvent to "${escapeAppleScript(location)}"` : ''}
139
+ ${description ? `set description of newEvent to "${escapeAppleScript(description)}"` : ''}
140
+ return uid of newEvent
141
+ end tell
142
+ end tell
143
+ `;
144
+
145
+ const uid = await runAppleScript(script);
146
+ return { success: true, id: uid, message: 'Event created successfully' };
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Update an existing event
152
+ * @param {string} eventId - Event UID
153
+ * @param {Object} updates - Fields to update
154
+ * @returns {Promise<Object>} - Result
155
+ */
156
+ async function updateEvent(eventId, { summary, start, end, location, description }) {
157
+ let updateCommands = [];
158
+
159
+ if (summary) updateCommands.push(`set summary of theEvent to "${escapeAppleScript(summary)}"`);
160
+ if (start) updateCommands.push(`set start date of theEvent to date "${formatAppleScriptDate(new Date(start))}"`);
161
+ if (end) updateCommands.push(`set end date of theEvent to date "${formatAppleScriptDate(new Date(end))}"`);
162
+ if (location !== undefined) updateCommands.push(`set location of theEvent to "${escapeAppleScript(location || '')}"`);
163
+ if (description !== undefined) updateCommands.push(`set description of theEvent to "${escapeAppleScript(description || '')}"`);
164
+
165
+ if (updateCommands.length === 0) {
166
+ return { success: false, message: 'No updates provided' };
167
+ }
168
+
169
+ const script = `
170
+ tell application "Calendar"
171
+ set allCalendars to calendars
172
+ repeat with cal in allCalendars
173
+ try
174
+ set theEvent to first event of cal whose uid is "${escapeAppleScript(eventId)}"
175
+ ${updateCommands.join('\n ')}
176
+ return "updated"
177
+ end try
178
+ end repeat
179
+ return "not found"
180
+ end tell
181
+ `;
182
+
183
+ const result = await runAppleScript(script);
184
+ if (result === 'not found') {
185
+ return { success: false, message: 'Event not found' };
186
+ }
187
+ return { success: true, message: 'Event updated successfully' };
188
+ }
189
+
190
+ /**
191
+ * Delete an event
192
+ * @param {string} eventId - Event UID
193
+ * @returns {Promise<Object>} - Result
194
+ */
195
+ async function deleteEvent(eventId) {
196
+ const script = `
197
+ tell application "Calendar"
198
+ set allCalendars to calendars
199
+ repeat with cal in allCalendars
200
+ try
201
+ set theEvent to first event of cal whose uid is "${escapeAppleScript(eventId)}"
202
+ delete theEvent
203
+ return "deleted"
204
+ end try
205
+ end repeat
206
+ return "not found"
207
+ end tell
208
+ `;
209
+
210
+ const result = await runAppleScript(script);
211
+ if (result === 'not found') {
212
+ return { success: false, message: 'Event not found' };
213
+ }
214
+ return { success: true, message: 'Event deleted successfully' };
215
+ }
216
+
217
+ module.exports = {
218
+ listEvents,
219
+ listCalendars,
220
+ createEvent,
221
+ updateEvent,
222
+ deleteEvent
223
+ };
package/config.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * iCloud MCP Configuration
3
+ * Centralized configuration for all iCloud services
4
+ */
5
+
6
+ // Load .env from the module dir, not the cwd — MCP clients like Claude
7
+ // Desktop launch the server from an arbitrary working directory (issue #3)
8
+ require('dotenv').config({ path: require('path').join(__dirname, '.env') });
9
+
10
+ module.exports = {
11
+ // Mode flags
12
+ USE_TEST_MODE: process.env.USE_TEST_MODE === 'true',
13
+ USE_LOCAL_MODE: process.env.USE_LOCAL_MODE !== 'false', // Default to true (local mode)
14
+
15
+ // Check if running on macOS (required for local mode)
16
+ IS_MACOS: process.platform === 'darwin',
17
+
18
+ // iCloud credentials
19
+ ICLOUD_EMAIL: process.env.ICLOUD_EMAIL,
20
+ ICLOUD_APP_PASSWORD: process.env.ICLOUD_APP_PASSWORD,
21
+
22
+ // IMAP settings for iCloud Mail
23
+ IMAP: {
24
+ HOST: 'imap.mail.me.com',
25
+ PORT: 993,
26
+ TLS: true,
27
+ AUTH_TIMEOUT: 10000,
28
+ CONN_TIMEOUT: 30000
29
+ },
30
+
31
+ // SMTP settings for sending mail
32
+ SMTP: {
33
+ HOST: 'smtp.mail.me.com',
34
+ PORT: 587,
35
+ SECURE: false // Uses STARTTLS
36
+ },
37
+
38
+ // CalDAV settings for Calendar
39
+ CALDAV: {
40
+ SERVER_URL: 'https://caldav.icloud.com',
41
+ // Principal URL will be discovered during auth
42
+ AUTH_METHOD: 'Basic'
43
+ },
44
+
45
+ // CardDAV settings for Contacts
46
+ CARDDAV: {
47
+ SERVER_URL: 'https://contacts.icloud.com',
48
+ AUTH_METHOD: 'Basic'
49
+ },
50
+
51
+ // Default settings
52
+ DEFAULTS: {
53
+ TIMEZONE: 'Europe/Madrid',
54
+ PAGE_SIZE: 25,
55
+ MAX_RESULTS: 50,
56
+ EMAIL_BODY_MAX_LENGTH: 50000,
57
+ DATE_FORMAT: 'es-ES'
58
+ },
59
+
60
+ // Email folder mappings
61
+ EMAIL_FOLDERS: {
62
+ inbox: 'INBOX',
63
+ sent: 'Sent Messages',
64
+ drafts: 'Drafts',
65
+ trash: 'Deleted Messages',
66
+ archive: 'Archive',
67
+ junk: 'Junk'
68
+ }
69
+ };
@@ -0,0 +1,331 @@
1
+ /**
2
+ * CardDAV client for iCloud Contacts
3
+ */
4
+
5
+ const { DAVClient } = require('tsdav');
6
+ const config = require('../config');
7
+ const { getCredentials } = require('../auth');
8
+
9
+ let cachedClient = null;
10
+
11
+ /**
12
+ * Get or create CardDAV client
13
+ */
14
+ async function getClient() {
15
+ if (cachedClient) {
16
+ return cachedClient;
17
+ }
18
+
19
+ const creds = getCredentials();
20
+
21
+ const client = new DAVClient({
22
+ serverUrl: config.CARDDAV.SERVER_URL,
23
+ credentials: {
24
+ username: creds.email,
25
+ password: creds.password
26
+ },
27
+ authMethod: 'Basic',
28
+ defaultAccountType: 'carddav'
29
+ });
30
+
31
+ try {
32
+ await client.login();
33
+ cachedClient = client;
34
+ return client;
35
+ } catch (error) {
36
+ if (error.message?.includes('401') || error.message?.includes('auth')) {
37
+ throw new Error('UNAUTHORIZED');
38
+ }
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Clear cached client
45
+ */
46
+ function clearClient() {
47
+ cachedClient = null;
48
+ }
49
+
50
+ /**
51
+ * Parse vCard to simple object
52
+ */
53
+ function parseVCard(vcardData, url) {
54
+ try {
55
+ const contact = {
56
+ url,
57
+ uid: '',
58
+ displayName: '',
59
+ firstName: '',
60
+ lastName: '',
61
+ emails: [],
62
+ phones: [],
63
+ organization: '',
64
+ title: '',
65
+ notes: ''
66
+ };
67
+
68
+ const lines = vcardData.split(/\r?\n/);
69
+
70
+ for (const line of lines) {
71
+ const colonIndex = line.indexOf(':');
72
+ if (colonIndex === -1) continue;
73
+
74
+ const key = line.substring(0, colonIndex).toUpperCase();
75
+ const value = line.substring(colonIndex + 1);
76
+
77
+ // Handle property parameters (like TYPE=WORK)
78
+ const keyParts = key.split(';');
79
+ const mainKey = keyParts[0];
80
+
81
+ switch (mainKey) {
82
+ case 'UID':
83
+ contact.uid = value;
84
+ break;
85
+ case 'FN':
86
+ contact.displayName = decodeVCardValue(value);
87
+ break;
88
+ case 'N':
89
+ const nameParts = value.split(';');
90
+ contact.lastName = decodeVCardValue(nameParts[0] || '');
91
+ contact.firstName = decodeVCardValue(nameParts[1] || '');
92
+ break;
93
+ case 'EMAIL':
94
+ const emailType = extractType(keyParts) || 'other';
95
+ contact.emails.push({ type: emailType, value: decodeVCardValue(value) });
96
+ break;
97
+ case 'TEL':
98
+ const phoneType = extractType(keyParts) || 'other';
99
+ contact.phones.push({ type: phoneType, value: decodeVCardValue(value) });
100
+ break;
101
+ case 'ORG':
102
+ contact.organization = decodeVCardValue(value.split(';')[0]);
103
+ break;
104
+ case 'TITLE':
105
+ contact.title = decodeVCardValue(value);
106
+ break;
107
+ case 'NOTE':
108
+ contact.notes = decodeVCardValue(value);
109
+ break;
110
+ }
111
+ }
112
+
113
+ // Use first/last name if no display name
114
+ if (!contact.displayName && (contact.firstName || contact.lastName)) {
115
+ contact.displayName = `${contact.firstName} ${contact.lastName}`.trim();
116
+ }
117
+
118
+ return contact;
119
+ } catch (error) {
120
+ console.error('Error parsing vCard:', error.message);
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Extract TYPE parameter from vCard property
127
+ */
128
+ function extractType(keyParts) {
129
+ for (const part of keyParts) {
130
+ if (part.startsWith('TYPE=')) {
131
+ return part.substring(5).toLowerCase();
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Decode vCard escaped value
139
+ */
140
+ function decodeVCardValue(value) {
141
+ if (!value) return '';
142
+ return value
143
+ .replace(/\\n/gi, '\n')
144
+ .replace(/\\,/g, ',')
145
+ .replace(/\\;/g, ';')
146
+ .replace(/\\\\/g, '\\');
147
+ }
148
+
149
+ /**
150
+ * Encode value for vCard
151
+ */
152
+ function encodeVCardValue(value) {
153
+ if (!value) return '';
154
+ return value
155
+ .replace(/\\/g, '\\\\')
156
+ .replace(/;/g, '\\;')
157
+ .replace(/,/g, '\\,')
158
+ .replace(/\n/g, '\\n');
159
+ }
160
+
161
+ /**
162
+ * Get all address books
163
+ */
164
+ async function getAddressBooks() {
165
+ const client = await getClient();
166
+ const addressBooks = await client.fetchAddressBooks();
167
+ return addressBooks.map(ab => ({
168
+ url: ab.url,
169
+ displayName: ab.displayName || 'Contacts'
170
+ }));
171
+ }
172
+
173
+ /**
174
+ * Fetch every contact from every address book, sorted by display name
175
+ */
176
+ async function fetchAllContacts() {
177
+ const client = await getClient();
178
+ const addressBooks = await client.fetchAddressBooks();
179
+
180
+ const allContacts = [];
181
+
182
+ for (const addressBook of addressBooks) {
183
+ try {
184
+ const vcards = await client.fetchVCards({ addressBook });
185
+
186
+ for (const vcard of vcards) {
187
+ const contact = parseVCard(vcard.data, vcard.url);
188
+ if (contact && contact.displayName) {
189
+ allContacts.push(contact);
190
+ }
191
+ }
192
+ } catch (error) {
193
+ console.error(`Error fetching from address book:`, error.message);
194
+ }
195
+ }
196
+
197
+ // Sort by display name
198
+ allContacts.sort((a, b) => a.displayName.localeCompare(b.displayName));
199
+
200
+ return allContacts;
201
+ }
202
+
203
+ /**
204
+ * List contacts
205
+ */
206
+ async function listContacts(count = 25) {
207
+ const allContacts = await fetchAllContacts();
208
+ return allContacts.slice(0, count);
209
+ }
210
+
211
+ /**
212
+ * Search contacts
213
+ */
214
+ async function searchContacts(query, count = 25) {
215
+ // Search the FULL contact list; capping before filtering missed anyone
216
+ // past the first `count * 2` in alphabetical order (issue behind PR #4/#7)
217
+ const allContacts = await fetchAllContacts();
218
+ const lowerQuery = query.toLowerCase();
219
+
220
+ const matches = allContacts.filter(contact => {
221
+ const searchText = [
222
+ contact.displayName,
223
+ contact.firstName,
224
+ contact.lastName,
225
+ contact.organization,
226
+ ...contact.emails.map(e => e.value),
227
+ ...contact.phones.map(p => p.value)
228
+ ].join(' ').toLowerCase();
229
+
230
+ return searchText.includes(lowerQuery);
231
+ });
232
+
233
+ return matches.slice(0, count);
234
+ }
235
+
236
+ /**
237
+ * Get contact by URL
238
+ */
239
+ async function getContact(contactUrl) {
240
+ const client = await getClient();
241
+
242
+ const vcard = await client.fetchVCards({
243
+ addressBook: { url: contactUrl.substring(0, contactUrl.lastIndexOf('/') + 1) },
244
+ objectUrls: [contactUrl]
245
+ });
246
+
247
+ if (vcard && vcard[0]) {
248
+ return parseVCard(vcard[0].data, vcard[0].url);
249
+ }
250
+
251
+ throw new Error('Contact not found');
252
+ }
253
+
254
+ /**
255
+ * Create a new contact
256
+ */
257
+ async function createContact({ displayName, firstName, lastName, email, phone, organization, title, notes }) {
258
+ const client = await getClient();
259
+ const addressBooks = await client.fetchAddressBooks();
260
+
261
+ if (addressBooks.length === 0) {
262
+ throw new Error('No address book found');
263
+ }
264
+
265
+ const addressBook = addressBooks[0];
266
+ const uid = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
267
+
268
+ let vcard = `BEGIN:VCARD
269
+ VERSION:3.0
270
+ UID:${uid}
271
+ FN:${encodeVCardValue(displayName || `${firstName || ''} ${lastName || ''}`.trim())}
272
+ N:${encodeVCardValue(lastName || '')};${encodeVCardValue(firstName || '')};;;`;
273
+
274
+ if (email) {
275
+ vcard += `\nEMAIL;TYPE=INTERNET:${encodeVCardValue(email)}`;
276
+ }
277
+ if (phone) {
278
+ vcard += `\nTEL;TYPE=CELL:${encodeVCardValue(phone)}`;
279
+ }
280
+ if (organization) {
281
+ vcard += `\nORG:${encodeVCardValue(organization)}`;
282
+ }
283
+ if (title) {
284
+ vcard += `\nTITLE:${encodeVCardValue(title)}`;
285
+ }
286
+ if (notes) {
287
+ vcard += `\nNOTE:${encodeVCardValue(notes)}`;
288
+ }
289
+
290
+ vcard += `\nEND:VCARD`;
291
+
292
+ const result = await client.createVCard({
293
+ addressBook,
294
+ filename: `${uid}.vcf`,
295
+ vCardString: vcard
296
+ });
297
+
298
+ return {
299
+ success: true,
300
+ uid,
301
+ url: result?.url
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Delete a contact
307
+ */
308
+ async function deleteContact(contactUrl) {
309
+ const client = await getClient();
310
+
311
+ await client.deleteVCard({
312
+ vCard: {
313
+ url: contactUrl,
314
+ etag: ''
315
+ }
316
+ });
317
+
318
+ return { success: true };
319
+ }
320
+
321
+ module.exports = {
322
+ getClient,
323
+ clearClient,
324
+ getAddressBooks,
325
+ listContacts,
326
+ searchContacts,
327
+ getContact,
328
+ createContact,
329
+ deleteContact,
330
+ parseVCard
331
+ };