mcp-icloud 2.1.4 → 2.2.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 CHANGED
@@ -103,7 +103,7 @@ Tools marked local only return an error in cloud mode, because the iCloud protoc
103
103
 
104
104
  Reading requires the [`imsg`](https://github.com/steipete/imsg) CLI and Full Disk Access.
105
105
 
106
- - **list-chats**: `limit` (number, optional)
106
+ - **list-chats**: `limit` (number, optional, max 1000), `since` (ISO 8601, optional — only chats active since that date, e.g. a weekly review)
107
107
  - **read-messages**: `chatId` (number); `limit` (number, optional); `start`, `end` (ISO 8601, optional); `attachments` (boolean, optional)
108
108
  - **send-message**: `to` (string), `body` (string), `file` (string, optional)
109
109
  - **react-message**: `chatId` (number), `type` (`love`, `like`, `dislike`, `laugh`, `emphasis`, `question`)
@@ -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 suffers the same AppleScript wall. The date window is already filtered 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), the query can still exceed any reasonable timeout. `list-calendars` and the event write tools are not affected. Until the planned EventKit backend lands (see [#10](https://github.com/MrGo2/icloud-mcp/issues/11)), use cloud mode for reading events: CalDAV filters server-side and is fast at any scale.
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/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
  },
@@ -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
- 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));
171
+ return sortAndSlice(JSON.parse(result), count);
76
172
  }
77
173
 
