icloud-mcp 1.2.2 → 1.4.0

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.
Files changed (4) hide show
  1. package/README.md +23 -2
  2. package/index.js +536 -63
  3. package/package.json +1 -1
  4. package/test.js +188 -26
package/README.md CHANGED
@@ -13,6 +13,7 @@ A Model Context Protocol (MCP) server that connects Claude Desktop to your iClou
13
13
  - āœ… Mark emails as read/unread, flag/unflag in bulk or individually
14
14
  - šŸ—‚ļø List, create, rename, and delete mailboxes
15
15
  - šŸ”„ Dry run mode for bulk operations — preview before committing
16
+ - šŸ“ Session logging — Claude tracks progress across long multi-step operations
16
17
 
17
18
  ## Prerequisites
18
19
 
@@ -70,7 +71,20 @@ Add the following under `mcpServers`, replacing the path with your npm root path
70
71
 
71
72
  > **Note:** If your `npm root -g` returned a different path, replace `/opt/homebrew/lib/node_modules` with that path.
72
73
 
73
- ### 4. Restart Claude Desktop
74
+ ### 4. Add Custom Instructions (Recommended)
75
+
76
+ For large inbox operations, add the following to Claude Desktop's custom instructions to ensure Claude stays on track and checks in with you regularly. Go to **Claude Desktop → Settings → Custom Instructions** and add:
77
+
78
+ ```
79
+ When using icloud-mail tools:
80
+ 1. Before starting any multi-step operation, call log_clear then log_write with your full plan
81
+ 2. After every single tool call, call log_write with what you did and the result
82
+ 3. After every 3 tool calls, stop and summarize progress to the user and wait for confirmation before continuing
83
+ 4. Never assume a bulk operation succeeded — always verify with count_emails after
84
+ 5. If you are ever unsure what you have done so far, call log_read before proceeding
85
+ ```
86
+
87
+ ### 5. Restart Claude Desktop
74
88
 
75
89
  Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manage your iCloud inbox through Claude.
76
90
 
@@ -106,6 +120,9 @@ Fully quit Claude Desktop (Cmd+Q) and reopen it. You should now be able to manag
106
120
  | `rename_mailbox` | Rename an existing folder |
107
121
  | `delete_mailbox` | Delete a folder (must be empty first) |
108
122
  | `empty_trash` | Permanently delete all emails in Deleted Messages |
123
+ | `log_write` | Write a step to the session log |
124
+ | `log_read` | Read the session log to see what has been done so far |
125
+ | `log_clear` | Clear the session log and start fresh |
109
126
 
110
127
  ## Bulk Move, Delete & Flag Filters
111
128
 
@@ -130,6 +147,10 @@ Pass `dryRun: true` to `bulk_move` or `bulk_delete` to preview how many emails w
130
147
 
131
148
  > *"How many emails would be deleted if I removed everything from linkedin.com before 2022?"*
132
149
 
150
+ ### Session Log
151
+
152
+ The session log persists to `~/.icloud-mcp-session.json` on your Mac — outside Claude's context window — so progress is never lost during long operations. Claude can write its plan at the start, log each completed step, and read the log back at any point to reorient itself.
153
+
133
154
  ## Example Usage
134
155
 
135
156
  Once configured, you can ask Claude things like:
@@ -153,4 +174,4 @@ Once configured, you can ask Claude things like:
153
174
 
154
175
  ## License
155
176
 
156
- MIT
177
+ MIT
package/index.js CHANGED
@@ -3,6 +3,13 @@ import { ImapFlow } from 'imapflow';
3
3
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
5
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
6
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
7
+ import { homedir } from 'os';
8
+ import { join } from 'path';
9
+
10
+ const LOG_FILE = join(homedir(), '.icloud-mcp-session.json');
11
+ const MANIFEST_FILE = join(homedir(), '.icloud-mcp-move-manifest.json');
12
+ const MAX_HISTORY = 5;
6
13
 
7
14
  const IMAP_USER = process.env.IMAP_USER;
8
15
  const IMAP_PASSWORD = process.env.IMAP_PASSWORD;
@@ -22,6 +29,385 @@ function createClient() {
22
29
  });
23
30
  }
24
31
 
