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/DISCLOSURE +35 -0
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/auth/index.js +175 -0
- package/calendar/caldav-client.js +320 -0
- package/calendar/index.js +252 -0
- package/calendar/local-client.js +223 -0
- package/config.js +69 -0
- package/contacts/carddav-client.js +331 -0
- package/contacts/index.js +350 -0
- package/contacts/local-client.js +372 -0
- package/email/imap-client.js +351 -0
- package/email/index.js +278 -0
- package/email/local-client.js +257 -0
- package/email/smtp-client.js +79 -0
- package/index.js +123 -0
- package/messages/index.js +157 -0
- package/messages/local-client.js +74 -0
- package/mode.js +82 -0
- package/notes/index.js +162 -0
- package/notes/local-client.js +203 -0
- package/package.json +76 -0
- package/reminders/index.js +215 -0
- package/reminders/local-client.js +269 -0
- package/safari/index.js +122 -0
- package/safari/local-client.js +156 -0
- package/utils/applescript.js +214 -0
- package/utils/date-utils.js +72 -0
- package/utils/error-handler.js +71 -0
- package/utils/schemas.js +33 -0
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 };
|