mcp-icloud 2.1.1 → 2.1.3

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/PRIVACY.md ADDED
@@ -0,0 +1,55 @@
1
+ # Privacy policy
2
+
3
+ Effective 10 August 2026. Applies to the mcp-icloud server (the software you
4
+ run), not to any AI client you connect it to.
5
+
6
+ ## The short version
7
+
8
+ mcp-icloud runs on your machine and works for you. It has no servers of its
9
+ own, no analytics, no telemetry, and no way to see your data. The author
10
+ never receives anything.
11
+
12
+ ## What the software touches, and where it goes
13
+
14
+ When a connected AI client (Claude Desktop, for example) calls a tool, the
15
+ server reads or writes data in your Apple services: mail, calendar events,
16
+ contacts, reminders, notes, messages, and Safari tabs. Two things happen
17
+ with that data, and nothing else.
18
+
19
+ First, tool results are returned to the AI client that asked for them. What
20
+ that client does with the data is governed by its own privacy policy, not
21
+ this one. Nothing is sent anywhere unless a client you connected asks.
22
+
23
+ Second, in cloud mode the server talks to Apple's servers on your behalf
24
+ (imap.mail.me.com, smtp.mail.me.com, caldav.icloud.com,
25
+ contacts.icloud.com), over TLS, authenticated with credentials you
26
+ provided. In local mode the server makes no network connections at all; it
27
+ drives the apps already on your Mac, and macOS asks for your consent per
28
+ app before any of that works. You can revoke that consent at any time in
29
+ System Settings under Privacy and Security.
30
+
31
+ ## Credentials
32
+
33
+ Cloud mode needs your iCloud address and an app-specific password. You
34
+ store them yourself, either in a local .env file or in the macOS Keychain
35
+ when you install the desktop extension. The server reads them once at
36
+ startup, sends them only to Apple, and never writes them to any log.
37
+ Diagnostics print a fixed mask instead of the value. Local mode needs no
38
+ credentials at all.
39
+
40
+ ## What is kept
41
+
42
+ Nothing. The server is stateless: no database, no cache, no files written.
43
+ When it stops, nothing of yours remains in it.
44
+
45
+ ## Third parties
46
+
47
+ There are none. No data is sold, shared, or transferred to anyone. The only
48
+ parties that ever see your data are Apple (which already has it) and the AI
49
+ client you chose to connect.
50
+
51
+ ## Changes and contact
52
+
53
+ Changes to this policy are made in this file, in the open, with the
54
+ repository's history as the record. Questions and concerns:
55
+ https://github.com/MrGo2/icloud-mcp/issues
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,10 @@ 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
+ ## Privacy Policy
38
+
39
+ The server collects nothing and keeps nothing: no telemetry, no analytics, no state on disk. Your data moves only between your machine, the AI client you connect and Apple's services. The full policy, covering collection, storage, third-party sharing, retention and contact, is in [PRIVACY.md](PRIVACY.md).
40
+
32
41
  ## Tools
33
42
 
34
43
  Tools marked local only return an error in cloud mode, because the iCloud protocols do not expose those services.
@@ -108,12 +117,26 @@ Reading requires the [`imsg`](https://github.com/steipete/imsg) CLI and Full Dis
108
117
 
109
118
  ### Tool annotations (MCP hints)
110
119
 
111
- Every tool declares its behaviour explicitly instead of relying on the spec defaults, which are deliberately pessimistic. All `list-*`, `read-*`, `search-*` and `get-*` tools, plus `about` and `check-auth-status`, are marked read-only. The four destructive tools (`delete-event`, `delete-contact`, `delete-reminder`, `close-safari-tab`) carry `destructiveHint`. Tools that reach the network or another person carry `openWorldHint`: all Email and Calendar tools, `send-message`, `react-message` and `open-safari-url`. The read-only tools plus `mark-as-read`, `complete-reminder`, `update-reminder`, `update-event` and `set-mode` are marked idempotent.
120
+ Every tool declares its behaviour explicitly instead of relying on the spec defaults, which are deliberately pessimistic. All `list-*`, `read-*`, `search-*` and `get-*` tools, plus `about` and `check-auth-status`, are marked read-only. Nine tools carry `destructiveHint`: the four deletions (`delete-event`, `delete-contact`, `delete-reminder`, `close-safari-tab`), the three that reach another person irrevocably (`send-email`, `send-message`, `react-message`) and the two in-place updates (`update-event`, `update-reminder`). Tools that reach the network or another person carry `openWorldHint`: all Email and Calendar tools, `send-message`, `react-message` and `open-safari-url`. The read-only tools plus `mark-as-read`, `complete-reminder`, `update-reminder`, `update-event` and `set-mode` are marked idempotent.
112
121
 
113
122
  ## Installation
114
123
 
115
124
  Requires Node.js 20 or newer.
116
125
 
126
+ ### Let your AI install it
127
+
128
+ Paste this into Claude, Claude Code, Cursor, ChatGPT or any agent with shell or config access:
129
+
130
+ ```
131
+ Install the iCloud MCP server. It is the npm package "mcp-icloud" (Node 20+),
132
+ stdio transport, server name "icloud", command "npx" with args ["-y", "mcp-icloud"].
133
+ On Claude Code run: claude mcp add --transport stdio icloud -- npx -y mcp-icloud
134
+ For other clients, add that command/args pair to the client's MCP config file.
135
+ Local mode (default) needs macOS; cloud mode needs env vars USE_LOCAL_MODE=false,
136
+ ICLOUD_EMAIL and ICLOUD_APP_PASSWORD (app-specific password from account.apple.com).
137
+ Docs: https://github.com/MrGo2/icloud-mcp
138
+ ```
139
+
117
140
  ### Claude Desktop
118
141
 
119
142
  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)')