32
+ // ─── Move Manifest ────────────────────────────────────────────────────────────
33
+
34
+ const CHUNK_SIZE = 250;
35
+ const CHUNK_SIZE_RETRY = 100;
36
+
37
+ function readManifest() {
38
+ if (!existsSync(MANIFEST_FILE)) return { current: null, history: [] };
39
+ try {
40
+ return JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'));
41
+ } catch {
42
+ return { current: null, history: [] };
43
+ }
44
+ }
45
+
46
+ function writeManifest(data) {
47
+ writeFileSync(MANIFEST_FILE, JSON.stringify(data, null, 2));
48
+ }
49
+
50
+ function updateManifest(updater) {
51
+ const data = readManifest();
52
+ updater(data);
53
+ data.current.updatedAt = new Date().toISOString();
54
+ writeManifest(data);
55
+ return data;
56
+ }
57
+
58
+ function archiveCurrent(data) {
59
+ if (data.current) {
60
+ data.history.unshift(data.current);
61
+ if (data.history.length > MAX_HISTORY) data.history = data.history.slice(0, MAX_HISTORY);
62
+ data.current = null;
63
+ }
64
+ }
65
+
66
+ function getMoveStatus() {
67
+ const data = readManifest();
68
+ if (!data.current) return { status: 'no_operation', history: data.history.map(summarizeOp) };
69
+ return {
70
+ current: formatOperation(data.current),
71
+ history: data.history.map(summarizeOp)
72
+ };
73
+ }
74
+
75
+ function abandonMove() {
76
+ const data = readManifest();
77
+ if (!data.current) return { abandoned: false, message: 'No in-progress operation to abandon' };
78
+ if (data.current.status !== 'in_progress') {
79
+ return { abandoned: false, message: `Current operation is already '${data.current.status}', nothing to abandon` };
80
+ }
81
+ const operationId = data.current.operationId;
82
+ data.current.status = 'abandoned';
83
+ data.current.updatedAt = new Date().toISOString();
84
+ archiveCurrent(data);
85
+ writeManifest(data);
86
+ return { abandoned: true, operationId };
87
+ }
88
+
89
+ function startOperation(source, target, uids) {
90
+ const data = readManifest();
91
+
92
+ if (data.current && data.current.status === 'in_progress') {
93
+ const op = data.current;
94
+ throw new Error(
95
+ `Incomplete move operation detected (${op.operationId}): ` +
96
+ `${op.summary.emailsMoved} of ${op.totalUids} emails moved from '${op.source}' to '${op.target}' ` +
97
+ `started at ${op.startedAt}. ` +
98
+ `Call abandon_move to discard it or get_move_status to inspect it before starting a new operation.`
99
+ );
100
+ }
101
+
102
+ archiveCurrent(data);
103
+
104
+ const operationId = `move_${Date.now()}`;
105
+ const chunks = [];
106
+
107
+ for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
108
+ chunks.push({
109
+ index: chunks.length,
110
+ uids: uids.slice(i, i + CHUNK_SIZE),
111
+ fingerprints: [],
112
+ status: 'pending',
113
+ copiedAt: null,
114
+ verifiedAt: null,
115
+ deletedAt: null,
116
+ failureReason: null
117
+ });
118
+ }
119
+
120
+ data.current = {
121
+ operationId,
122
+ startedAt: new Date().toISOString(),
123
+ updatedAt: new Date().toISOString(),
124
+ source,
125
+ target,
126
+ totalUids: uids.length,
127
+ status: 'in_progress',
128
+ chunks,
129
+ summary: {
130
+ chunksComplete: 0,
131
+ emailsMoved: 0,
132
+ emailsPending: uids.length,
133
+ emailsFailed: 0
134
+ }
135
+ };
136
+
137
+ writeManifest(data);
138
+ return data.current;
139
+ }
140
+
141
+ function updateChunk(index, updates) {
142
+ updateManifest((data) => {
143
+ const chunk = data.current.chunks[index];
144
+ Object.assign(chunk, updates);
145
+
146
+ let moved = 0, failed = 0, pending = 0;
147
+ for (const c of data.current.chunks) {
148
+ if (c.status === 'complete') moved += c.uids.length;
149
+ else if (c.status === 'failed') failed += c.uids.length;
150
+ else pending += c.uids.length;
151
+ }
152
+ data.current.summary = {
153
+ chunksComplete: data.current.chunks.filter(c => c.status === 'complete').length,
154
+ emailsMoved: moved,
155
+ emailsPending: pending,
156
+ emailsFailed: failed
157
+ };
158
+ });
159
+ }
160
+
161
+ function completeOperation() {
162
+ const data = readManifest();
163
+ if (!data.current) return;
164
+ data.current.status = 'complete';
165
+ data.current.updatedAt = new Date().toISOString();
166
+ archiveCurrent(data);
167
+ writeManifest(data);
168
+ }
169
+
170
+ function failOperation(reason) {
171
+ const data = readManifest();
172
+ if (!data.current) return;
173
+ data.current.status = 'failed';
174
+ data.current.failureReason = reason;
175
+ data.current.updatedAt = new Date().toISOString();
176
+ archiveCurrent(data);
177
+ writeManifest(data);
178
+ }
179
+
180
+ function formatOperation(op) {
181
+ return {
182
+ operationId: op.operationId,
183
+ status: op.status,
184
+ source: op.source,
185
+ target: op.target,
186
+ startedAt: op.startedAt,
187
+ updatedAt: op.updatedAt,
188
+ summary: op.summary,
189
+ failedChunks: op.chunks.filter(c => c.status === 'failed').map(c => ({
190
+ index: c.index,
191
+ uids: c.uids.length,
192
+ reason: c.failureReason
193
+ }))
194
+ };
195
+ }
196
+
197
+ function summarizeOp(op) {
198
+ return {
199
+ operationId: op.operationId,
200
+ status: op.status,
201
+ source: op.source,
202
+ target: op.target,
203
+ startedAt: op.startedAt,
204
+ moved: op.summary.emailsMoved,
205
+ failed: op.summary.emailsFailed,
206
+ total: op.totalUids
207
+ };
208
+ }
209
+
210
+ // ─── Fingerprinting ───────────────────────────────────────────────────────────
211
+
212
+ function buildFingerprint(msg) {
213
+ const messageId = msg.envelope?.messageId ?? null;
214
+ const sender = msg.envelope?.from?.[0]?.address ?? '';
215
+ const date = msg.envelope?.date ? new Date(msg.envelope.date).toISOString() : '';
216
+ const subject = msg.envelope?.subject ?? '';
217
+ const fallback = [sender, date, subject].join('|');
218
+ return { uid: msg.uid, messageId, fallback };
219
+ }
220
+
221
+ function fingerprintToKey(fp) {
222
+ return fp.messageId ?? fp.fallback;
223
+ }
224
+
225
+ // ─── IMAP Move Helpers ────────────────────────────────────────────────────────
226
+
227
+ async function fetchEnvelopes(mailbox, uids) {
228
+ const client = createClient();
229
+ await client.connect();
230
+ await client.mailboxOpen(mailbox);
231
+ const envelopes = [];
232
+ for await (const msg of client.fetch(uids, { envelope: true }, { uid: true })) {
233
+ envelopes.push(msg);
234
+ }
235
+ await client.logout();
236
+ return envelopes;
237
+ }
238
+
239
+ async function copyChunk(sourceMailbox, targetMailbox, uids) {
240
+ const client = createClient();
241
+ await client.connect();
242
+ await client.mailboxOpen(sourceMailbox);
243
+ await client.messageCopy(uids, targetMailbox, { uid: true });
244
+ await client.logout();
245
+ }
246
+
247
+ async function deleteChunk(sourceMailbox, uids) {
248
+ const client = createClient();
249
+ await client.connect();
250
+ await client.mailboxOpen(sourceMailbox);
251
+ await client.messageDelete(uids, { uid: true });
252
+ await client.logout();
253
+ }
254
+
255
+ async function verifyFingerprintsInTarget(targetMailbox, expectedFingerprints) {
256
+ const client = createClient();
257
+ await client.connect();
258
+ const mb = await client.mailboxOpen(targetMailbox);
259
+ const total = mb.exists;
260
+
261
+ // Fetch recent envelopes from target — copies land at the end
262
+ const fetchCount = Math.min(total, expectedFingerprints.length * 2 + 500);
263
+ const start = Math.max(1, total - fetchCount + 1);
264
+ const range = `${start}:${total}`;
265
+
266
+ const targetKeys = new Set();
267
+ for await (const msg of client.fetch(range, { envelope: true })) {
268
+ const fp = buildFingerprint(msg);
269
+ targetKeys.add(fingerprintToKey(fp));
270
+ }
271
+ await client.logout();
272
+
273
+ const missing = [];
274
+ for (const fp of expectedFingerprints) {
275
+ const key = fingerprintToKey(fp);
276
+ if (!targetKeys.has(key)) missing.push(fp);
277
+ }
278
+
279
+ return {
280
+ verified: missing.length === 0,
281
+ missing,
282
+ found: expectedFingerprints.length - missing.length,
283
+ expected: expectedFingerprints.length
284
+ };
285
+ }
286
+
287
+ // ─── Transient error detection ────────────────────────────────────────────────
288
+
289
+ function isTransient(err) {
290
+ const msg = err.message ?? '';
291
+ return msg.includes('ECONNRESET') ||
292
+ msg.includes('ECONNREFUSED') ||
293
+ msg.includes('ETIMEDOUT') ||
294
+ msg.includes('EPIPE') ||
295
+ msg.includes('socket hang up') ||
296
+ msg.includes('Connection not available') ||
297
+ msg.includes('BAD') || // IMAP BAD response — often transient on Apple
298
+ msg.includes('NO '); // IMAP NO response — often transient on Apple
299
+ }
300
+
301
+ async function withRetry(label, fn, maxAttempts = 3) {
302
+ let lastErr;
303
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
304
+ try {
305
+ return await fn();
306
+ } catch (err) {
307
+ lastErr = err;
308
+ if (!isTransient(err) || attempt === maxAttempts) throw err;
309
+ const delay = attempt * 2000; // 2s, 4s backoff
310
+ process.stderr.write(`[retry] ${label} failed (attempt ${attempt}/${maxAttempts}): ${err.message} — retrying in ${delay}ms\n`);
311
+ await new Promise(r => setTimeout(r, delay));
312
+ }
313
+ }
314
+ throw lastErr;
315
+ }
316
+
317
+ // ─── Safe Move ────────────────────────────────────────────────────────────────
318
+
319
+ async function safeMoveEmails(uids, sourceMailbox, targetMailbox) {
320
+ const operation = startOperation(sourceMailbox, targetMailbox, uids);
321
+ let totalMoved = 0;
322
+ let totalFailed = 0;
323
+
324
+ for (const chunk of operation.chunks) {
325
+ const chunkUids = chunk.uids;
326
+ let succeeded = false;
327
+
328
+ // Try at full chunk size first, then smaller on verification failure
329
+ for (const attemptChunkSize of [CHUNK_SIZE, CHUNK_SIZE_RETRY]) {
330
+ const subChunks = [];
331
+ for (let i = 0; i < chunkUids.length; i += attemptChunkSize) {
332
+ subChunks.push(chunkUids.slice(i, i + attemptChunkSize));
333
+ }
334
+
335
+ let verificationFailed = false;
336
+
337
+ for (const subChunk of subChunks) {
338
+ // Step 1: fetch fingerprints from source and write to manifest
339
+ // before doing anything destructive — retries on transient errors
340
+ const envelopes = await withRetry(
341
+ `fetchEnvelopes chunk ${chunk.index}`,
342
+ () => fetchEnvelopes(sourceMailbox, subChunk)
343
+ );
344
+ const fingerprints = envelopes.map(buildFingerprint);
345
+
346
+ updateManifest((data) => {
347
+ const c = data.current.chunks[chunk.index];
348
+ c.fingerprints = [...c.fingerprints, ...fingerprints];
349
+ c.status = 'pending';
350
+ });
351
+
352
+ // Step 2: copy to target — retries on transient errors
353
+ await withRetry(
354
+ `copyChunk chunk ${chunk.index}`,
355
+ () => copyChunk(sourceMailbox, targetMailbox, subChunk)
356
+ );
357
+ updateChunk(chunk.index, { status: 'copied_not_verified', copiedAt: new Date().toISOString() });
358
+
359
+ // Step 3: verify by fetching envelopes from target — retries on transient errors
360
+ const verification = await withRetry(
361
+ `verifyFingerprints chunk ${chunk.index}`,
362
+ () => verifyFingerprintsInTarget(targetMailbox, fingerprints)
363
+ );
364
+ if (!verification.verified) {
365
+ verificationFailed = true;
366
+ break;
367
+ }
368
+ updateChunk(chunk.index, { status: 'verified_not_deleted', verifiedAt: new Date().toISOString() });
369
+
370
+ // Step 4: safe to delete from source — retries on transient errors
371
+ await withRetry(
372
+ `deleteChunk chunk ${chunk.index}`,
373
+ () => deleteChunk(sourceMailbox, subChunk)
374
+ );
375
+ totalMoved += subChunk.length;
376
+ }
377
+
378
+ if (!verificationFailed) {
379
+ succeeded = true;
380
+ break;
381
+ }
382
+
383
+ if (attemptChunkSize === CHUNK_SIZE_RETRY) {
384
+ // Verification failed at both chunk sizes — stop and report, source untouched from here
385
+ updateChunk(chunk.index, {
386
+ status: 'failed',
387
+ failureReason: 'Verification failed at both chunk sizes'
388
+ });
389
+ totalFailed += chunkUids.length;
390
+ failOperation(`Verification failed after retry on chunk ${chunk.index}`);
391
+ return {
392
+ status: 'partial',
393
+ moved: totalMoved,
394
+ failed: totalFailed,
395
+ message: `Verification failed after retry. ${totalMoved} emails moved successfully. ${operation.totalUids - totalMoved} remain in source untouched. Call get_move_status for details.`
396
+ };
397
+ }
398
+ }
399
+
400
+ if (succeeded) {
401
+ updateChunk(chunk.index, { status: 'complete', deletedAt: new Date().toISOString() });
402
+ }
403
+ }
404
+
405
+ completeOperation();
406
+ return { status: 'complete', moved: totalMoved, total: operation.totalUids };
407
+ }
408
+
409
+ // ─── Email Functions ──────────────────────────────────────────────────────────
410
+
25
411
  async function fetchEmails(mailbox = 'INBOX', limit = 10, onlyUnread = false, page = 1) {
26
412
  const client = createClient();
27
413
  await client.connect();
@@ -165,15 +551,18 @@ async function bulkDeleteBySender(sender, mailbox = 'INBOX') {
165
551
  await client.connect();
166
552
  await client.mailboxOpen(mailbox);
167
553
  const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
168
- if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
169
- const chunkSize = 250;
554
+ await client.logout();
555
+ if (uids.length === 0) return { deleted: 0 };
170
556
  let deleted = 0;
171
- for (let i = 0; i < uids.length; i += chunkSize) {
172
- const chunk = uids.slice(i, i + chunkSize);
173
- await client.messageDelete(chunk, { uid: true });
557
+ for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
558
+ const chunk = uids.slice(i, i + CHUNK_SIZE);
559
+ const dc = createClient();
560
+ await dc.connect();
561
+ await dc.mailboxOpen(mailbox);
562
+ await dc.messageDelete(chunk, { uid: true });
563
+ await dc.logout();
174
564
  deleted += chunk.length;
175
565
  }
176
- await client.logout();
177
566
  return { deleted, sender };
178
567
  }
179
568
 
@@ -182,16 +571,11 @@ async function bulkMoveBySender(sender, targetMailbox, sourceMailbox = 'INBOX')
182
571
  await client.connect();
183
572
  await client.mailboxOpen(sourceMailbox);
184
573
  const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
185
- if (uids.length === 0) { await client.logout(); return { moved: 0 }; }
186
- const chunkSize = 250;
187
- let moved = 0;
188
- for (let i = 0; i < uids.length; i += chunkSize) {
189
- const chunk = uids.slice(i, i + chunkSize);
190
- await client.messageMove(chunk, targetMailbox, { uid: true });
191
- moved += chunk.length;
192
- }
193
574
  await client.logout();
194
- return { moved, sender, targetMailbox };
575
+ if (uids.length === 0) return { moved: 0 };
576
+ await ensureMailbox(targetMailbox);
577
+ const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox);
578
+ return { ...result, sender, targetMailbox };
195
579
  }
