mcp-icloud 2.1.5 → 2.2.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.
- package/README.md +5 -1
- package/auth/index.js +2 -1
- package/calendar/index.js +2 -2
- package/calendar/local-client.js +100 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -216,7 +216,11 @@ Mail.app tools iterate messages through AppleScript, which is slow on very large
|
|
|
216
216
|
|
|
217
217
|
### Known limitation: large calendar sets
|
|
218
218
|
|
|
219
|
-
`list-events` in local mode
|
|
219
|
+
`list-events` in local mode no longer scripts Calendar.app when it can avoid it. It reads the date window straight from EventKit, the macOS calendar store, in one query that returns in about a tenth of a second even with dozens of subscribed calendars. EventKit needs its own permission, separate from Automation: the first `list-events` call asks the program that launches the server (Claude Desktop, your terminal, or your editor) for access to your calendars. Approve it once, or grant it later under **System Settings → Privacy & Security → Calendars**.
|
|
220
|
+
|
|
221
|
+
If that access is denied, restricted or left unanswered, `list-events` falls back to the old Calendar.app query and logs `EventKit unavailable` on stderr. That path still filters the window inside Calendar.app with a `whose` query, but on accounts with many calendars, especially subscribed ones carrying years of events (holidays, sports fixtures, travel feeds), it can exceed any reasonable timeout. In that case grant Calendars access, or use cloud mode, where CalDAV filters server-side (see [#11](https://github.com/MrGo2/icloud-mcp/issues/11)).
|
|
222
|
+
|
|
223
|
+
Through EventKit, a recurring event appears once per occurrence in the window, and every occurrence carries the series id. Passing that id to `update-event` or `delete-event` in local mode changes or deletes the whole series.
|
|
220
224
|
|
|
221
225
|
### App-specific password (cloud mode)
|
|
222
226
|
|
package/auth/index.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
7
|
const config = require('../config');
|
|
8
|
+
const { version } = require('../package.json');
|
|
8
9
|
const { formatSuccess, formatError } = require('../utils/error-handler');
|
|
9
10
|
const { getMode, setMode, isLocalMode } = require('../mode');
|
|
10
11
|
|
|
@@ -38,7 +39,7 @@ async function handleAbout() {
|
|
|
38
39
|
const cloudServices = 'Email, Calendar, Contacts';
|
|
39
40
|
|
|
40
41
|
return formatSuccess(
|
|
41
|
-
`iCloud MCP Server
|
|
42
|
+
`iCloud MCP Server v${version}
|
|
42
43
|
|
|
43
44
|
Mode: ${mode.toUpperCase()}
|
|
44
45
|
Services: ${mode === 'local' ? localServices : cloudServices}
|
package/calendar/index.js
CHANGED
|
@@ -209,7 +209,7 @@ const calendarTools = [
|
|
|
209
209
|
{
|
|
210
210
|
name: 'update-event',
|
|
211
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).',
|
|
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). In local mode, every occurrence of a recurring event shares the series UID, so this acts on the whole series, not one occurrence.',
|
|
213
213
|
inputSchema: {
|
|
214
214
|
eventUrl: z.string().describe('Event handle from list-events: the URL in cloud mode, the UID in local mode'),
|
|
215
215
|
summary: z.string().optional().describe('New event title (optional)'),
|
|
@@ -224,7 +224,7 @@ const calendarTools = [
|
|
|
224
224
|
{
|
|
225
225
|
name: 'delete-event',
|
|
226
226
|
title: 'Delete Event',
|
|
227
|
-
description: 'Deletes a calendar event',
|
|
227
|
+
description: 'Deletes a calendar event. In local mode, every occurrence of a recurring event shares the series UID, so this acts on the whole series, not one occurrence.',
|
|
228
228
|
inputSchema: {
|
|
229
229
|
eventUrl: z.string().describe('Event handle from list-events: the URL in cloud mode, the UID in local mode')
|
|
230
230
|
},
|
package/calendar/local-client.js
CHANGED
|
@@ -6,8 +6,19 @@
|
|
|
6
6
|
const { runAppleScript, runJXA, escapeAppleScript, escapeJXA, asInt, appleScriptDateStmts } = require('../utils/applescript');
|
|
7
7
|
const config = require('../config');
|
|
8
8
|
|
|
9
|
+
// EKAuthorizationStatus: 0 notDetermined, 1 restricted, 2 denied, 3 fullAccess, 4 writeOnly
|
|
10
|
+
const EK_FULL_ACCESS = 3;
|
|
11
|
+
// How long to wait for the user to answer the Calendars permission prompt.
|
|
12
|
+
// The completion block never fires inside osascript, so the status is polled.
|
|
13
|
+
const EK_REQUEST_TIMEOUT_SECONDS = 25;
|
|
14
|
+
// Ask for access at most once per server process: a host that never shows the
|
|
15
|
+
// prompt would otherwise pay the full timeout on every call.
|
|
16
|
+
let eventKitRequested = false;
|
|
17
|
+
|
|
9
18
|
/**
|
|
10
|
-
* List upcoming events from every calendar
|
|
19
|
+
* List upcoming events from every calendar.
|
|
20
|
+
* Uses EventKit (fast, one store query) when the host has full Calendars
|
|
21
|
+
* access; otherwise falls back to scripting Calendar.app.
|
|
11
22
|
* @param {number} count - Number of events to retrieve
|
|
12
23
|
* @param {number} daysAhead - Number of days to look ahead
|
|
13
24
|
* @returns {Promise<Array>} - List of events
|
|
@@ -16,6 +27,93 @@ async function listEvents(count = 25, daysAhead = 30) {
|
|
|
16
27
|
const now = new Date();
|
|
17
28
|
const future = new Date(now.getTime() + asInt(daysAhead, 30) * 24 * 60 * 60 * 1000);
|
|
18
29
|
|
|
30
|
+
let status;
|
|
31
|
+
try {
|
|
32
|
+
const result = JSON.parse(await runJXA(eventKitScript(now, future, !eventKitRequested)));
|
|
33
|
+
status = result.status;
|
|
34
|
+
if (result.requested) eventKitRequested = true;
|
|
35
|
+
if (status === EK_FULL_ACCESS && Array.isArray(result.events)) {
|
|
36
|
+
return sortAndSlice(result.events, count);
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
status = 'error: ' + error.message;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.error(`[icloud-mcp] list-events: EventKit unavailable (status ${status}), using Calendar.app`);
|
|
43
|
+
return listEventsAppleScript(count, now, future);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* JXA that reads the window from EventKit. Occurrences of recurring events
|
|
48
|
+
* are expanded; each carries its series' calendarItemIdentifier, which is
|
|
49
|
+
* the Calendar.app `uid` that update-event and delete-event resolve.
|
|
50
|
+
*/
|
|
51
|
+
function eventKitScript(now, future, request) {
|
|
52
|
+
return `
|
|
53
|
+
ObjC.import('EventKit');
|
|
54
|
+
ObjC.import('Foundation');
|
|
55
|
+
const T = $.EKEntityTypeEvent;
|
|
56
|
+
const store = $.EKEventStore.alloc.init;
|
|
57
|
+
const readStatus = () => Number($.EKEventStore.authorizationStatusForEntityType(T));
|
|
58
|
+
let status = readStatus();
|
|
59
|
+
let requested = false;
|
|
60
|
+
|
|
61
|
+
if (${request ? 'true' : 'false'} && (status === 0 || status === 4)) {
|
|
62
|
+
requested = true;
|
|
63
|
+
if (store.respondsToSelector('requestFullAccessToEventsWithCompletion:')) {
|
|
64
|
+
store.requestFullAccessToEventsWithCompletion((granted, err) => {});
|
|
65
|
+
} else {
|
|
66
|
+
store.requestAccessToEntityTypeCompletion(T, (granted, err) => {});
|
|
67
|
+
}
|
|
68
|
+
const deadline = Date.now() + ${asInt(EK_REQUEST_TIMEOUT_SECONDS, 25)} * 1000;
|
|
69
|
+
while (status !== 3 && Date.now() < deadline) {
|
|
70
|
+
$.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.2));
|
|
71
|
+
status = readStatus();
|
|
72
|
+
if (status === 1 || status === 2) break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let events = null;
|
|
77
|
+
if (status === 3) {
|
|
78
|
+
const rangeStart = new Date(${JSON.stringify(now.toISOString())});
|
|
79
|
+
const rangeEnd = new Date(${JSON.stringify(future.toISOString())});
|
|
80
|
+
const nsDate = (d) => $.NSDate.dateWithTimeIntervalSince1970(d.getTime() / 1000);
|
|
81
|
+
const toDate = (ns) => ns && !ns.isNil() ? new Date(Number(ns.timeIntervalSince1970) * 1000) : null;
|
|
82
|
+
const pred = store.predicateForEventsWithStartDateEndDateCalendars(nsDate(rangeStart), nsDate(rangeEnd), $());
|
|
83
|
+
const evs = store.eventsMatchingPredicate(pred);
|
|
84
|
+
events = [];
|
|
85
|
+
for (let i = 0; i < evs.count; i++) {
|
|
86
|
+
const ev = evs.objectAtIndex(i);
|
|
87
|
+
const start = toDate(ev.startDate);
|
|
88
|
+
// Same window as the Calendar.app path: events starting inside it.
|
|
89
|
+
if (!start || start < rangeStart || start > rangeEnd) continue;
|
|
90
|
+
const end = toDate(ev.endDate);
|
|
91
|
+
events.push({
|
|
92
|
+
id: ObjC.unwrap(ev.calendarItemIdentifier),
|
|
93
|
+
summary: ObjC.unwrap(ev.title) || '',
|
|
94
|
+
startDate: start.toISOString(),
|
|
95
|
+
endDate: end ? end.toISOString() : null,
|
|
96
|
+
location: ObjC.unwrap(ev.location) || '',
|
|
97
|
+
calendar: ObjC.unwrap(ev.calendar.title)
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
JSON.stringify({ status, requested, events });
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sortAndSlice(events, count) {
|
|
107
|
+
events.sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
|
|
108
|
+
return events.slice(0, asInt(count, 25));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Calendar.app path: used when EventKit access is not granted.
|
|
113
|
+
* Slow on accounts with many or subscribed calendars.
|
|
114
|
+
*/
|
|
115
|
+
async function listEventsAppleScript(count, now, future) {
|
|
116
|
+
|
|
19
117
|
// Batch property access: per-event JXA calls trigger AppleEvent errors.
|
|
20
118
|
// The window is filtered inside the script so a busy calendar cannot push
|
|
21
119
|
// upcoming events out of the result set before we ever see them.
|
|
@@ -70,9 +168,7 @@ async function listEvents(count = 25, daysAhead = 30) {
|
|
|
70
168
|
const result = await runJXA(script);
|
|
71
169
|
if (!result) return [];
|
|
72
170
|
|
|
73
|
-
|
|
74
|
-
events.sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
|
|
75
|
-
return events.slice(0, asInt(count, 25));
|
|
171
|
+
return sortAndSlice(JSON.parse(result), count);
|
|
76
172
|
}
|
|
77
173
|
|
|
78
174
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-icloud",
|
|
3
3
|
"mcpName": "io.github.MrGo2/icloud-mcp",
|
|
4
|
-
"version": "2.1
|
|
4
|
+
"version": "2.2.1",
|
|
5
5
|
"description": "MCP server for Apple services - Email, Calendar, Contacts, Reminders, Notes, Messages and Safari - with dual local (AppleScript) and cloud (IMAP/CalDAV/CardDAV) modes",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
29
|
"start": "node index.js",
|
|
30
|
-
"test": "node test/security.test.js && node test/local-output.test.js && node test/email-read.test.js && node test/messages.test.js",
|
|
30
|
+
"test": "node test/security.test.js && node test/local-output.test.js && node test/email-read.test.js && node test/messages.test.js && node test/calendar-eventkit.test.js",
|
|
31
31
|
"test-mode": "USE_TEST_MODE=true node index.js",
|
|
32
32
|
"inspect": "npx @modelcontextprotocol/inspector node index.js",
|
|
33
33
|
"test:local": "node test/local-output.test.js"
|