@@ -218,7 +218,7 @@ const calendarTools = [
218
218
  description: z.string().optional().describe('New description (optional)'),
219
219
  location: z.string().optional().describe('New location (optional)')
220
220
  },
221
- annotations: {"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":true},
221
+ annotations: {"readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":true},
222
222
  handler: withErrorHandler(handleUpdateEvent, 'update-event')
223
223
  },
224
224
  {
@@ -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; 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'),
@@ -225,14 +225,14 @@ const emailTools = [
225
225
  body: z.string().describe('Email body content'),
226
226
  isHtml: z.boolean().optional().describe('Whether the body is HTML (default: false)')
227
227
  },
228
- annotations: {"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},
228
+ annotations: {"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":true},
229
229
  handler: withErrorHandler(handleSendEmail, 'send-email')
230
230
  },
231
231
  {
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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * iCloud MCP Server v2.1.1
4
+ * iCloud MCP Server
5
5
  *
6
6
  * Provides Claude with access to Apple services:
7
7
  * - Email (via IMAP/SMTP or Mail.app)
@@ -21,6 +21,7 @@ const { McpServer } = require('@modelcontextprotocol/server');
21
21
  const { serveStdio } = require('@modelcontextprotocol/server/stdio');
22
22
 
23
23
  const config = require('./config');
24
+ const { version } = require('./package.json');
24
25
  const { getMode, onModeChange } = require('./mode');
25
26
 
26
27
  // Import all modules - tools are always available, handlers check mode
@@ -56,11 +57,15 @@ function buildServer() {
56
57
  const server = new McpServer(
57
58
  {
58
59
  name: 'icloud-mcp',
59
- version: '2.1.1',
60
+ version,
60
61
  title: 'iCloud MCP'
61
62
  },
62
63
  {
63
64
  capabilities: { tools: { listChanged: true } },
65
+ // The 41-tool list is identical in both modes (mode changes behaviour,
66
+ // not the roster), so clients may cache it. Without this the SDK
67
+ // advertises ttlMs: 0 and every 2026-era client re-fetches constantly.
68
+ cacheHints: { 'tools/list': { ttlMs: 3600000, cacheScope: 'public' } },
64
69
  instructions: `Access to Apple services in two modes.
65
70
 
66
71
  LOCAL (default, macOS only) drives the native apps via AppleScript.
@@ -94,7 +99,7 @@ mode returns an error. Use set-mode to switch without restarting.`
94
99
  return server;
95
100
  }
96
101
 
97
- console.error('[icloud-mcp] Starting iCloud MCP server v2.1.1...');
102
+ console.error(`[icloud-mcp] Starting iCloud MCP server v${version}...`);
98
103
  console.error(`[icloud-mcp] Initial mode: ${getMode().toUpperCase()}`);
99
104
  console.error(`[icloud-mcp] Tools available: ${TOOLS.length}`);
100
105
 
package/messages/index.js CHANGED
@@ -110,13 +110,13 @@ const messagesTools = [
110
110
  {
111
111
  name: 'send-message',
112
112
  title: 'Send Message',
113
- description: 'Send an iMessage or SMS. IMPORTANT: Always confirm with user before sending.',
113
+ description: 'Sends an iMessage or SMS to a phone number or email handle, with an optional file attachment. The message is delivered as soon as the tool runs; there is no draft step and no recall.',
114
114
  inputSchema: {
115
115
  to: z.string().describe('Recipient phone number (with country code) or email'),
116
116
  body: z.string().describe('Message content'),
117
117
  file: z.string().optional().describe('Path to file attachment (optional)')
118
118
  },
119
- annotations: {"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},
119
+ annotations: {"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":true},
120
120
  handler: withErrorHandler(async ({ to, body, file }) => {
121
121
  const modeError = requireLocalMode('send-message');
122
122
  if (modeError) return modeError;
@@ -144,7 +144,7 @@ const messagesTools = [
144
144
  chatId: z.number().int().describe('Chat ID'),
145
145
  type: z.enum(['love', 'like', 'dislike', 'laugh', 'emphasis', 'question']).describe('Reaction type')
146
146
  },
147
- annotations: {"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},
147
+ annotations: {"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":true},
148
148
  handler: withErrorHandler(async ({ chatId, type }) => {
149
149
  const modeError = requireLocalMode('react-message');
150
150
  if (modeError) return modeError;
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.3",
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": {
@@ -22,6 +22,7 @@
22
22
  "utils/",
23
23
  "LICENSE",
24
24
  "README.md",
25
+ "PRIVACY.md",
25
26
  "DISCLOSURE"
26
27
  ],
27
28
  "scripts": {
@@ -115,7 +115,7 @@ const remindersTools = [
115
115
  dueDate: z.string().optional().describe('New due date in ISO format'),
116
116
  priority: z.number().int().min(0).max(9).optional().describe('New priority level')
117
117
  },
118
- annotations: {"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},
118
+ annotations: {"readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false},
119
119
  handler: async ({ reminderId, ...updates }) => {
120
120
  const modeError = requireLocalMode('update-reminder');
121
121
  if (modeError) return modeError;
@@ -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
  };