196
580
 
197
581
  async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
@@ -199,15 +583,18 @@ async function bulkDeleteBySubject(subject, mailbox = 'INBOX') {
199
583
  await client.connect();
200
584
  await client.mailboxOpen(mailbox);
201
585
  const uids = (await client.search({ subject }, { uid: true })) ?? [];
202
- if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
203
- const chunkSize = 250;
586
+ await client.logout();
587
+ if (uids.length === 0) return { deleted: 0 };
204
588
  let deleted = 0;
205
- for (let i = 0; i < uids.length; i += chunkSize) {
206
- const chunk = uids.slice(i, i + chunkSize);
207
- await client.messageDelete(chunk, { uid: true });
589
+ for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
590
+ const chunk = uids.slice(i, i + CHUNK_SIZE);
591
+ const dc = createClient();
592
+ await dc.connect();
593
+ await dc.mailboxOpen(mailbox);
594
+ await dc.messageDelete(chunk, { uid: true });
595
+ await dc.logout();
208
596
  deleted += chunk.length;
209
597
  }
210
- await client.logout();
211
598
  return { deleted, subject };
212
599
  }
213
600
 
@@ -218,15 +605,18 @@ async function deleteOlderThan(days, mailbox = 'INBOX') {
218
605
  const date = new Date();
219
606
  date.setDate(date.getDate() - days);
220
607
  const uids = (await client.search({ before: date }, { uid: true })) ?? [];
221
- if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
222
- const chunkSize = 250;
608
+ await client.logout();
609
+ if (uids.length === 0) return { deleted: 0 };
223
610
  let deleted = 0;
224
- for (let i = 0; i < uids.length; i += chunkSize) {
225
- const chunk = uids.slice(i, i + chunkSize);
226
- await client.messageDelete(chunk, { uid: true });
611
+ for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
612
+ const chunk = uids.slice(i, i + CHUNK_SIZE);
613
+ const dc = createClient();
614
+ await dc.connect();
615
+ await dc.mailboxOpen(mailbox);
616
+ await dc.messageDelete(chunk, { uid: true });
617
+ await dc.logout();
227
618
  deleted += chunk.length;
228
619
  }
229
- await client.logout();
230
620
  return { deleted, olderThan: date.toISOString() };
231
621
  }
