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.
package/index.js ADDED
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * iCloud MCP Server v2.1.1
5
+ *
6
+ * Provides Claude with access to Apple services:
7
+ * - Email (via IMAP/SMTP or Mail.app)
8
+ * - Calendar (via CalDAV or Calendar.app)
9
+ * - Contacts (via CardDAV or Contacts.app)
10
+ * - Reminders (via Reminders.app - local only)
11
+ * - Notes (via Notes.app - local only)
12
+ * - Messages (via Messages.app - local only)
13
+ * - Safari (via Safari.app - local only)
14
+ *
15
+ * Modes (switchable at runtime via set-mode tool):
16
+ * - LOCAL: Uses AppleScript to access native macOS apps (fast, requires Mac)
17
+ * - CLOUD: Uses iCloud protocols (IMAP, CalDAV, CardDAV) - works from anywhere
18
+ */
19
+
20
+ const { McpServer } = require('@modelcontextprotocol/server');
21
+ const { serveStdio } = require('@modelcontextprotocol/server/stdio');
22
+
23
+ const config = require('./config');
24
+ const { getMode, onModeChange } = require('./mode');
25
+
26
+ // Import all modules - tools are always available, handlers check mode
27
+ const { authTools } = require('./auth');
28
+ const { emailTools } = require('./email');
29
+ const { calendarTools } = require('./calendar');
30
+ const { contactsTools } = require('./contacts');
31
+ const { remindersTools } = require('./reminders');
32
+ const { notesTools } = require('./notes');
33
+ const { messagesTools } = require('./messages');
34
+ const { safariTools } = require('./safari');
35
+
36
+ // All tools always available - handlers check mode and error if unsupported
37
+ const TOOLS = [
38
+ ...authTools,
39
+ ...emailTools,
40
+ ...calendarTools,
41
+ ...contactsTools,
42
+ ...remindersTools,
43
+ ...notesTools,
44
+ ...messagesTools,
45
+ ...safariTools
46
+ ];
47
+
48
+ /**
49
+ * Build a server instance with every tool registered.
50
+ *
51
+ * serveStdio takes a factory rather than an instance: the opening exchange
52
+ * decides the protocol era and pins one instance for the connection, so the
53
+ * same registrations have to be reproducible on demand.
54
+ */
55
+ function buildServer() {
56
+ const server = new McpServer(
57
+ {
58
+ name: 'icloud-mcp',
59
+ version: '2.1.1',
60
+ title: 'iCloud MCP'
61
+ },
62
+ {
63
+ capabilities: { tools: { listChanged: true } },
64
+ instructions: `Access to Apple services in two modes.
65
+
66
+ LOCAL (default, macOS only) drives the native apps via AppleScript.
67
+ CLOUD uses the iCloud protocols and needs an app-specific password.
68
+
69
+ Reminders, Notes, Messages and Safari are LOCAL only: calling them in CLOUD
70
+ mode returns an error. Use set-mode to switch without restarting.`
71
+ }
72
+ );
73
+
74
+ for (const tool of TOOLS) {
75
+ server.registerTool(
76
+ tool.name,
77
+ {
78
+ title: tool.title,
79
+ description: tool.description,
80
+ inputSchema: tool.inputSchema,
81
+ ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
82
+ ...(tool.annotations ? { annotations: tool.annotations } : {})
83
+ },
84
+ tool.handler
85
+ );
86
+ }
87
+
88
+ // set-mode changes which tools actually work, so tell the client to re-read
89
+ // the list. Failures here are cosmetic - never take the connection down.
90
+ onModeChange(() => {
91
+ Promise.resolve(server.sendToolListChanged()).catch(() => {});
92
+ });
93
+
94
+ return server;
95
+ }
96
+
97
+ console.error('[icloud-mcp] Starting iCloud MCP server v2.1.1...');
98
+ console.error(`[icloud-mcp] Initial mode: ${getMode().toUpperCase()}`);
99
+ console.error(`[icloud-mcp] Tools available: ${TOOLS.length}`);
100
+
101
+ if (getMode() === 'local') {
102
+ console.error('[icloud-mcp] Services: Email, Calendar, Contacts, Reminders, Notes, Messages, Safari');
103
+ } else {
104
+ console.error('[icloud-mcp] Services: Email, Calendar, Contacts');
105
+ console.error(`[icloud-mcp] Credentials configured: ${!!(config.ICLOUD_EMAIL && config.ICLOUD_APP_PASSWORD)}`);
106
+ }
107
+
108
+ if (config.USE_TEST_MODE) {
109
+ console.error('[icloud-mcp] TEST MODE ENABLED');
110
+ }
111
+
112
+ // serveStdio returns a handle (not a promise); it owns the transport lifetime.
113
+ const handle = serveStdio(buildServer);
114
+
115
+ function shutdown(signal) {
116
+ console.error(`[icloud-mcp] Received ${signal}, shutting down`);
117
+ Promise.resolve(handle?.close?.())
118
+ .catch(() => {})
119
+ .finally(() => process.exit(0));
120
+ }
121
+
122
+ process.on('SIGINT', () => shutdown('SIGINT'));
123
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Messages Module
3
+ * MCP tool definitions for Messages.app
4
+ * Send via AppleScript, read via imsg CLI
5
+ */
6
+
7
+ const { z } = require('zod');
8
+ const localClient = require('./local-client');
9
+ const { formatError, formatSuccess, withErrorHandler } = require('../utils/error-handler');
10
+ const { listOutput, listResult } = require('../utils/schemas');
11
+ const { isLocalMode } = require('../mode');
12
+ const { execFileSync } = require('child_process');
13
+ const fs = require('fs');
14
+
15
+ // Homebrew's prefix differs between Apple Silicon and Intel, and an MCP server
16
+ // launched by a GUI client often has a minimal PATH, so probe both.
17
+ const IMSG_CANDIDATES = [
18
+ process.env.ICLOUD_MCP_IMSG_PATH,
19
+ process.env.IMSG_PATH,
20
+ '/opt/homebrew/bin/imsg',
21
+ '/usr/local/bin/imsg'
22
+ ].filter(Boolean);
23
+
24
+ function resolveImsg() {
25
+ for (const candidate of IMSG_CANDIDATES) {
26
+ if (fs.existsSync(candidate)) return candidate;
27
+ }
28
+ // Last resort: let the OS search PATH.
29
+ return 'imsg';
30
+ }
31
+
32
+ function requireLocalMode(toolName) {
33
+ if (!isLocalMode()) {
34
+ return formatError(new Error(`${toolName} is only available in LOCAL mode. Use set-mode to switch.`));
35
+ }
36
+ return null;
37
+ }
38
+
39
+ function runImsg(args) {
40
+ let output;
41
+ try {
42
+ output = execFileSync(resolveImsg(), args, {
43
+ timeout: 15000,
44
+ encoding: 'utf-8',
45
+ stdio: ['pipe', 'pipe', 'pipe']
46
+ });
47
+ } catch (error) {
48
+ const detail = (error.stderr || error.message || '').toString();
49
+ if (detail.includes('permissionDenied')) {
50
+ throw new Error('Full Disk Access required. Grant it to the terminal in System Settings > Privacy & Security > Full Disk Access.');
51
+ }
52
+ if (error.code === 'ENOENT') {
53
+ throw new Error('The imsg CLI was not found. Install it (brew install imsg) or set ICLOUD_MCP_IMSG_PATH.');
54
+ }
55
+ throw new Error(detail.trim() || 'imsg failed');
56
+ }
57
+
58
+ try {
59
+ return JSON.parse(output);
60
+ } catch {
61
+ // imsg outputs JSONL (one JSON object per line)
62
+ const lines = output.trim().split('\n').filter(Boolean);
63
+ const parsed = lines.map(l => { try { return JSON.parse(l); } catch { return { raw: l }; } });
64
+ return parsed.length === 1 ? parsed[0] : parsed;
65
+ }
66
+ }
67
+
68
+ const messagesTools = [
69
+ {
70
+ name: 'list-chats',
71
+ outputSchema: listOutput('Recent conversations'),
72
+ title: 'List Chats',
73
+ description: 'List recent iMessage/SMS conversations with contact names and last message preview',
74
+ inputSchema: {
75
+ limit: z.number().int().min(1).max(200).optional().describe('Number of conversations to show (default 20)')
76
+ },
77
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":false},
78
+ handler: withErrorHandler(async ({ limit = 20 }) => {
79
+ const modeError = requireLocalMode('list-chats');
80
+ if (modeError) return modeError;
81
+ const result = runImsg(['chats', '--limit', String(limit), '--json']);
82
+ // imsg emits JSONL, and runImsg unwraps a single line to a bare object.
83
+ const chats = Array.isArray(result) ? result : (result ? [result] : []);
84
+ return formatSuccess(JSON.stringify(chats, null, 2), listResult(chats));
85
+ }, 'list-chats')
86
+ },
87
+ {
88
+ name: 'read-messages',
89
+ title: 'Read Messages',
90
+ description: 'Read message history for a specific iMessage/SMS conversation',
91
+ inputSchema: {
92
+ chatId: z.number().int().describe('Chat ID (rowid from list-chats)'),
93
+ limit: z.number().int().min(1).max(500).optional().describe('Number of messages (default 20)'),
94
+ start: z.string().optional().describe('Start date (ISO 8601, optional)'),
95
+ end: z.string().optional().describe('End date (ISO 8601, optional)'),
96
+ attachments: z.boolean().optional().describe('Include attachment info (default false)')
97
+ },
98
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":false},
99
+ handler: withErrorHandler(async ({ chatId, limit = 20, start, end, attachments }) => {
100
+ const modeError = requireLocalMode('read-messages');
101
+ if (modeError) return modeError;
102
+ const args = ['history', '--chat-id', String(chatId), '--limit', String(limit), '--json'];
103
+ if (start) args.push('--start', start);
104
+ if (end) args.push('--end', end);
105
+ if (attachments) args.push('--attachments');
106
+ const result = runImsg(args);
107
+ return formatSuccess(JSON.stringify(result, null, 2));
108
+ }, 'read-messages')
109
+ },
110
+ {
111
+ name: 'send-message',
112
+ title: 'Send Message',
113
+ description: 'Send an iMessage or SMS. IMPORTANT: Always confirm with user before sending.',
114
+ inputSchema: {
115
+ to: z.string().describe('Recipient phone number (with country code) or email'),
116
+ body: z.string().describe('Message content'),
117
+ file: z.string().optional().describe('Path to file attachment (optional)')
118
+ },
119
+ annotations: {"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},
120
+ handler: withErrorHandler(async ({ to, body, file }) => {
121
+ const modeError = requireLocalMode('send-message');
122
+ if (modeError) return modeError;
123
+ try {
124
+ const result = await localClient.sendMessage({ to, body });
125
+ return formatSuccess(JSON.stringify(result, null, 2));
126
+ } catch (error) {
127
+ // Fallback to imsg CLI
128
+ try {
129
+ const args = ['send', '--to', to, '--text', body];
130
+ if (file) args.push('--file', file);
131
+ const result = runImsg(args);
132
+ return formatSuccess(JSON.stringify(result, null, 2));
133
+ } catch (e) {
134
+ throw new Error(`${error.message} (imsg fallback also failed: ${e.message})`);
135
+ }
136
+ }
137
+ }, 'send-message')
138
+ },
139
+ {
140
+ name: 'react-message',
141
+ title: 'React To Message',
142
+ description: 'Send a tapback reaction (love, like, dislike, laugh, emphasis, question)',
143
+ inputSchema: {
144
+ chatId: z.number().int().describe('Chat ID'),
145
+ type: z.enum(['love', 'like', 'dislike', 'laugh', 'emphasis', 'question']).describe('Reaction type')
146
+ },
147
+ annotations: {"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true},
148
+ handler: withErrorHandler(async ({ chatId, type }) => {
149
+ const modeError = requireLocalMode('react-message');
150
+ if (modeError) return modeError;
151
+ const result = runImsg(['react', '--chat-id', String(chatId), '--type', type]);
152
+ return formatSuccess(JSON.stringify(result, null, 2));
153
+ }, 'react-message')
154
+ }
155
+ ];
156
+
157
+ module.exports = { messagesTools };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Local Messages Client
3
+ * Accesses Messages.app via AppleScript
4
+ * Note: Messages.app has limited AppleScript support - can only send, not read
5
+ */
6
+
7
+ const { runAppleScript, escapeAppleScript } = require('../utils/applescript');
8
+
9
+ /**
10
+ * Send an iMessage or SMS
11
+ * @param {Object} options - Message options
12
+ * @returns {Promise<Object>} - Send result
13
+ */
14
+ async function sendMessage({ to, body }) {
15
+ // Determine if it's a phone number or email
16
+ const isPhone = /^[\d\s\-\+\(\)]+$/.test(to.replace(/\s/g, ''));
17
+
18
+ const script = `
19
+ tell application "Messages"
20
+ set targetService to 1st account whose service type = iMessage
21
+ set targetBuddy to participant "${escapeAppleScript(to)}" of targetService
22
+ send "${escapeAppleScript(body)}" to targetBuddy
23
+ end tell
24
+ return "sent"
25
+ `;
26
+
27
+ try {
28
+ await runAppleScript(script);
29
+ return { success: true, message: `Message sent to ${to}` };
30
+ } catch (error) {
31
+ // Try alternative approach for phone numbers
32
+ if (isPhone) {
33
+ const altScript = `
34
+ tell application "Messages"
35
+ set targetService to 1st account whose service type = iMessage
36
+ set theBuddy to buddy "${escapeAppleScript(to)}" of targetService
37
+ send "${escapeAppleScript(body)}" to theBuddy
38
+ end tell
39
+ return "sent"
40
+ `;
41
+
42
+ try {
43
+ await runAppleScript(altScript);
44
+ return { success: true, message: `Message sent to ${to}` };
45
+ } catch (e) {
46
+ throw error;
47
+ }
48
+ }
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Send a message using Shortcuts Events (more reliable)
55
+ * @param {Object} options - Message options
56
+ * @returns {Promise<Object>} - Send result
57
+ */
58
+ async function sendMessageViaShortcuts({ to, body }) {
59
+ // This method uses "Shortcuts Events" which can be more reliable
60
+ const script = `
61
+ tell application "Messages"
62
+ send "${escapeAppleScript(body)}" to buddy "${escapeAppleScript(to)}" of (1st account whose service type = iMessage)
63
+ end tell
64
+ return "sent"
65
+ `;
66
+
67
+ await runAppleScript(script);
68
+ return { success: true, message: `Message sent to ${to}` };
69
+ }
70
+
71
+ module.exports = {
72
+ sendMessage,
73
+ sendMessageViaShortcuts
74
+ };
package/mode.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Mode management module
3
+ *
4
+ * Manages the current operating mode (local vs cloud)
5
+ * Separate module to avoid circular dependencies
6
+ */
7
+
8
+ const config = require('./config');
9
+
10
+ // Initial mode based on config
11
+ let currentMode = (config.USE_LOCAL_MODE && config.IS_MACOS) ? 'local' : 'cloud';
12
+
13
+ // Listeners notified after a successful mode change. The server uses this to
14
+ // emit notifications/tools/list_changed, since the mode decides which tools work.
15
+ const listeners = new Set();
16
+
17
+ /**
18
+ * Subscribe to mode changes.
19
+ * @param {() => void} fn
20
+ * @returns {() => void} - Unsubscribe
21
+ */
22
+ function onModeChange(fn) {
23
+ listeners.add(fn);
24
+ return () => listeners.delete(fn);
25
+ }
26
+
27
+ /**
28
+ * Get current mode
29
+ * @returns {'local' | 'cloud'}
30
+ */
31
+ function getMode() {
32
+ return currentMode;
33
+ }
34
+
35
+ /**
36
+ * Set current mode
37
+ * @param {'local' | 'cloud'} mode
38
+ */
39
+ function setMode(mode) {
40
+ if (mode !== 'local' && mode !== 'cloud') {
41
+ throw new Error('Mode must be "local" or "cloud"');
42
+ }
43
+
44
+ if (mode === 'local' && !config.IS_MACOS) {
45
+ throw new Error('Local mode only available on macOS');
46
+ }
47
+
48
+ currentMode = mode;
49
+ console.error(`[icloud-mcp] Mode changed to: ${mode}`);
50
+
51
+ for (const fn of listeners) {
52
+ try {
53
+ fn(mode);
54
+ } catch (error) {
55
+ console.error('[icloud-mcp] Mode listener failed:', error.message);
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Check if currently in local mode
62
+ * @returns {boolean}
63
+ */
64
+ function isLocalMode() {
65
+ return currentMode === 'local';
66
+ }
67
+
68
+ /**
69
+ * Check if currently in cloud mode
70
+ * @returns {boolean}
71
+ */
72
+ function isCloudMode() {
73
+ return currentMode === 'cloud';
74
+ }
75
+
76
+ module.exports = {
77
+ getMode,
78
+ setMode,
79
+ isLocalMode,
80
+ isCloudMode,
81
+ onModeChange
82
+ };
package/notes/index.js ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Notes Module
3
+ * MCP tool definitions for Notes.app
4
+ * NOTE: Only available in LOCAL mode (AppleScript)
5
+ */
6
+
7
+ const { z } = require('zod');
8
+ const localClient = require('./local-client');
9
+ const { formatError } = require('../utils/error-handler');
10
+ const { listOutput, listResult } = require('../utils/schemas');
11
+ const { isLocalMode } = require('../mode');
12
+
13
+ function requireLocalMode(toolName) {
14
+ if (!isLocalMode()) {
15
+ return formatError(new Error(`${toolName} is only available in LOCAL mode. Use set-mode to switch.`));
16
+ }
17
+ return null;
18
+ }
19
+
20
+ const notesTools = [
21
+ {
22
+ name: 'list-note-folders',
23
+ outputSchema: listOutput('list-note-folders'),
24
+ title: 'List Note Folders',
25
+ description: 'Lists all folders in Notes.app (LOCAL mode only)',
26
+ inputSchema: {},
27
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":false},
28
+ handler: async () => {
29
+ const modeError = requireLocalMode('list-note-folders');
30
+ if (modeError) return modeError;
31
+
32
+ try {
33
+ const folders = await localClient.listNoteFolders();
34
+ return {
35
+ content: [{
36
+ type: 'text',
37
+ text: JSON.stringify(folders, null, 2)
38
+ }],
39
+ structuredContent: listResult(folders)
40
+ };
41
+ } catch (error) {
42
+ return formatError(error, 'list-note-folders');
43
+ }
44
+ }
45
+ },
46
+ {
47
+ name: 'list-notes',
48
+ outputSchema: listOutput('list-notes'),
49
+ title: 'List Notes',
50
+ description: 'Lists notes from a specific folder or all folders',
51
+ inputSchema: {
52
+ folderName: z.string().optional().describe('Name of the folder (optional, lists all if not provided)'),
53
+ count: z.number().int().min(1).max(200).optional().describe('Maximum number of notes to return (default: 25)')
54
+ },
55
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":false},
56
+ handler: async ({ folderName, count = 25 }) => {
57
+ const modeError = requireLocalMode('list-notes');
58
+ if (modeError) return modeError;
59
+
60
+ try {
61
+ const notes = await localClient.listNotes(folderName, count);
62
+ return {
63
+ content: [{
64
+ type: 'text',
65
+ text: JSON.stringify(notes, null, 2)
66
+ }],
67
+ structuredContent: listResult(notes)
68
+ };
69
+ } catch (error) {
70
+ return formatError(error, 'list-notes');
71
+ }
72
+ }
73
+ },
74
+ {
75
+ name: 'read-note',
76
+ title: 'Read Note',
77
+ description: 'Reads the content of a specific note',
78
+ inputSchema: {
79
+ noteId: z.string().describe('ID of the note to read (required)')
80
+ },
81
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":false},
82
+ handler: async ({ noteId }) => {
83
+ const modeError = requireLocalMode('read-note');
84
+ if (modeError) return modeError;
85
+
86
+ try {
87
+ const note = await localClient.readNote(noteId);
88
+ if (!note) {
89
+ return {
90
+ content: [{
91
+ type: 'text',
92
+ text: JSON.stringify({ error: 'Note not found' })
93
+ }]
94
+ };
95
+ }
96
+ return {
97
+ content: [{
98
+ type: 'text',
99
+ text: JSON.stringify(note, null, 2)
100
+ }]
101
+ };
102
+ } catch (error) {
103
+ return formatError(error, 'read-note');
104
+ }
105
+ }
106
+ },
107
+ {
108
+ name: 'create-note',
109
+ title: 'Create Note',
110
+ description: 'Creates a new note',
111
+ inputSchema: {
112
+ title: z.string().describe('Note title (required)'),
113
+ body: z.string().optional().describe('Note content/body'),
114
+ folderName: z.string().optional().describe('Folder to create the note in (default: Notes)')
115
+ },
116
+ annotations: {"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false},
117
+ handler: async (args) => {
118
+ const modeError = requireLocalMode('create-note');
119
+ if (modeError) return modeError;
120
+
121
+ try {
122
+ const result = await localClient.createNote(args);
123
+ return {
124
+ content: [{
125
+ type: 'text',
126
+ text: JSON.stringify(result, null, 2)
127
+ }]
128
+ };
129
+ } catch (error) {
130
+ return formatError(error, 'create-note');
131
+ }
132
+ }
133
+ },
134
+ {
135
+ name: 'search-notes',
136
+ title: 'Search Notes',
137
+ description: 'Search notes by text in title or content',
138
+ inputSchema: {
139
+ query: z.string().describe('Search text (required)'),
140
+ count: z.number().int().min(1).max(200).optional().describe('Maximum results to return (default: 25)')
141
+ },
142
+ annotations: {"readOnlyHint":true,"idempotentHint":true,"openWorldHint":false},
143
+ handler: async ({ query, count = 25 }) => {
144
+ const modeError = requireLocalMode('search-notes');
145
+ if (modeError) return modeError;
146
+
147
+ try {
148
+ const results = await localClient.searchNotes(query, count);
149
+ return {
150
+ content: [{
151
+ type: 'text',
152
+ text: JSON.stringify(results, null, 2)
153
+ }]
154
+ };
155
+ } catch (error) {
156
+ return formatError(error, 'search-notes');
157
+ }
158
+ }
159
+ }
160
+ ];
161
+
162
+ module.exports = { notesTools };