78
174
  /**
package/messages/index.js CHANGED
@@ -9,7 +9,7 @@ const localClient = require('./local-client');
9
9
  const { formatError, formatSuccess, withErrorHandler } = require('../utils/error-handler');
10
10
  const { listOutput, listResult } = require('../utils/schemas');
11
11
  const { isLocalMode } = require('../mode');
12
- const { execFileSync } = require('child_process');
12
+ const { execFile } = require('child_process');
13
13
  const fs = require('fs');
14
14
 
15
15
  // Homebrew's prefix differs between Apple Silicon and Intel, and an MCP server
@@ -29,6 +29,9 @@ function resolveImsg() {
29
29
  return 'imsg';
30
30
  }
31
31
 
32
+ // Chats scanned when `since` is set; listing 5,000 takes well under a second.
33
+ const SINCE_FETCH_LIMIT = 5000;
34
+
32
35
  function requireLocalMode(toolName) {
33
36
  if (!isLocalMode()) {
34
37
  return formatError(new Error(`${toolName} is only available in LOCAL mode. Use set-mode to switch.`));
@@ -36,14 +39,29 @@ function requireLocalMode(toolName) {
36
39
  return null;
37
40
  }
38
41
 
39
- function runImsg(args) {
40
- let output;
41
- try {
42
- output = execFileSync(resolveImsg(), args, {
43
- timeout: 15000,
42
+ // Async on purpose: a synchronous call blocks the whole server, so parallel
43
+ // reads from a client queue behind each other until they time out (#17).
44
+ function execImsg(args) {
45
+ return new Promise((resolve, reject) => {
46
+ execFile(resolveImsg(), args, {
47
+ timeout: 30000,
44
48
  encoding: 'utf-8',
45
- stdio: ['pipe', 'pipe', 'pipe']
49
+ maxBuffer: 64 * 1024 * 1024
50
+ }, (error, stdout, stderr) => {
51
+ if (error) {
52
+ error.stderr = stderr;
53
+ reject(error);
54
+ } else {
55
+ resolve(stdout);
56
+ }
46
57
  });
58
+ });
59
+ }
60
+
61
+ async function runImsg(args) {
62
+ let output;
63
+ try {
64
+ output = await execImsg(args);
47
65
  } catch (error) {
48
66
  const detail = (error.stderr || error.message || '').toString();
49
67
  if (detail.includes('permissionDenied')) {
@@ -52,6 +70,9 @@ function runImsg(args) {
52
70
  if (error.code === 'ENOENT') {
53
71
  throw new Error('The imsg CLI was not found. Install it (brew install imsg) or set ICLOUD_MCP_IMSG_PATH.');
54
72
  }
73
+ if (error.killed) {
74
+ throw new Error('imsg timed out after 30s');
75
+ }
55
76
  throw new Error(detail.trim() || 'imsg failed');
56
77
  }
57
78
 
@@ -70,17 +91,26 @@ const messagesTools = [
70
91
  name: 'list-chats',
71
92
  outputSchema: listOutput('Recent conversations'),
72
93
  title: 'List Chats',
73
- description: 'List recent iMessage/SMS conversations with contact names and last message preview',
94
+ description: 'List recent iMessage/SMS conversations, most recent first. Use `since` to get only the conversations active after a date (e.g. the last 7 days).',
74
95
  inputSchema: {
75
- limit: z.number().int().min(1).max(200).optional().describe('Number of conversations to show (default 20)')
96
+ limit: z.number().int().min(1).max(1000).optional().describe('Maximum number of conversations to return (default 20)'),
97
+ since: z.string().refine(v => !Number.isNaN(Date.parse(v)), 'since must be an ISO 8601 date')
98
+ .optional().describe('Only conversations with a message at or after this date (ISO 8601, optional)')
76
99
  },
77
100
  annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":false},
78
- handler: withErrorHandler(async ({ limit = 20 }) => {
101
+ handler: withErrorHandler(async ({ limit = 20, since }) => {
79
102
  const modeError = requireLocalMode('list-chats');
80
103
  if (modeError) return modeError;
81
- const result = runImsg(['chats', '--limit', String(limit), '--json']);
104
+ // imsg has no date filter, but returns chats newest first, so fetch a
105
+ // wide window and cut it at `since` here.
106
+ const fetchLimit = since ? Math.max(limit, SINCE_FETCH_LIMIT) : limit;
107
+ const result = await runImsg(['chats', '--limit', String(fetchLimit), '--json']);
82
108
  // imsg emits JSONL, and runImsg unwraps a single line to a bare object.
83
- const chats = Array.isArray(result) ? result : (result ? [result] : []);
109
+ let chats = Array.isArray(result) ? result : (result ? [result] : []);
110
+ if (since) {
111
+ const cutoff = Date.parse(since);
112
+ chats = chats.filter(c => Date.parse(c.last_message_at) >= cutoff).slice(0, limit);
113
+ }
84
114
  return formatSuccess(JSON.stringify(chats, null, 2), listResult(chats));
85
115
  }, 'list-chats')
86
116
  },
@@ -103,7 +133,7 @@ const messagesTools = [
103
133
  if (start) args.push('--start', start);
104
134
  if (end) args.push('--end', end);
105
135
  if (attachments) args.push('--attachments');
106
- const result = runImsg(args);
136
+ const result = await runImsg(args);
107
137
  return formatSuccess(JSON.stringify(result, null, 2));
108
138
  }, 'read-messages')
109
139
  },
@@ -128,7 +158,7 @@ const messagesTools = [
128
158
  try {
129
159
  const args = ['send', '--to', to, '--text', body];
130
160
  if (file) args.push('--file', file);
131
- const result = runImsg(args);
161
+ const result = await runImsg(args);
132
162
  return formatSuccess(JSON.stringify(result, null, 2));
133
163
  } catch (e) {
134
164
  throw new Error(`${error.message} (imsg fallback also failed: ${e.message})`);
@@ -148,7 +178,7 @@ const messagesTools = [
148
178
  handler: withErrorHandler(async ({ chatId, type }) => {
149
179
  const modeError = requireLocalMode('react-message');
150
180
  if (modeError) return modeError;
151
- const result = runImsg(['react', '--chat-id', String(chatId), '--type', type]);
181
+ const result = await runImsg(['react', '--chat-id', String(chatId), '--type', type]);
152
182
  return formatSuccess(JSON.stringify(result, null, 2));
153
183
  }, 'react-message')
154
184
  }
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",
4
+ "version": "2.2.0",
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",
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"