232
622
 
@@ -301,10 +691,9 @@ async function emptyTrash() {
301
691
  await client.mailboxOpen('Deleted Messages');
302
692
  const uids = (await client.search({ all: true }, { uid: true })) ?? [];
303
693
  if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
304
- const chunkSize = 250;
305
694
  let deleted = 0;
306
- for (let i = 0; i < uids.length; i += chunkSize) {
307
- const chunk = uids.slice(i, i + chunkSize);
695
+ for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
696
+ const chunk = uids.slice(i, i + CHUNK_SIZE);
308
697
  await client.messageDelete(chunk, { uid: true });
309
698
  deleted += chunk.length;
310
699
  }
@@ -323,16 +712,32 @@ async function createMailbox(name) {
323
712
  async function renameMailbox(oldName, newName) {
324
713
  const client = createClient();
325
714
  await client.connect();
326
- await client.mailboxRename(oldName, newName);
327
- await client.logout();
715
+ try {
716
+ await Promise.race([
717
+ client.mailboxRename(oldName, newName),
718
+ new Promise((_, reject) =>
719
+ setTimeout(() => reject(new Error('rename timed out after 15s — Apple IMAP may not support renaming this folder')), 15000)
720
+ )
721
+ ]);
722
+ } finally {
723
+ try { await client.logout(); } catch { client.close(); }
724
+ }
328
725
  return { renamed: { from: oldName, to: newName } };
329
726
  }
330
727
 
331
728
  async function deleteMailbox(name) {
332
729
  const client = createClient();
333
730
  await client.connect();
334
- await client.mailboxDelete(name);
335
- await client.logout();
731
+ try {
732
+ await Promise.race([
733
+ client.mailboxDelete(name),
734
+ new Promise((_, reject) =>
735
+ setTimeout(() => reject(new Error('delete timed out after 15s — Apple IMAP may not support deleting this folder')), 15000)
736
+ )
737
+ ]);
738
+ } finally {
739
+ try { await client.logout(); } catch { client.close(); }
740
+ }
336
741
  return { deleted: name };
337
742
  }
338
743
 
@@ -483,26 +888,29 @@ function buildQuery(filters) {
483
888
  return query;
484
889
  }
485
890
 
891
+ async function ensureMailbox(name) {
892
+ const client = createClient();
893
+ await client.connect();
894
+ try { await client.mailboxCreate(name); } catch { /* already exists */ }
895
+ await client.logout();
896
+ }
897
+
486
898
  async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX', dryRun = false) {
487
899
  const client = createClient();
488
900
  await client.connect();
489
901
  await client.mailboxOpen(sourceMailbox);
490
902
  const query = buildQuery(filters);
491
903
  const uids = (await client.search(query, { uid: true })) ?? [];
904
+ await client.logout();
905
+
492
906
  if (dryRun) {
493
- await client.logout();
494
907
  return { dryRun: true, wouldMove: uids.length, sourceMailbox, targetMailbox, filters };
495
908
  }
496
- if (uids.length === 0) { await client.logout(); return { moved: 0, sourceMailbox, targetMailbox }; }
497
- const chunkSize = 250;
498
- let moved = 0;
499
- for (let i = 0; i < uids.length; i += chunkSize) {
500
- const chunk = uids.slice(i, i + chunkSize);
501
- await client.messageMove(chunk, targetMailbox, { uid: true });
502
- moved += chunk.length;
503
- }
504
- await client.logout();
505
- return { moved, sourceMailbox, targetMailbox, filters };
909
+ if (uids.length === 0) return { moved: 0, sourceMailbox, targetMailbox };
910
+
911
+ await ensureMailbox(targetMailbox);
912
+ const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox);
913
+ return { ...result, sourceMailbox, targetMailbox, filters };
506
914
  }
