mcp-icloud 2.1.1 → 2.1.2

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
@@ -5,17 +5,22 @@
5
5
 
6
6
  <div align="center">
7
7
 
8
+ [![npm](https://img.shields.io/npm/v/mcp-icloud?style=flat-square&color=cb3837&logo=npm)](https://www.npmjs.com/package/mcp-icloud)
8
9
  [![CI](https://github.com/MrGo2/icloud-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/MrGo2/icloud-mcp/actions/workflows/ci.yml)
9
10
  [![Release](https://img.shields.io/github/v/release/MrGo2/icloud-mcp?style=flat-square&color=8250df)](https://github.com/MrGo2/icloud-mcp/releases/latest)
10
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](LICENSE)
11
12
  [![Node](https://img.shields.io/badge/node-%E2%89%A520-brightgreen?style=flat-square&logo=node.js&logoColor=white)](package.json)
12
13
  [![MCP](https://img.shields.io/badge/MCP-SDK%20v2-a371f7?style=flat-square)](https://modelcontextprotocol.io)
14
+ [![icloud-mcp MCP server](https://glama.ai/mcp/servers/MrGo2/icloud-mcp/badges/score.svg)](https://glama.ai/mcp/servers/MrGo2/icloud-mcp)
13
15
 
14
16
  </div>
15
17
 
18
+ <img src="assets/demo.gif" alt="Claude creating a note, a reminder and a calendar event through icloud-mcp" width="100%">
19
+
16
20
  icloud-mcp covers seven Apple services with two interchangeable backends, and as far as we know it is the only MCP server that does. In local mode it drives the native macOS apps through AppleScript: no credentials, no network, and it reaches the services the iCloud protocols do not expose (Reminders, Notes, Messages, Safari). In cloud mode it speaks IMAP/SMTP, CalDAV and CardDAV instead, so it runs on any machine, not just a Mac. The `set-mode` tool switches between the two at runtime, without a restart.
17
21
 
18
22
  [![Install with NPX in VS Code](https://img.shields.io/badge/VS_Code-NPM-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=icloud&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22mcp-icloud%22%5D%7D)
23
+ [![Install in Cursor](https://img.shields.io/badge/Cursor-Install-000000?style=flat-square&logo=cursor&logoColor=white)](https://cursor.com/en/install-mcp?name=icloud&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIm1jcC1pY2xvdWQiXX0=)
19
24
 
20
25
  ## Features
21
26
 
@@ -29,6 +34,8 @@ Credentials are never logged. `ICLOUD_APP_PASSWORD` is read once at startup and
29
34
 
30
35
  Every tool argument is validated against a zod schema before the handler runs. Arguments are also coerced at each AppleScript interpolation site, so a value that is not a number cannot reach a script template.
31
36
 
37
+ The server collects nothing and keeps nothing: no telemetry, no analytics, no state on disk. The full policy is in [PRIVACY.md](PRIVACY.md).
38
+
32
39
  ## Tools
33
40
 
34
41
  Tools marked local only return an error in cloud mode, because the iCloud protocols do not expose those services.
@@ -114,6 +121,20 @@ Every tool declares its behaviour explicitly instead of relying on the spec defa
114
121
 
115
122
  Requires Node.js 20 or newer.
116
123
 
124
+ ### Let your AI install it
125
+
126
+ Paste this into Claude, Claude Code, Cursor, ChatGPT or any agent with shell or config access:
127
+
128
+ ```
129
+ Install the iCloud MCP server. It is the npm package "mcp-icloud" (Node 20+),
130
+ stdio transport, server name "icloud", command "npx" with args ["-y", "mcp-icloud"].
131
+ On Claude Code run: claude mcp add --transport stdio icloud -- npx -y mcp-icloud
132
+ For other clients, add that command/args pair to the client's MCP config file.
133
+ Local mode (default) needs macOS; cloud mode needs env vars USE_LOCAL_MODE=false,
134
+ ICLOUD_EMAIL and ICLOUD_APP_PASSWORD (app-specific password from account.apple.com).
135
+ Docs: https://github.com/MrGo2/icloud-mcp
136
+ ```
137
+
117
138
  ### Claude Desktop
118
139
 
119
140
  Add to `claude_desktop_config.json`:
package/calendar/index.js CHANGED
@@ -182,7 +182,7 @@ const calendarTools = [
182
182
  name: 'list-events',
183
183
  outputSchema: listOutput('Calendar events'),
184
184
  title: 'List Events',
185
- description: 'Lists upcoming calendar events',
185
+ description: 'Lists upcoming calendar events within a look-ahead window (30 days by default, up to 365). Each event carries its title, start and end times, and the ref handle that update-event and delete-event take as their eventUrl argument.',
186
186
  inputSchema: {
187
187
  count: z.number().int().min(1).max(50).optional().describe('Number of events to retrieve (default: 25, max: 50)'),
188
188
  daysAhead: z.number().int().min(1).max(365).optional().describe('Number of days to look ahead (default: 30)')
@@ -3,7 +3,7 @@
3
3
  * Accesses Calendar.app via AppleScript
4
4
  */
5
5
 
6
- const { runAppleScript, runJXA, escapeAppleScript, escapeJXA, asInt, formatAppleScriptDate } = require('../utils/applescript');
6
+ const { runAppleScript, runJXA, escapeAppleScript, escapeJXA, asInt, appleScriptDateStmts } = require('../utils/applescript');
7
7
  const config = require('../config');
8
8
 
9
9
  /**
@@ -115,26 +115,12 @@ async function createEvent({ summary, start, end, location, description, calenda
115
115
  // Use default calendar if none specified
116
116
  const targetCalendar = calendarName || 'Calendar';
117
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 = `
118
+ const script = `
135
119
  tell application "Calendar"
120
+ ${appleScriptDateStmts('newStart', startDate)}
121
+ ${appleScriptDateStmts('newEnd', endDate)}
136
122
  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)}"}
123
+ set newEvent to make new event with properties {summary:"${escapeAppleScript(summary)}", start date:newStart, end date:newEnd${allDay ? ', allday event:true' : ''}}
138
124
  ${location ? `set location of newEvent to "${escapeAppleScript(location)}"` : ''}
139
125
  ${description ? `set description of newEvent to "${escapeAppleScript(description)}"` : ''}
140
126
  return uid of newEvent
@@ -142,9 +128,8 @@ async function createEvent({ summary, start, end, location, description, calenda
142
128
  end tell
143
129
  `;
144
130
 
145
- const uid = await runAppleScript(script);
146
- return { success: true, id: uid, message: 'Event created successfully' };
147
- }
131
+ const uid = await runAppleScript(script);
132
+ return { success: true, id: uid, message: 'Event created successfully' };
148
133
  }
149
134
 
150
135
  /**
@@ -155,10 +140,17 @@ async function createEvent({ summary, start, end, location, description, calenda
155
140
  */
156
141
  async function updateEvent(eventId, { summary, start, end, location, description }) {
157
142
  let updateCommands = [];
143
+ let dateSetup = [];
158
144
 
159
145
  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))}"`);
146
+ if (start) {
147
+ dateSetup.push(appleScriptDateStmts('newStart', new Date(start)));
148
+ updateCommands.push('set start date of theEvent to newStart');
149
+ }
150
+ if (end) {
151
+ dateSetup.push(appleScriptDateStmts('newEnd', new Date(end)));
152
+ updateCommands.push('set end date of theEvent to newEnd');
153
+ }
162
154
  if (location !== undefined) updateCommands.push(`set location of theEvent to "${escapeAppleScript(location || '')}"`);
163
155
  if (description !== undefined) updateCommands.push(`set description of theEvent to "${escapeAppleScript(description || '')}"`);
164
156
 
@@ -168,6 +160,7 @@ async function updateEvent(eventId, { summary, start, end, location, description
168
160
 
169
161
  const script = `
170
162
  tell application "Calendar"
163
+ ${dateSetup.join('\n ')}
171
164
  set allCalendars to calendars
172
165
  repeat with cal in allCalendars
173
166
  try
package/contacts/index.js CHANGED
@@ -260,7 +260,7 @@ const contactsTools = [
260
260
  name: 'list-contacts',
261
261
  outputSchema: listOutput('Contacts'),
262
262
  title: 'List Contacts',
263
- description: 'Lists contacts from your iCloud address book',
263
+ description: 'Lists contacts from the user\'s address book, up to 50 per call, including the handle that read-contact and delete-contact take. For a targeted lookup, search-contacts filters by name, email or phone instead of paging through the full list.',
264
264
  inputSchema: {
265
265
  count: z.number().int().min(1).max(50).optional().describe('Number of contacts to retrieve (default: 25, max: 50)')
266
266
  },
@@ -292,7 +292,7 @@ const contactsTools = [
292
292
  {
293
293
  name: 'create-contact',
294
294
  title: 'Create Contact',
295
- description: 'Creates a new contact',
295
+ description: 'Creates a contact in the user\'s address book (Contacts.app in local mode, iCloud over CardDAV in cloud mode). Every field is optional, so pass at least a name (displayName, or firstName and lastName) along with any known email, phone, organization, job title or notes.',
296
296
  inputSchema: {
297
297
  displayName: z.string().optional().describe('Full display name'),
298
298
  firstName: z.string().optional().describe('First name'),
@@ -309,7 +309,7 @@ const contactsTools = [
309
309
  {
310
310
  name: 'delete-contact',
311
311
  title: 'Delete Contact',
312
- description: 'Deletes a contact',
312
+ description: 'Permanently deletes a contact from the user\'s address book. Takes the handle returned by list-contacts or search-contacts; confirm the target with read-contact first, because there is no undo through this server.',
313
313
  inputSchema: {
314
314
  contactUrl: z.string().describe('URL or ID of the contact to delete (from list-contacts output)')
315
315
  },
package/email/index.js CHANGED
@@ -216,7 +216,7 @@ const emailTools = [
216
216
  {
217
217
  name: 'send-email',
218
218
  title: 'Send Email',
219
- description: 'Composes and sends an email',
219
+ description: 'Sends an email immediately from the user\'s account to one or more comma-separated recipients, with optional CC, BCC and HTML body. Cloud mode delivers over SMTP and returns the new message ID; local mode sends through Mail.app, which only sends plain text and returns no ID. There is no draft step, so the message goes out as soon as the tool runs.',
220
220
  inputSchema: {
221
221
  to: z.string().describe('Recipient email address(es), comma-separated'),
222
222
  cc: z.string().optional().describe('CC recipient(s), comma-separated'),
@@ -232,7 +232,7 @@ const emailTools = [
232
232
  name: 'search-emails',
233
233
  outputSchema: listOutput('Matching emails'),
234
234
  title: 'Search Emails',
235
- description: 'Search for emails by criteria',
235
+ description: 'Searches one mail folder (inbox by default) by free text, sender, subject and unread state; the filters combine as AND. Results carry the same summary fields as list-emails, including the ref handle that read-email and mark-as-read take.',
236
236
  inputSchema: {
237
237
  query: z.string().optional().describe('Text to search in email content'),
238
238
  from: z.string().optional().describe('Filter by sender'),
package/index.js CHANGED
@@ -61,6 +61,10 @@ function buildServer() {
61
61
  },
62
62
  {
63
63
  capabilities: { tools: { listChanged: true } },
64
+ // The 41-tool list is identical in both modes (mode changes behaviour,
65
+ // not the roster), so clients may cache it. Without this the SDK
66
+ // advertises ttlMs: 0 and every 2026-era client re-fetches constantly.
67
+ cacheHints: { 'tools/list': { ttlMs: 3600000, cacheScope: 'public' } },
64
68
  instructions: `Access to Apple services in two modes.
65
69
 
66
70
  LOCAL (default, macOS only) drives the native apps via AppleScript.
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.1",
4
+ "version": "2.1.2",
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": {
@@ -3,7 +3,7 @@
3
3
  * Accesses Reminders.app via AppleScript
4
4
  */
5
5
 
6
- const { runAppleScript, runJXA, escapeAppleScript, escapeJXA, asInt, asBool, formatAppleScriptDate } = require('../utils/applescript');
6
+ const { runAppleScript, runJXA, escapeAppleScript, escapeJXA, asInt, asBool, appleScriptDateStmts } = require('../utils/applescript');
7
7
 
8
8
  /**
9
9
  * List all reminder lists
@@ -109,7 +109,8 @@ async function createReminder({ name, body, dueDate, listName = 'Reminders', pri
109
109
 
110
110
  if (dueDate) {
111
111
  script += `
112
- set due date of newReminder to date "${formatAppleScriptDate(new Date(dueDate))}"
112
+ ${appleScriptDateStmts('theDue', new Date(dueDate))}
113
+ set due date of newReminder to theDue
113
114
  `;
114
115
  }
115
116
 
@@ -135,11 +136,16 @@ async function updateReminder(reminderId, { name, body, dueDate, priority }) {
135
136
  if (name) updateCommands.push(`set name of theReminder to "${escapeAppleScript(name)}"`);
136
137
  if (body !== undefined) updateCommands.push(`set body of theReminder to "${escapeAppleScript(body || '')}"`);
137
138
  if (priority !== undefined) updateCommands.push(`set priority of theReminder to ${asInt(priority, 0)}`);
138
- if (dueDate) updateCommands.push(`set due date of theReminder to date "${formatAppleScriptDate(new Date(dueDate))}"`);
139
+ let dateSetup = [];
140
+ if (dueDate) {
141
+ dateSetup.push(appleScriptDateStmts('theDue', new Date(dueDate)));
142
+ updateCommands.push('set due date of theReminder to theDue');
143
+ }
139
144
  if (dueDate === null) updateCommands.push(`set due date of theReminder to missing value`);
140
145
 
141
146
  const script = `
142
147
  tell application "Reminders"
148
+ ${dateSetup.join('\n ')}
143
149
  set allLists to lists
144
150
  repeat with theList in allLists
145
151
  try
@@ -161,18 +161,24 @@ function asBool(value, fallback = false) {
161
161
  * @param {Date|string} date - Date to format
162
162
  * @returns {string} - AppleScript date string
163
163
  */
164
- function formatAppleScriptDate(date) {
164
+ /**
165
+ * AppleScript statements that build a Date into `varName`.
166
+ * `date "August 15, 2026 6:00 PM"` literals only parse when the string matches
167
+ * the system locale, so the date is assembled numerically instead. Day is set
168
+ * to 1 first so applying the components one by one cannot overflow a month.
169
+ */
170
+ function appleScriptDateStmts(varName, date) {
165
171
  const d = date instanceof Date ? date : new Date(date);
166
- // Format: "January 15, 2026 at 10:30:00 AM"
167
- return d.toLocaleString('en-US', {
168
- year: 'numeric',
169
- month: 'long',
170
- day: 'numeric',
171
- hour: 'numeric',
172
- minute: '2-digit',
173
- second: '2-digit',
174
- hour12: true
175
- }).replace(',', ' at');
172
+ if (isNaN(d.getTime())) throw new Error(`Invalid date: ${date}`);
173
+ const secs = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
174
+ return [
175
+ `set ${varName} to (current date)`,
176
+ `set day of ${varName} to 1`,
177
+ `set year of ${varName} to ${d.getFullYear()}`,
178
+ `set month of ${varName} to ${d.getMonth() + 1}`,
179
+ `set day of ${varName} to ${d.getDate()}`,
180
+ `set time of ${varName} to ${secs}`
181
+ ].join('\n ');
176
182
  }
177
183
 
178
184
  /**
@@ -209,6 +215,6 @@ module.exports = {
209
215
  escapeJXA,
210
216
  asInt,
211
217
  asBool,
212
- formatAppleScriptDate,
218
+ appleScriptDateStmts,
213
219
  AppleScriptError
214
220
  };