icloud-mcp 1.0.0 → 1.0.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 +14 -14
- package/index.js +126 -11
- package/package.json +9 -3
- package/test.js +50 -1
package/README.md
CHANGED
|
@@ -27,14 +27,20 @@ A Model Context Protocol (MCP) server that connects Claude Desktop to your iClou
|
|
|
27
27
|
3. Click **+** to generate a new password
|
|
28
28
|
4. Label it something like `Claude MCP` and save the generated password
|
|
29
29
|
|
|
30
|
-
### 2. Install the
|
|
30
|
+
### 2. Install the package
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
|
|
34
|
-
cd icloud-mcp
|
|
35
|
-
npm install
|
|
33
|
+
npm install -g icloud-mcp
|
|
36
34
|
```
|
|
37
35
|
|
|
36
|
+
Then find the install location:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm root -g
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This will return a path like `/opt/homebrew/lib/node_modules` or `/usr/local/lib/node_modules`.
|
|
43
|
+
|
|
38
44
|
### 3. Configure Claude Desktop
|
|
39
45
|
|
|
40
46
|
Open your Claude Desktop config file:
|
|
@@ -43,14 +49,14 @@ Open your Claude Desktop config file:
|
|
|
43
49
|
open ~/Library/Application\ Support/Claude/claude_desktop_config.json
|
|
44
50
|
```
|
|
45
51
|
|
|
46
|
-
Add the following under `mcpServers
|
|
52
|
+
Add the following under `mcpServers`, replacing the path with your npm root path from the previous step:
|
|
47
53
|
|
|
48
54
|
```json
|
|
49
55
|
{
|
|
50
56
|
"mcpServers": {
|
|
51
57
|
"icloud-mail": {
|
|
52
|
-
"command": "
|
|
53
|
-
"args": ["/
|
|
58
|
+
"command": "node",
|
|
59
|
+
"args": ["/opt/homebrew/lib/node_modules/icloud-mcp/index.js"],
|
|
54
60
|
"env": {
|
|
55
61
|
"IMAP_USER": "you@icloud.com",
|
|
56
62
|
"IMAP_PASSWORD": "your-app-specific-password"
|
|
@@ -60,7 +66,7 @@ Add the following under `mcpServers`:
|
|
|
60
66
|
}
|
|
61
67
|
```
|
|
62
68
|
|
|
63
|
-
> **Note:**
|
|
69
|
+
> **Note:** If your `npm root -g` returned a different path, replace `/opt/homebrew/lib/node_modules` with that path.
|
|
64
70
|
|
|
65
71
|
### 4. Restart Claude Desktop
|
|
66
72
|
|
|
@@ -101,12 +107,6 @@ Once configured, you can ask Claude things like:
|
|
|
101
107
|
- *"Move all emails from newsletters@substack.com to my newsletters folder"*
|
|
102
108
|
- *"Show me emails from the last week"*
|
|
103
109
|
|
|
104
|
-
## Running Tests
|
|
105
|
-
|
|
106
|
-
```bash
|
|
107
|
-
IMAP_USER="you@icloud.com" IMAP_PASSWORD="your-app-specific-password" /opt/homebrew/bin/node test.js
|
|
108
|
-
```
|
|
109
|
-
|
|
110
110
|
## Security
|
|
111
111
|
|
|
112
112
|
- Your credentials are stored only in your local Claude Desktop config file
|
package/index.js
CHANGED
|
@@ -113,7 +113,7 @@ async function getTopSenders(mailbox = 'INBOX', sampleSize = 500) {
|
|
|
113
113
|
return { sampledEmails: count, topAddresses, topDomains };
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
async function getUnreadSenders(mailbox = 'INBOX', sampleSize = 500) {
|
|
116
|
+
async function getUnreadSenders(mailbox = 'INBOX', sampleSize = 500, maxResults = 20) {
|
|
117
117
|
const client = createClient();
|
|
118
118
|
await client.connect();
|
|
119
119
|
await client.mailboxOpen(mailbox);
|
|
@@ -121,16 +121,19 @@ async function getUnreadSenders(mailbox = 'INBOX', sampleSize = 500) {
|
|
|
121
121
|
const recentUids = uids.reverse().slice(0, sampleSize);
|
|
122
122
|
const senderCounts = {};
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
if (recentUids.length === 0) {
|
|
125
|
+
await client.logout();
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Fetch all envelopes in a single batch instead of one by one
|
|
130
|
+
for await (const msg of client.fetch(recentUids, { envelope: true }, { uid: true })) {
|
|
131
|
+
const address = msg.envelope.from?.[0]?.address;
|
|
132
|
+
if (address) senderCounts[address] = (senderCounts[address] || 0) + 1;
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
await client.logout();
|
|
133
|
-
return Object.entries(senderCounts).sort((a, b) => b[1] - a[1]).slice(0,
|
|
136
|
+
return Object.entries(senderCounts).sort((a, b) => b[1] - a[1]).slice(0, maxResults).map(([address, count]) => ({ address, count }));
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
async function getEmailsBySender(sender, mailbox = 'INBOX', limit = 10) {
|
|
@@ -377,12 +380,79 @@ async function moveEmail(uid, targetMailbox, sourceMailbox = 'INBOX') {
|
|
|
377
380
|
return true;
|
|
378
381
|
}
|
|
379
382
|
|
|
383
|
+
// Build an IMAP search query from a filters object
|
|
384
|
+
function buildQuery(filters) {
|
|
385
|
+
const query = {};
|
|
386
|
+
if (filters.sender) query.from = filters.sender;
|
|
387
|
+
if (filters.domain) query.from = filters.domain.replace(/^@/, '');
|
|
388
|
+
if (filters.subject) query.subject = filters.subject;
|
|
389
|
+
if (filters.before) query.before = new Date(filters.before);
|
|
390
|
+
if (filters.since) query.since = new Date(filters.since);
|
|
391
|
+
if (filters.unread === true) query.seen = false;
|
|
392
|
+
if (filters.unread === false) query.seen = true;
|
|
393
|
+
if (filters.flagged === true) query.flagged = true;
|
|
394
|
+
if (filters.flagged === false) query.unflagged = true;
|
|
395
|
+
if (filters.larger) query.larger = filters.larger * 1024;
|
|
396
|
+
if (filters.smaller) query.smaller = filters.smaller * 1024;
|
|
397
|
+
if (filters.hasAttachment) query.header = ['Content-Type', 'multipart/mixed'];
|
|
398
|
+
// If no filters set, match all
|
|
399
|
+
if (Object.keys(query).length === 0) query.all = true;
|
|
400
|
+
return query;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX') {
|
|
404
|
+
const client = createClient();
|
|
405
|
+
await client.connect();
|
|
406
|
+
await client.mailboxOpen(sourceMailbox);
|
|
407
|
+
const query = buildQuery(filters);
|
|
408
|
+
const uids = (await client.search(query, { uid: true })) ?? [];
|
|
409
|
+
if (uids.length === 0) { await client.logout(); return { moved: 0, sourceMailbox, targetMailbox }; }
|
|
410
|
+
await client.messageMove(uids, targetMailbox, { uid: true });
|
|
411
|
+
await client.logout();
|
|
412
|
+
return { moved: uids.length, sourceMailbox, targetMailbox, filters };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function bulkDelete(filters, sourceMailbox = 'INBOX') {
|
|
416
|
+
const client = createClient();
|
|
417
|
+
await client.connect();
|
|
418
|
+
await client.mailboxOpen(sourceMailbox);
|
|
419
|
+
const query = buildQuery(filters);
|
|
420
|
+
const uids = (await client.search(query, { uid: true })) ?? [];
|
|
421
|
+
if (uids.length === 0) { await client.logout(); return { deleted: 0, sourceMailbox }; }
|
|
422
|
+
await client.messageDelete(uids, { uid: true });
|
|
423
|
+
await client.logout();
|
|
424
|
+
return { deleted: uids.length, sourceMailbox, filters };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function countEmails(filters, mailbox = 'INBOX') {
|
|
428
|
+
const client = createClient();
|
|
429
|
+
await client.connect();
|
|
430
|
+
await client.mailboxOpen(mailbox);
|
|
431
|
+
const query = buildQuery(filters);
|
|
432
|
+
const uids = (await client.search(query, { uid: true })) ?? [];
|
|
433
|
+
await client.logout();
|
|
434
|
+
return { count: uids.length, mailbox, filters };
|
|
435
|
+
}
|
|
436
|
+
|
|
380
437
|
async function main() {
|
|
381
438
|
const server = new Server(
|
|
382
|
-
{ name: 'icloud-mail', version: '1.0
|
|
439
|
+
{ name: 'icloud-mail', version: '1.1.0' },
|
|
383
440
|
{ capabilities: { tools: {} } }
|
|
384
441
|
);
|
|
385
442
|
|
|
443
|
+
const filtersSchema = {
|
|
444
|
+
sender: { type: 'string', description: 'Match exact sender email address' },
|
|
445
|
+
domain: { type: 'string', description: 'Match any sender from this domain (e.g. substack.com)' },
|
|
446
|
+
subject: { type: 'string', description: 'Keyword to match in subject' },
|
|
447
|
+
before: { type: 'string', description: 'Only emails before this date (YYYY-MM-DD)' },
|
|
448
|
+
since: { type: 'string', description: 'Only emails since this date (YYYY-MM-DD)' },
|
|
449
|
+
unread: { type: 'boolean', description: 'True for unread only, false for read only' },
|
|
450
|
+
flagged: { type: 'boolean', description: 'True for flagged only, false for unflagged only' },
|
|
451
|
+
larger: { type: 'number', description: 'Only emails larger than this size in KB' },
|
|
452
|
+
smaller: { type: 'number', description: 'Only emails smaller than this size in KB' },
|
|
453
|
+
hasAttachment: { type: 'boolean', description: 'Only emails with attachments' }
|
|
454
|
+
};
|
|
455
|
+
|
|
386
456
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
387
457
|
tools: [
|
|
388
458
|
{
|
|
@@ -408,7 +478,8 @@ async function main() {
|
|
|
408
478
|
type: 'object',
|
|
409
479
|
properties: {
|
|
410
480
|
mailbox: { type: 'string', description: 'Mailbox to analyze (default INBOX)' },
|
|
411
|
-
sampleSize: { type: 'number', description: 'Number of emails to sample (default 500)' }
|
|
481
|
+
sampleSize: { type: 'number', description: 'Number of emails to sample (default 500)' },
|
|
482
|
+
maxResults: { type: 'number', description: 'Max number of senders to return (default 20)' }
|
|
412
483
|
}
|
|
413
484
|
}
|
|
414
485
|
},
|
|
@@ -608,6 +679,41 @@ async function main() {
|
|
|
608
679
|
name: 'list_mailboxes',
|
|
609
680
|
description: 'List all mailboxes/folders in iCloud Mail',
|
|
610
681
|
inputSchema: { type: 'object', properties: {} }
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
name: 'bulk_move',
|
|
685
|
+
description: 'Move emails matching any combination of filters from one mailbox to another. Use this for large-scale moves — it operates on ALL matching emails in a single IMAP operation regardless of count. Prefer this over read_inbox + move_email for any bulk operation.',
|
|
686
|
+
inputSchema: {
|
|
687
|
+
type: 'object',
|
|
688
|
+
properties: {
|
|
689
|
+
targetMailbox: { type: 'string', description: 'Destination mailbox path' },
|
|
690
|
+
sourceMailbox: { type: 'string', description: 'Source mailbox (default INBOX)' },
|
|
691
|
+
...filtersSchema
|
|
692
|
+
},
|
|
693
|
+
required: ['targetMailbox']
|
|
694
|
+
}
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
name: 'bulk_delete',
|
|
698
|
+
description: 'Delete emails matching any combination of filters. Use this for large-scale deletes — it operates on ALL matching emails in a single IMAP operation regardless of count. Prefer this over read_inbox + delete_email for any bulk operation.',
|
|
699
|
+
inputSchema: {
|
|
700
|
+
type: 'object',
|
|
701
|
+
properties: {
|
|
702
|
+
sourceMailbox: { type: 'string', description: 'Mailbox to delete from (default INBOX)' },
|
|
703
|
+
...filtersSchema
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
name: 'count_emails',
|
|
709
|
+
description: 'Count how many emails match a set of filters without moving or deleting them. Use this before bulk_move or bulk_delete to preview how many emails will be affected.',
|
|
710
|
+
inputSchema: {
|
|
711
|
+
type: 'object',
|
|
712
|
+
properties: {
|
|
713
|
+
mailbox: { type: 'string', description: 'Mailbox to count in (default INBOX)' },
|
|
714
|
+
...filtersSchema
|
|
715
|
+
}
|
|
716
|
+
}
|
|
611
717
|
}
|
|
612
718
|
]
|
|
613
719
|
}));
|
|
@@ -621,7 +727,7 @@ async function main() {
|
|
|
621
727
|
} else if (name === 'get_top_senders') {
|
|
622
728
|
result = await getTopSenders(args.mailbox || 'INBOX', args.sampleSize || 500);
|
|
623
729
|
} else if (name === 'get_unread_senders') {
|
|
624
|
-
result = await getUnreadSenders(args.mailbox || 'INBOX', args.sampleSize || 500);
|
|
730
|
+
result = await getUnreadSenders(args.mailbox || 'INBOX', args.sampleSize || 500, args.maxResults || 20);
|
|
625
731
|
} else if (name === 'get_emails_by_sender') {
|
|
626
732
|
result = await getEmailsBySender(args.sender, args.mailbox || 'INBOX', args.limit || 10);
|
|
627
733
|
} else if (name === 'read_inbox') {
|
|
@@ -656,6 +762,15 @@ async function main() {
|
|
|
656
762
|
result = await moveEmail(args.uid, args.targetMailbox, args.sourceMailbox || 'INBOX');
|
|
657
763
|
} else if (name === 'list_mailboxes') {
|
|
658
764
|
result = await listMailboxes();
|
|
765
|
+
} else if (name === 'bulk_move') {
|
|
766
|
+
const { targetMailbox, sourceMailbox, ...filters } = args;
|
|
767
|
+
result = await bulkMove(filters, targetMailbox, sourceMailbox || 'INBOX');
|
|
768
|
+
} else if (name === 'bulk_delete') {
|
|
769
|
+
const { sourceMailbox, ...filters } = args;
|
|
770
|
+
result = await bulkDelete(filters, sourceMailbox || 'INBOX');
|
|
771
|
+
} else if (name === 'count_emails') {
|
|
772
|
+
const { mailbox, ...filters } = args;
|
|
773
|
+
result = await countEmails(filters, mailbox || 'INBOX');
|
|
659
774
|
} else {
|
|
660
775
|
throw new Error(`Unknown tool: ${name}`);
|
|
661
776
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "icloud-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "A Model Context Protocol (MCP) server for iCloud Mail",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -11,11 +11,17 @@
|
|
|
11
11
|
"start": "node index.js",
|
|
12
12
|
"test": "node test.js"
|
|
13
13
|
},
|
|
14
|
-
"keywords": [
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mcp",
|
|
16
|
+
"icloud",
|
|
17
|
+
"email",
|
|
18
|
+
"imap",
|
|
19
|
+
"claude"
|
|
20
|
+
],
|
|
15
21
|
"author": "Adam Zaidi",
|
|
16
22
|
"license": "MIT",
|
|
17
23
|
"dependencies": {
|
|
18
24
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
19
25
|
"imapflow": "^1.2.10"
|
|
20
26
|
}
|
|
21
|
-
}
|
|
27
|
+
}
|
package/test.js
CHANGED
|
@@ -100,12 +100,27 @@ test('get_top_senders (sample 50)', () => {
|
|
|
100
100
|
console.log(`\n → top sender: ${result.topAddresses[0]?.address} (${result.topAddresses[0]?.count})`);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
test('get_unread_senders (sample 50)', () => {
|
|
103
|
+
test('get_unread_senders (sample 50, default maxResults)', () => {
|
|
104
104
|
const result = callTool('get_unread_senders', { sampleSize: 50 });
|
|
105
105
|
assert(Array.isArray(result), 'result should be an array');
|
|
106
|
+
assert(result.length <= 20, 'should not exceed default maxResults of 20');
|
|
106
107
|
console.log(`\n → ${result.length} unread senders found`);
|
|
107
108
|
});
|
|
108
109
|
|
|
110
|
+
test('get_unread_senders (sample 50, maxResults 5)', () => {
|
|
111
|
+
const result = callTool('get_unread_senders', { sampleSize: 50, maxResults: 5 });
|
|
112
|
+
assert(Array.isArray(result), 'result should be an array');
|
|
113
|
+
assert(result.length <= 5, 'should not exceed maxResults of 5');
|
|
114
|
+
console.log(`\n → ${result.length} unread senders found (capped at 5)`);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('get_unread_senders (sample 50, maxResults 50)', () => {
|
|
118
|
+
const result = callTool('get_unread_senders', { sampleSize: 50, maxResults: 50 });
|
|
119
|
+
assert(Array.isArray(result), 'result should be an array');
|
|
120
|
+
assert(result.length <= 50, 'should not exceed maxResults of 50');
|
|
121
|
+
console.log(`\n → ${result.length} unread senders found (capped at 50)`);
|
|
122
|
+
});
|
|
123
|
+
|
|
109
124
|
console.log('\n📧 Reading Emails');
|
|
110
125
|
|
|
111
126
|
test('read_inbox (page 1, limit 5)', () => {
|
|
@@ -171,6 +186,37 @@ test('get_email (fetch first email content)', () => {
|
|
|
171
186
|
console.log(`\n → fetched email: "${result.subject?.slice(0, 40)}..."`);
|
|
172
187
|
});
|
|
173
188
|
|
|
189
|
+
console.log('\n🔍 Count & Bulk Query');
|
|
190
|
+
|
|
191
|
+
test('count_emails (all in INBOX)', () => {
|
|
192
|
+
const result = callTool('count_emails', { mailbox: 'INBOX' });
|
|
193
|
+
assert(typeof result.count === 'number', 'count should be a number');
|
|
194
|
+
assert(result.count > 0, 'should have emails in INBOX');
|
|
195
|
+
console.log(`\n → ${result.count} emails match`);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('count_emails (unread only)', () => {
|
|
199
|
+
const result = callTool('count_emails', { unread: true });
|
|
200
|
+
assert(typeof result.count === 'number', 'count should be a number');
|
|
201
|
+
console.log(`\n → ${result.count} unread emails`);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('count_emails (by domain)', () => {
|
|
205
|
+
const senders = callTool('get_top_senders', { sampleSize: 20 });
|
|
206
|
+
const topDomain = senders.topDomains[0]?.domain;
|
|
207
|
+
assert(topDomain, 'should have at least one domain');
|
|
208
|
+
const result = callTool('count_emails', { domain: topDomain });
|
|
209
|
+
console.log('\n → raw result:', JSON.stringify(result));
|
|
210
|
+
assert(typeof result.count === 'number', 'count should be a number');
|
|
211
|
+
console.log(`\n → ${result.count} emails from @${topDomain}`);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('count_emails (before date)', () => {
|
|
215
|
+
const result = callTool('count_emails', { before: '2020-01-01' });
|
|
216
|
+
assert(typeof result.count === 'number', 'count should be a number');
|
|
217
|
+
console.log(`\n → ${result.count} emails before 2020`);
|
|
218
|
+
});
|
|
219
|
+
|
|
174
220
|
console.log('\n✏️ Write Operations (flag/mark only — no deletions)');
|
|
175
221
|
|
|
176
222
|
test('flag_email and unflag_email', () => {
|
|
@@ -196,6 +242,9 @@ test('mark_as_read and mark_as_unread', () => {
|
|
|
196
242
|
});
|
|
197
243
|
|
|
198
244
|
console.log('\n⚠️ Destructive Tests (skipped by default)');
|
|
245
|
+
console.log(' Skipping: bulk_move (sender → target folder)');
|
|
246
|
+
console.log(' Skipping: bulk_move (folder → folder)');
|
|
247
|
+
console.log(' Skipping: bulk_delete (by domain)');
|
|
199
248
|
console.log(' Skipping: bulk_delete_by_sender');
|
|
200
249
|
console.log(' Skipping: bulk_delete_by_subject');
|
|
201
250
|
console.log(' Skipping: delete_older_than');
|