507
915
 
508
916
  async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
@@ -511,19 +919,23 @@ async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = false) {
511
919
  await client.mailboxOpen(sourceMailbox);
512
920
  const query = buildQuery(filters);
513
921
  const uids = (await client.search(query, { uid: true })) ?? [];
922
+ await client.logout();
923
+
514
924
  if (dryRun) {
515
- await client.logout();
516
925
  return { dryRun: true, wouldDelete: uids.length, sourceMailbox, filters };
517
926
  }
518
- if (uids.length === 0) { await client.logout(); return { deleted: 0, sourceMailbox }; }
519
- const chunkSize = 250;
927
+ if (uids.length === 0) return { deleted: 0, sourceMailbox };
928
+
520
929
  let deleted = 0;
521
- for (let i = 0; i < uids.length; i += chunkSize) {
522
- const chunk = uids.slice(i, i + chunkSize);
523
- await client.messageDelete(chunk, { uid: true });
930
+ for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
931
+ const chunk = uids.slice(i, i + CHUNK_SIZE);
932
+ const deleteClient = createClient();
933
+ await deleteClient.connect();
934
+ await deleteClient.mailboxOpen(sourceMailbox);
935
+ await deleteClient.messageDelete(chunk, { uid: true });
936
+ await deleteClient.logout();
524
937
  deleted += chunk.length;
525
938
  }
526
- await client.logout();
527
939
  return { deleted, sourceMailbox, filters };
528
940
  }
529
941
 
@@ -537,9 +949,35 @@ async function countEmails(filters, mailbox = 'INBOX') {
537
949
  return { count: uids.length, mailbox, filters };
538
950
  }
539
951
 
952
+ // ─── Session Log ──────────────────────────────────────────────────────────────
953
+
954
+ function logRead() {
955
+ if (!existsSync(LOG_FILE)) return { steps: [], startedAt: null };
956
+ try {
957
+ return JSON.parse(readFileSync(LOG_FILE, 'utf8'));
958
+ } catch {
959
+ return { steps: [], startedAt: null };
960
+ }
961
+ }
962
+
963
+ function logWrite(step) {
964
+ const log = logRead();
965
+ if (!log.startedAt) log.startedAt = new Date().toISOString();
966
+ log.steps.push({ time: new Date().toISOString(), step });
967
+ writeFileSync(LOG_FILE, JSON.stringify(log, null, 2));
968
+ return log;
969
+ }
970
+
971
+ function logClear() {
972
+ writeFileSync(LOG_FILE, JSON.stringify({ steps: [], startedAt: null }, null, 2));
973
+ return { cleared: true };
974
+ }
975
+
976
+ // ─── MCP Server ───────────────────────────────────────────────────────────────
977
+
540
978
  async function main() {
541
979
  const server = new Server(
542
- { name: 'icloud-mail', version: '1.3.0' },
980
+ { name: 'icloud-mail', version: '1.6.0' },
543
981
  { capabilities: { tools: {} } }
544
982
  );
545
983
 
@@ -568,9 +1006,7 @@ async function main() {
568
1006
  description: 'Get total, unread, and recent email counts for any specific mailbox/folder',
569
1007
  inputSchema: {
570
1008
  type: 'object',
571
- properties: {
572
- mailbox: { type: 'string', description: 'Mailbox path to summarize (e.g. Newsletters, Archive)' }
573
- },
1009
+ properties: { mailbox: { type: 'string', description: 'Mailbox path to summarize (e.g. Newsletters, Archive)' } },
574
1010
  required: ['mailbox']
575
1011
  }
576
1012
  },
@@ -663,7 +1099,7 @@ async function main() {
663
1099
  },
664
1100
  {
665
1101
  name: 'bulk_move',
666
- description: 'Move emails matching any combination of filters from one mailbox to another. Processes in chunks of 250 for reliability. Use dryRun: true to preview without making changes.',
1102
+ description: 'Move emails matching any combination of filters from one mailbox to another. Uses safe copy-verify-delete with fingerprint verification and a persistent manifest. Use dryRun: true to preview without making changes.',
667
1103
  inputSchema: {
668
1104
  type: 'object',
669
1105
  properties: {
@@ -846,9 +1282,7 @@ async function main() {
846
1282
  description: 'Create a new mailbox/folder',
847
1283
  inputSchema: {
848
1284
  type: 'object',
849
- properties: {
850
- name: { type: 'string', description: 'Name of the new mailbox' }
851
- },
1285
+ properties: { name: { type: 'string', description: 'Name of the new mailbox' } },
852
1286
  required: ['name']
853
1287
  }
854
1288
  },
@@ -869,9 +1303,7 @@ async function main() {
869
1303
  description: 'Delete a mailbox/folder. The folder must be empty first.',
870
1304
  inputSchema: {
871
1305
  type: 'object',
872
- properties: {
873
- name: { type: 'string', description: 'Mailbox path to delete' }
874
- },
1306
+ properties: { name: { type: 'string', description: 'Mailbox path to delete' } },
875
1307
  required: ['name']
876
1308
  }
877
1309
  },
@@ -879,6 +1311,37 @@ async function main() {
879
1311
  name: 'empty_trash',
880
1312
  description: 'Permanently delete all emails in Deleted Messages',
881
1313
  inputSchema: { type: 'object', properties: {} }
1314
+ },
1315
+ {
1316
+ name: 'get_move_status',
1317
+ description: 'Check the status of the current or most recent bulk move operation. Shows progress, chunk statuses, and any failures. Call this to monitor a long-running move or inspect a failed one.',
1318
+ inputSchema: { type: 'object', properties: {} }
1319
+ },
1320
+ {
1321
+ name: 'abandon_move',
1322
+ description: 'Abandon an in-progress move operation so a new one can start. Only use if you are certain the operation should not be resumed. Emails already moved will not be returned to source.',
1323
+ inputSchema: { type: 'object', properties: {} }
1324
+ },
1325
+ {
1326
+ name: 'log_write',
1327
+ description: 'Write a step to the session log. Use this to record your plan before starting, and after each completed step. Helps maintain progress across long operations.',
1328
+ inputSchema: {
1329
+ type: 'object',
1330
+ properties: {
1331
+ step: { type: 'string', description: 'Description of what you are doing or just completed' }
1332
+ },
1333
+ required: ['step']
1334
+ }
1335
+ },
1336
+ {
1337
+ name: 'log_read',
1338
+ description: 'Read the current session log to see what has been done so far.',
1339
+ inputSchema: { type: 'object', properties: {} }
1340
+ },
1341
+ {
1342
+ name: 'log_clear',
1343
+ description: 'Clear the session log and start fresh. Use this at the start of a new task.',
1344
+ inputSchema: { type: 'object', properties: {} }
882
1345
  }
883
1346
  ]
884
1347
  }));
@@ -948,6 +1411,16 @@ async function main() {
948
1411
  result = await deleteMailbox(args.name);
949
1412
  } else if (name === 'empty_trash') {
950
1413
  result = await emptyTrash();
1414
+ } else if (name === 'get_move_status') {
1415
+ result = getMoveStatus();
1416
+ } else if (name === 'abandon_move') {
1417
+ result = abandonMove();
1418
+ } else if (name === 'log_write') {
1419
+ result = logWrite(args.step);
1420
+ } else if (name === 'log_read') {
1421
+ result = logRead();
1422
+ } else if (name === 'log_clear') {
1423
+ result = logClear();
951
1424
  } else {
952
1425
  throw new Error(`Unknown tool: ${name}`);
953
1426
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icloud-mcp",
3
- "version": "1.2.2",
3
+ "version": "1.4.0",
4
4
  "description": "A Model Context Protocol (MCP) server for iCloud Mail",
5
5
  "main": "index.js",
6
6
  "bin": {
package/test.js CHANGED
@@ -14,7 +14,15 @@ if (!IMAP_USER || !IMAP_PASSWORD) {
14
14
 
15
15
  const projectDir = fileURLToPath(new URL('.', import.meta.url));
16
16
 
17
- function callTool(name, args = {}) {
17
+ // Timeout in ms per tool category
18
+ const TIMEOUTS = {
19
+ mailbox_mgmt: 60000, // create/rename/delete mailbox — can be slow on iCloud
20
+ default: 300000 // everything else — allow up to 5 min for large operations
21
+ };
22
+
23
+ const MAILBOX_MGMT_TOOLS = new Set(['create_mailbox', 'rename_mailbox', 'delete_mailbox']);
24
+
25
+ function callToolRaw(name, args = {}, timeout = TIMEOUTS.default) {
18
26
  const messages = [
19
27
  { jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } },
20
28
  { jsonrpc: '2.0', method: 'notifications/initialized' },
@@ -32,7 +40,7 @@ function callTool(name, args = {}) {
32
40
  {
33
41
  cwd: projectDir,
34
42
  encoding: 'utf8',
35
- timeout: 300000,
43
+ timeout,
36
44
  env: { ...process.env, IMAP_USER, IMAP_PASSWORD }
37
45
  }
38
46
  );
@@ -53,6 +61,26 @@ function callTool(name, args = {}) {
53
61
  }
54
62
  }
55
63
 
64
+ function callTool(name, args = {}) {
65
+ const timeout = MAILBOX_MGMT_TOOLS.has(name) ? TIMEOUTS.mailbox_mgmt : TIMEOUTS.default;
66
+ try {
67
+ return callToolRaw(name, args, timeout);
68
+ } catch (err) {
69
+ // Only retry on spawn-level transient errors (ECONNRESET, ETIMEDOUT on the
70
+ // child process itself) — NOT on Tool errors, which are application-level
71
+ // failures that should propagate immediately.
72
+ const isSpawnTransient = (
73
+ err.message.includes('ECONNRESET') ||
74
+ err.message.includes('ETIMEDOUT')
75
+ ) && !err.message.startsWith('Tool error:');
76
+ if (isSpawnTransient) {
77
+ console.log(`\n āš ļø transient spawn error (${err.message.split(':')[0]}), retrying...`);
78
+ return callToolRaw(name, args, timeout);
79
+ }
80
+ throw err;
81
+ }
82
+ }
83
+
56
84
  let passed = 0;
57
85
  let failed = 0;
58
86
 
@@ -72,7 +100,40 @@ function assert(condition, message) {
72
100
  if (!condition) throw new Error(message);
73
101
  }
74
102
 
75
- console.log('\n🧪 iCloud MCP Server Tests\n');
103
+ console.log('\n🧪 iCloud MCP Server Tests v1.6.0\n');
104
+
105
+ // ─── Pre-flight cleanup ───────────────────────────────────────────────────────
106
+ // Abandon any leftover in-progress manifest from a previous crashed run,
107
+ // then restore any emails stranded in the test folder.
108
+ console.log('🧹 Pre-flight cleanup');
109
+
110
+ try {
111
+ const status = callTool('get_move_status');
112
+ if (status.current && status.current.status === 'in_progress') {
113
+ console.log(` āš ļø found in-progress manifest (${status.current.operationId}) — abandoning before cleanup`);
114
+ callTool('abandon_move');
115
+ }
116
+ } catch (err) {
117
+ console.log(` āš ļø could not check manifest: ${err.message}`);
118
+ }
119
+
120
+ try { callTool('delete_mailbox', { name: 'mcp-test-folder-renamed' }); } catch {}
121
+ try { callTool('delete_mailbox', { name: 'mcp-test-folder' }); } catch {}
122
+
123
+ try {
124
+ const strandedInTest = callTool('count_emails', { mailbox: 'test' });
125
+ if (strandedInTest.count > 0) {
126
+ console.log(` āš ļø found ${strandedInTest.count} stranded emails in test folder — restoring to newsletters first`);
127
+ const restoreResult = callTool('bulk_move', { sourceMailbox: 'test', targetMailbox: 'newsletters' });
128
+ console.log(` āš ļø restore status: ${restoreResult.status}, moved: ${restoreResult.moved}`);
129
+ } else {
130
+ console.log(' āœ“ test folder is clean');
131
+ }
132
+ } catch (err) {
133
+ console.log(` āš ļø stranded email cleanup failed: ${err.message} — proceeding anyway`);
134
+ }
135
+
136
+ console.log('');
76
137
 
77
138
  // ─── Mailbox & Summary ────────────────────────────────────────────────────────
78
139
  console.log('šŸ“¬ Mailbox & Summary');
@@ -270,6 +331,7 @@ test('bulk_move (dryRun)', () => {
270
331
  const result = callTool('bulk_move', { domain: topDomain, targetMailbox: 'Archive', dryRun: true });
271
332
  assert(result.dryRun === true, 'dryRun should be true');
272
333
  assert(typeof result.wouldMove === 'number', 'wouldMove should be a number');
334
+ assert(result.targetMailbox === 'Archive', 'targetMailbox should be Archive');
273
335
  console.log(`\n → would move ${result.wouldMove} emails from @${topDomain}`);
274
336
  });
275
337
 
@@ -277,6 +339,7 @@ test('bulk_delete (dryRun)', () => {
277
339
  const result = callTool('bulk_delete', { before: '2015-01-01', dryRun: true });
278
340
  assert(result.dryRun === true, 'dryRun should be true');
279
341
  assert(typeof result.wouldDelete === 'number', 'wouldDelete should be a number');
342
+ assert(typeof result.sourceMailbox === 'string', 'sourceMailbox should be a string');
280
343
  console.log(`\n → would delete ${result.wouldDelete} emails before 2015`);
281
344
  });
282
345
 
@@ -314,48 +377,147 @@ test('create_mailbox', () => {
314
377
  console.log(`\n → created: ${result.created}`);
315
378
  });
316
379
 
317
- test('rename_mailbox', () => {
318
- const result = callTool('rename_mailbox', { oldName: 'mcp-test-folder', newName: 'mcp-test-folder-renamed' });
319
- assert(result.renamed.from === 'mcp-test-folder', 'from should match old name');
320
- assert(result.renamed.to === 'mcp-test-folder-renamed', 'to should match new name');
321
- console.log(`\n → renamed: ${result.renamed.from} → ${result.renamed.to}`);
380
+ test('rename_mailbox + delete_mailbox', () => {
381
+ const renamed = callTool('rename_mailbox', { oldName: 'mcp-test-folder', newName: 'mcp-test-folder-renamed' });
382
+ assert(renamed.renamed.from === 'mcp-test-folder', 'from should match old name');
383
+ assert(renamed.renamed.to === 'mcp-test-folder-renamed', 'to should match new name');
384
+ console.log(`\n → renamed: ${renamed.renamed.from} → ${renamed.renamed.to}`);
385
+
386
+ const deleted = callTool('delete_mailbox', { name: 'mcp-test-folder-renamed' });
387
+ assert(deleted.deleted === 'mcp-test-folder-renamed', 'should confirm deletion');
388
+ console.log(`\n → deleted: ${deleted.deleted}`);
389
+ });
390
+
391
+ // ─── Move Manifest ────────────────────────────────────────────────────────────
392
+ console.log('\nšŸ—ŗļø Move Manifest');
393
+
394
+ test('get_move_status (no operation)', () => {
395
+ // Abandon any leftover operation so we start clean
396
+ try { callTool('abandon_move'); } catch {}
397
+ const result = callTool('get_move_status');
398
+ assert(result.status === 'no_operation', `expected no_operation, got ${result.status}`);
399
+ assert(Array.isArray(result.history), 'history should be an array');
400
+ console.log(`\n → status: ${result.status}, history: ${result.history.length} entries`);
322
401
  });
323
402
 
324
- test('delete_mailbox', () => {
325
- const result = callTool('delete_mailbox', { name: 'mcp-test-folder-renamed' });
326
- assert(result.deleted === 'mcp-test-folder-renamed', 'should confirm deletion');
327
- console.log(`\n → deleted: ${result.deleted}`);
403
+ test('abandon_move (nothing to abandon)', () => {
404
+ const result = callTool('abandon_move');
405
+ assert(result.abandoned === false, 'should return abandoned: false when nothing in progress');
406
+ assert(typeof result.message === 'string', 'should include a message');
407
+ console.log(`\n → ${result.message}`);
328
408
  });
329
409
 
330
- // ─── Chunk Move Test (live) ───────────────────────────────────────────────────
331
- console.log('\nšŸ“¦ Chunk Move Test (live — newsletters ↔ test)');
410
+ // ─── Safe Move (live) ─────────────────────────────────────────────────────────
411
+ console.log('\nšŸ” Safe Move Test (live — newsletters ↔ test)');
332
412
 
333
- test('bulk_move newsletters → test (chunked)', () => {
413
+ test('bulk_move newsletters → test (fingerprint verified)', () => {
334
414
  const beforeSource = callTool('count_emails', { mailbox: 'newsletters' });
335
415
  assert(beforeSource.count > 0, 'newsletters should have emails');
336
416
  console.log(`\n → newsletters before: ${beforeSource.count}`);
337
417
 
338
418
  const moveResult = callTool('bulk_move', { sourceMailbox: 'newsletters', targetMailbox: 'test' });
339
- console.log(`\n → moved: ${moveResult.moved}`);
340
- assert(moveResult.moved === beforeSource.count, `moved count should match source (expected ${beforeSource.count}, got ${moveResult.moved})`);
419
+ console.log(`\n → status: ${moveResult.status}, moved: ${moveResult.moved} of ${moveResult.total}`);
420
+ assert(moveResult.status === 'complete', `expected complete, got ${moveResult.status}: ${moveResult.message || ''}`);
421
+ assert(moveResult.moved === beforeSource.count, `moved ${moveResult.moved} but expected ${beforeSource.count}`);
422
+ assert(moveResult.total === beforeSource.count, `total ${moveResult.total} should match source count ${beforeSource.count}`);
423
+
424
+ const afterSource = callTool('count_emails', { mailbox: 'newsletters' });
425
+ console.log(`\n → newsletters after (should be 0): ${afterSource.count}`);
426
+ assert(afterSource.count === 0, `newsletters should be empty, has ${afterSource.count}`);
341
427
 
342
428
  const afterTarget = callTool('count_emails', { mailbox: 'test' });
343
429
  console.log(`\n → test folder after: ${afterTarget.count}`);
344
- assert(afterTarget.count === beforeSource.count, `test folder should have all ${beforeSource.count} emails`);
430
+ assert(afterTarget.count === beforeSource.count, `test should have ${beforeSource.count}, has ${afterTarget.count}`);
345
431
  });
346
432
 
347
- test('bulk_move test → newsletters (restore)', () => {
433
+ test('get_move_status (after completed move)', () => {
434
+ const result = callTool('get_move_status');
435
+ // Current should be null (archived after completion), history should have the move
436
+ assert(result.status === 'no_operation', `expected no_operation after completed move, got ${result.status}`);
437
+ assert(result.history.length > 0, 'history should have at least one entry');
438
+ const last = result.history[0];
439
+ assert(last.status === 'complete', `last operation should be complete, got ${last.status}`);
440
+ assert(last.source === 'newsletters', `source should be newsletters, got ${last.source}`);
441
+ assert(last.target === 'test', `target should be test, got ${last.target}`);
442
+ console.log(`\n → last op: ${last.status}, ${last.moved}/${last.total} moved from ${last.source} → ${last.target}`);
443
+ });
444
+
445
+ test('bulk_move test → newsletters (restore, fingerprint verified)', () => {
348
446
  const beforeSource = callTool('count_emails', { mailbox: 'test' });
349
447
  assert(beforeSource.count > 0, 'test folder should have emails to restore');
350
- console.log(`\n → test folder before restore: ${beforeSource.count}`);
448
+ console.log(`\n → test before restore: ${beforeSource.count}`);
351
449
 
352
450
  const moveBack = callTool('bulk_move', { sourceMailbox: 'test', targetMailbox: 'newsletters' });
353
- console.log(`\n → moved back: ${moveBack.moved}`);
354
- assert(moveBack.moved === beforeSource.count, `should move all ${beforeSource.count} emails back`);
355
-
356
- const afterRestore = callTool('count_emails', { mailbox: 'newsletters' });
357
- console.log(`\n → newsletters restored: ${afterRestore.count}`);
358
- assert(afterRestore.count === beforeSource.count, 'newsletters should be fully restored');
451
+ console.log(`\n → status: ${moveBack.status}, moved: ${moveBack.moved} of ${moveBack.total}`);
452
+ assert(moveBack.status === 'complete', `expected complete, got ${moveBack.status}: ${moveBack.message || ''}`);
453
+ assert(moveBack.moved === beforeSource.count, `moved ${moveBack.moved} but expected ${beforeSource.count}`);
454
+
455
+ const afterSource = callTool('count_emails', { mailbox: 'test' });
456
+ console.log(`\n → test after (should be 0): ${afterSource.count}`);
457
+ assert(afterSource.count === 0, `test should be empty, has ${afterSource.count}`);
458
+
459
+ const afterTarget = callTool('count_emails', { mailbox: 'newsletters' });
460
+ console.log(`\n → newsletters restored: ${afterTarget.count}`);
461
+ assert(afterTarget.count === beforeSource.count, `newsletters should have ${beforeSource.count}, has ${afterTarget.count}`);
462
+ });
463
+
464
+ test('get_move_status (history has both moves)', () => {
465
+ const result = callTool('get_move_status');
466
+ assert(result.status === 'no_operation', 'no operation should be in progress');
467
+ assert(result.history.length >= 2, `history should have at least 2 entries, has ${result.history.length}`);
468
+ const [restore, forward] = result.history;
469
+ assert(restore.status === 'complete', `restore op should be complete, got ${restore.status}`);
470
+ assert(restore.source === 'test', `restore source should be test, got ${restore.source}`);
471
+ assert(forward.status === 'complete', `forward op should be complete, got ${forward.status}`);
472
+ assert(forward.source === 'newsletters', `forward source should be newsletters, got ${forward.source}`);
473
+ console.log(`\n → history[0]: ${restore.source} → ${restore.target} (${restore.status})`);
474
+ console.log(`\n → history[1]: ${forward.source} → ${forward.target} (${forward.status})`);
475
+ });
476
+
477
+ // ─── Session Log ─────────────────────────────────────────────────────────────
478
+ console.log('\nšŸ“ Session Log');
479
+
480
+ test('log_clear', () => {
481
+ const result = callTool('log_clear');
482
+ assert(result.cleared === true, 'should confirm cleared');
483
+ console.log(`\n → log cleared`);
484
+ });
485
+
486
+ test('log_write (plan)', () => {
487
+ const result = callTool('log_write', { step: 'plan: test log functionality' });
488
+ assert(Array.isArray(result.steps), 'steps should be an array');
489
+ assert(result.steps.length === 1, 'should have 1 step');
490
+ assert(typeof result.startedAt === 'string', 'startedAt should be a string');
491
+ assert(result.steps[0].step === 'plan: test log functionality', 'step content should match');
492
+ assert(typeof result.steps[0].time === 'string', 'step should have a timestamp');
493
+ console.log(`\n → wrote step, log has ${result.steps.length} entry`);
494
+ });
495
+
496
+ test('log_write (second step)', () => {
497
+ const result = callTool('log_write', { step: 'done: log test complete' });
498
+ assert(Array.isArray(result.steps), 'steps should be an array');
499
+ assert(result.steps.length === 2, 'should have 2 steps');
500
+ assert(result.steps[1].step === 'done: log test complete', 'second step content should match');
501
+ console.log(`\n → log now has ${result.steps.length} entries`);
502
+ });
503
+
504
+ test('log_read', () => {
505
+ const result = callTool('log_read');
506
+ assert(Array.isArray(result.steps), 'steps should be an array');
507
+ assert(result.steps.length === 2, 'should have 2 steps');
508
+ assert(result.steps[0].step === 'plan: test log functionality', 'first step should match');
509
+ assert(result.steps[1].step === 'done: log test complete', 'second step should match');
510
+ assert(typeof result.startedAt === 'string', 'startedAt should persist');
511
+ console.log(`\n → read log: ${result.steps.length} steps, started ${result.startedAt}`);
512
+ });
513
+
514
+ test('log_clear (cleanup)', () => {
515
+ const result = callTool('log_clear');
516
+ assert(result.cleared === true, 'should confirm cleared');
517
+ const log = callTool('log_read');
518
+ assert(log.steps.length === 0, 'log should be empty after clear');
519
+ assert(log.startedAt === null, 'startedAt should be null after clear');
520
+ console.log(`\n → log cleared and verified empty`);
359
521
  });
360
522
 
361
523
  // ─── Destructive (skipped) ────────────────────────────────────────────────────