ofw-mcp 2.5.0 → 2.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +11 -0
- package/dist/auth-password.js +8 -1
- package/dist/bundle.js +878 -519
- package/dist/cache/node.js +85 -0
- package/dist/cache/store.js +480 -0
- package/dist/client.js +22 -4
- package/dist/config.js +21 -0
- package/dist/index.js +13 -2
- package/dist/ofw-auth.js +26 -0
- package/dist/sync.js +241 -60
- package/dist/tools/attachments.js +66 -0
- package/dist/tools/messages.js +67 -81
- package/package.json +12 -3
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +2 -0
- package/dist/cache.js +0 -345
package/dist/tools/messages.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
6
|
-
import { basename, dirname, extname, join } from 'node:path';
|
|
7
|
-
import { fileBlob } from '@chrischall/mcp-utils';
|
|
3
|
+
import { getAttachmentsDir, getDefaultInlineAttachments, getSyncMaxRequests, getWriteMode } from '../config.js';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
8
5
|
import { ApiRecipientSchema, expandPath, hasRealView, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
|
|
9
6
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
10
7
|
// Schemas for the load-bearing fields of each /pub/v3 response this file
|
|
@@ -38,6 +35,10 @@ const MessageDetailSchema = z.looseObject({
|
|
|
38
35
|
from: z.looseObject({ name: z.string().optional() }).optional(),
|
|
39
36
|
files: z.array(z.number()).optional(),
|
|
40
37
|
recipients: z.array(ApiRecipientSchema).optional(),
|
|
38
|
+
// The detail payload carries its own owning folder ({id, name}). We read the
|
|
39
|
+
// id to label a live-fetched message sent-vs-inbox instead of blindly
|
|
40
|
+
// defaulting to inbox — see the folder derivation in ofw_get_message.
|
|
41
|
+
folder: z.looseObject({ id: z.number() }).optional(),
|
|
41
42
|
});
|
|
42
43
|
// Attachment-backfill detail fetch reads only `files`.
|
|
43
44
|
const DetailFilesSchema = z.looseObject({ files: z.array(z.number()).optional() });
|
|
@@ -51,33 +52,6 @@ const UploadedFileSchema = z.looseObject({
|
|
|
51
52
|
sizeInBytes: z.number().optional(),
|
|
52
53
|
shareClass: z.string().optional(),
|
|
53
54
|
});
|
|
54
|
-
// Lightweight mime sniff from extension. OFW re-derives mime from the filename
|
|
55
|
-
// server-side anyway, so this is just a polite Content-Type for the Blob.
|
|
56
|
-
const MIME_BY_EXT = {
|
|
57
|
-
'.pdf': 'application/pdf',
|
|
58
|
-
'.png': 'image/png',
|
|
59
|
-
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
60
|
-
'.gif': 'image/gif',
|
|
61
|
-
'.webp': 'image/webp',
|
|
62
|
-
'.heic': 'image/heic',
|
|
63
|
-
'.txt': 'text/plain',
|
|
64
|
-
'.md': 'text/markdown',
|
|
65
|
-
'.csv': 'text/csv',
|
|
66
|
-
'.html': 'text/html', '.htm': 'text/html',
|
|
67
|
-
'.json': 'application/json',
|
|
68
|
-
'.xml': 'application/xml',
|
|
69
|
-
'.doc': 'application/msword',
|
|
70
|
-
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
71
|
-
'.xls': 'application/vnd.ms-excel',
|
|
72
|
-
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
73
|
-
'.ppt': 'application/vnd.ms-powerpoint',
|
|
74
|
-
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
75
|
-
'.zip': 'application/zip',
|
|
76
|
-
'.ics': 'text/calendar',
|
|
77
|
-
};
|
|
78
|
-
function mimeFromName(name) {
|
|
79
|
-
return MIME_BY_EXT[extname(name).toLowerCase()] ?? 'application/octet-stream';
|
|
80
|
-
}
|
|
81
55
|
// The list endpoint payload (cached as `listData`) reports attachments via
|
|
82
56
|
// `files: <count>` (a number) — the actual fileIds only appear on the detail
|
|
83
57
|
// endpoint as `files: [number, ...]`. Some intermediate shapes return an
|
|
@@ -92,7 +66,7 @@ function listDataHintsAtFiles(listData) {
|
|
|
92
66
|
return ld.files.length > 0;
|
|
93
67
|
return false;
|
|
94
68
|
}
|
|
95
|
-
export function registerMessageTools(server, client) {
|
|
69
|
+
export function registerMessageTools(server, client, cacheProvider, attachmentIO) {
|
|
96
70
|
// OFW_WRITE_MODE gate (see config.ts). Send lands on the court-visible
|
|
97
71
|
// record, so it is 'all'-only; draft-level writes (save/delete drafts,
|
|
98
72
|
// upload attachments) also register under 'drafts'. Read/sync/download
|
|
@@ -135,9 +109,10 @@ export function registerMessageTools(server, client) {
|
|
|
135
109
|
note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.',
|
|
136
110
|
});
|
|
137
111
|
}
|
|
112
|
+
const cache = cacheProvider();
|
|
138
113
|
const filter = { folder, since: args.since, until: args.until, q: args.q };
|
|
139
|
-
const total = countMessages(filter);
|
|
140
|
-
const messages = listMessages({ ...filter, page, size });
|
|
114
|
+
const total = await cache.countMessages(filter);
|
|
115
|
+
const messages = await cache.listMessages({ ...filter, page, size });
|
|
141
116
|
const payload = { messages, total, page, size };
|
|
142
117
|
if (total === 0) {
|
|
143
118
|
payload.note = 'No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.';
|
|
@@ -155,13 +130,14 @@ export function registerMessageTools(server, client) {
|
|
|
155
130
|
},
|
|
156
131
|
}, async (args) => {
|
|
157
132
|
const id = Number(args.messageId);
|
|
133
|
+
const cache = cacheProvider();
|
|
158
134
|
// Draft routing: if this id is in the drafts cache, return a
|
|
159
135
|
// MessageRow-shaped synthesis built from the draft. The drafts table
|
|
160
136
|
// is the source of truth for draft bodies (sync keeps it fresh);
|
|
161
137
|
// the messages-table cache for the same id is stale by construction
|
|
162
138
|
// when ofw_get_message was called on a draft id before sync caught
|
|
163
139
|
// up — see syncDrafts, which also evicts these stale rows.
|
|
164
|
-
const draftRow = getDraft(id);
|
|
140
|
+
const draftRow = await cache.getDraft(id);
|
|
165
141
|
if (draftRow !== null) {
|
|
166
142
|
return jsonResponse({
|
|
167
143
|
id: draftRow.id,
|
|
@@ -181,7 +157,7 @@ export function registerMessageTools(server, client) {
|
|
|
181
157
|
attachments: [],
|
|
182
158
|
});
|
|
183
159
|
}
|
|
184
|
-
const cached = getMessage(id);
|
|
160
|
+
const cached = await cache.getMessage(id);
|
|
185
161
|
if (cached && cached.body !== null) {
|
|
186
162
|
let row = cached;
|
|
187
163
|
// Refresh view status for a sent message we still believe is unviewed:
|
|
@@ -203,13 +179,13 @@ export function registerMessageTools(server, client) {
|
|
|
203
179
|
recipients,
|
|
204
180
|
listData: { ...cached.listData, showNeverViewed: !hasRealView(recipients) },
|
|
205
181
|
};
|
|
206
|
-
upsertMessage(row);
|
|
182
|
+
await cache.upsertMessage(row);
|
|
207
183
|
}
|
|
208
184
|
catch {
|
|
209
185
|
// Best-effort: fall back to the cached row on any fetch/parse error.
|
|
210
186
|
}
|
|
211
187
|
}
|
|
212
|
-
let attachments = listAttachmentsForMessage(id);
|
|
188
|
+
let attachments = await cache.listAttachmentsForMessage(id);
|
|
213
189
|
// Lazy attachment backfill. The list-endpoint payload (stored in
|
|
214
190
|
// listData) hints at attachments via `files: <count>` but doesn't
|
|
215
191
|
// expose the fileIds — those live only on /pub/v3/messages/{id}.
|
|
@@ -221,8 +197,8 @@ export function registerMessageTools(server, client) {
|
|
|
221
197
|
try {
|
|
222
198
|
const detail = parseLenient(DetailFilesSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (attachment backfill)' });
|
|
223
199
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
224
|
-
await fetchAttachmentMetaForMessage(client, id, detail.files);
|
|
225
|
-
attachments = listAttachmentsForMessage(id);
|
|
200
|
+
await fetchAttachmentMetaForMessage(client, id, detail.files, cache);
|
|
201
|
+
attachments = await cache.listAttachmentsForMessage(id);
|
|
226
202
|
}
|
|
227
203
|
}
|
|
228
204
|
catch {
|
|
@@ -232,7 +208,20 @@ export function registerMessageTools(server, client) {
|
|
|
232
208
|
return jsonResponse({ ...row, attachments });
|
|
233
209
|
}
|
|
234
210
|
const detail = parseLenient(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (ofw_get_message)' });
|
|
235
|
-
|
|
211
|
+
// Derive the folder for a live-fetched message. A cached row (reached here
|
|
212
|
+
// only when its body was NULL) already knows its folder, so keep it.
|
|
213
|
+
// Otherwise use the detail's own folder id, matched against the sent folder
|
|
214
|
+
// id persisted by the last resolveFolderIds — a sent message must not be
|
|
215
|
+
// mislabeled 'inbox' (which would also hide it from ofw_get_unread_sent and
|
|
216
|
+
// a sent-scoped ofw_list_messages). When that mapping isn't known yet (no
|
|
217
|
+
// sync has run in this cache), fall back to 'inbox' as before.
|
|
218
|
+
let folder = cached?.folder ?? 'inbox';
|
|
219
|
+
if (!cached) {
|
|
220
|
+
const sentFolderId = await cache.getMeta('sent_folder_id');
|
|
221
|
+
if (sentFolderId !== null && detail.folder?.id != null && String(detail.folder.id) === sentFolderId) {
|
|
222
|
+
folder = 'sent';
|
|
223
|
+
}
|
|
224
|
+
}
|
|
236
225
|
const row = {
|
|
237
226
|
id: detail.id,
|
|
238
227
|
folder,
|
|
@@ -246,11 +235,11 @@ export function registerMessageTools(server, client) {
|
|
|
246
235
|
chainRootId: cached?.chainRootId ?? null,
|
|
247
236
|
listData: cached?.listData ?? detail,
|
|
248
237
|
};
|
|
249
|
-
upsertMessage(row);
|
|
238
|
+
await cache.upsertMessage(row);
|
|
250
239
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
251
|
-
await fetchAttachmentMetaForMessage(client, detail.id, detail.files);
|
|
240
|
+
await fetchAttachmentMetaForMessage(client, detail.id, detail.files, cache);
|
|
252
241
|
}
|
|
253
|
-
const attachments = listAttachmentsForMessage(detail.id);
|
|
242
|
+
const attachments = await cache.listAttachmentsForMessage(detail.id);
|
|
254
243
|
return jsonResponse({ ...row, attachments });
|
|
255
244
|
});
|
|
256
245
|
if (allowSend)
|
|
@@ -271,6 +260,7 @@ export function registerMessageTools(server, client) {
|
|
|
271
260
|
throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
|
|
272
261
|
}
|
|
273
262
|
const draftRef = args.messageId ?? args.draftId;
|
|
263
|
+
const cache = cacheProvider();
|
|
274
264
|
// Best-effort draft lookup: when draftRef points at a cached draft, use
|
|
275
265
|
// its stored fields (including replyToId) as defaults for anything the
|
|
276
266
|
// caller didn't supply. The "missing draft" case only matters when we
|
|
@@ -284,7 +274,7 @@ export function registerMessageTools(server, client) {
|
|
|
284
274
|
let draftFound = false;
|
|
285
275
|
if (draftRef !== undefined) {
|
|
286
276
|
draftLookupAttempted = true;
|
|
287
|
-
const draft = getDraft(draftRef);
|
|
277
|
+
const draft = await cache.getDraft(draftRef);
|
|
288
278
|
if (draft !== null) {
|
|
289
279
|
draftFound = true;
|
|
290
280
|
subject = subject ?? draft.subject;
|
|
@@ -312,11 +302,11 @@ export function registerMessageTools(server, client) {
|
|
|
312
302
|
let chainRootId = null;
|
|
313
303
|
let rewriteNote = null;
|
|
314
304
|
if (requestedReplyTo !== null) {
|
|
315
|
-
resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
|
|
305
|
+
resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
|
|
316
306
|
if (resolvedReplyTo !== requestedReplyTo) {
|
|
317
307
|
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
318
308
|
}
|
|
319
|
-
const parent = getMessage(resolvedReplyTo);
|
|
309
|
+
const parent = await cache.getMessage(resolvedReplyTo);
|
|
320
310
|
chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
|
|
321
311
|
}
|
|
322
312
|
const myFileIDs = args.myFileIDs ?? [];
|
|
@@ -346,13 +336,13 @@ export function registerMessageTools(server, client) {
|
|
|
346
336
|
chainRootId,
|
|
347
337
|
listData: detail,
|
|
348
338
|
};
|
|
349
|
-
upsertMessage(persisted);
|
|
339
|
+
await cache.upsertMessage(persisted);
|
|
350
340
|
// Link attached files to the new message in the attachments cache.
|
|
351
341
|
// We may not have full metadata if the upload happened in a prior
|
|
352
342
|
// session — fall back to what we know.
|
|
353
343
|
for (const fileId of myFileIDs) {
|
|
354
|
-
const existing = getAttachment(fileId);
|
|
355
|
-
upsertAttachmentForMessage({
|
|
344
|
+
const existing = await cache.getAttachment(fileId);
|
|
345
|
+
await cache.upsertAttachmentForMessage({
|
|
356
346
|
fileId,
|
|
357
347
|
fileName: existing?.fileName ?? `file-${fileId}`,
|
|
358
348
|
label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
|
|
@@ -375,7 +365,7 @@ export function registerMessageTools(server, client) {
|
|
|
375
365
|
}
|
|
376
366
|
else if (draftRef !== undefined) {
|
|
377
367
|
await deleteOFWMessages(client, [draftRef]);
|
|
378
|
-
deleteDraft(draftRef);
|
|
368
|
+
await cache.deleteDraft(draftRef);
|
|
379
369
|
}
|
|
380
370
|
const responseObj = persisted ?? raw;
|
|
381
371
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
|
|
@@ -392,7 +382,7 @@ export function registerMessageTools(server, client) {
|
|
|
392
382
|
}, async (args) => {
|
|
393
383
|
const page = args.page ?? 1;
|
|
394
384
|
const size = args.size ?? 50;
|
|
395
|
-
const drafts = listDrafts({ page, size });
|
|
385
|
+
const drafts = await cacheProvider().listDrafts({ page, size });
|
|
396
386
|
const payload = drafts.length === 0
|
|
397
387
|
? { drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' }
|
|
398
388
|
: { drafts };
|
|
@@ -411,11 +401,12 @@ export function registerMessageTools(server, client) {
|
|
|
411
401
|
myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
|
|
412
402
|
},
|
|
413
403
|
}, async (args) => {
|
|
404
|
+
const cache = cacheProvider();
|
|
414
405
|
const requestedReplyTo = args.replyToId ?? null;
|
|
415
406
|
let resolvedReplyTo = requestedReplyTo;
|
|
416
407
|
let rewriteNote = null;
|
|
417
408
|
if (requestedReplyTo !== null) {
|
|
418
|
-
resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
|
|
409
|
+
resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
|
|
419
410
|
if (resolvedReplyTo !== requestedReplyTo) {
|
|
420
411
|
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
421
412
|
}
|
|
@@ -451,13 +442,13 @@ export function registerMessageTools(server, client) {
|
|
|
451
442
|
modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
|
|
452
443
|
listData: detail,
|
|
453
444
|
};
|
|
454
|
-
upsertDraft(persisted);
|
|
445
|
+
await cache.upsertDraft(persisted);
|
|
455
446
|
// Replace-path: caller passed messageId, so they want the old draft
|
|
456
447
|
// gone. Delete it after the new one is safely created+cached.
|
|
457
448
|
if (args.messageId !== undefined && args.messageId !== newId) {
|
|
458
449
|
try {
|
|
459
450
|
await deleteOFWMessages(client, [args.messageId]);
|
|
460
|
-
deleteDraft(args.messageId);
|
|
451
|
+
await cache.deleteDraft(args.messageId);
|
|
461
452
|
replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.)`;
|
|
462
453
|
}
|
|
463
454
|
catch (e) {
|
|
@@ -479,7 +470,7 @@ export function registerMessageTools(server, client) {
|
|
|
479
470
|
},
|
|
480
471
|
}, async (args) => {
|
|
481
472
|
const data = await deleteOFWMessages(client, [args.messageId]);
|
|
482
|
-
deleteDraft(args.messageId);
|
|
473
|
+
await cacheProvider().deleteDraft(args.messageId);
|
|
483
474
|
return data ? jsonResponse(data) : textResponse('Draft deleted.');
|
|
484
475
|
});
|
|
485
476
|
server.registerTool('ofw_get_unread_sent', {
|
|
@@ -492,7 +483,7 @@ export function registerMessageTools(server, client) {
|
|
|
492
483
|
}, async (args) => {
|
|
493
484
|
const page = args.page ?? 1;
|
|
494
485
|
const size = args.size ?? 50;
|
|
495
|
-
const sent = listMessages({ folder: 'sent', page, size });
|
|
486
|
+
const sent = await cacheProvider().listMessages({ folder: 'sent', page, size });
|
|
496
487
|
if (sent.length === 0) {
|
|
497
488
|
return jsonResponse({ note: 'Sent cache is empty. Call ofw_sync_messages to populate.' });
|
|
498
489
|
}
|
|
@@ -519,16 +510,12 @@ export function registerMessageTools(server, client) {
|
|
|
519
510
|
description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
|
|
520
511
|
},
|
|
521
512
|
}, async (args) => {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
throw new Error(`Not a file: ${abs}`);
|
|
526
|
-
const fileName = basename(abs);
|
|
527
|
-
const mime = mimeFromName(fileName);
|
|
513
|
+
// Resolve the upload source through the injected attachment-I/O boundary
|
|
514
|
+
// (disk read on node; an in-memory source on the hosted connector).
|
|
515
|
+
const { blob, fileName, mimeType: mime, sizeBytes } = await attachmentIO.resolveUpload(args.path);
|
|
528
516
|
// Build the multipart payload matching the OFW web UI's request shape.
|
|
529
517
|
const form = new FormData();
|
|
530
|
-
|
|
531
|
-
form.append('file', await fileBlob(abs, { type: mime }), fileName);
|
|
518
|
+
form.append('file', blob, fileName);
|
|
532
519
|
form.append('source', 'message');
|
|
533
520
|
form.append('description', args.description ?? fileName);
|
|
534
521
|
form.append('label', args.label ?? fileName);
|
|
@@ -538,12 +525,12 @@ export function registerMessageTools(server, client) {
|
|
|
538
525
|
// Cache metadata so subsequent ofw_get_message calls can surface it and
|
|
539
526
|
// ofw_download_attachment can short-circuit. messageId is 0 (the
|
|
540
527
|
// not-yet-linked sentinel) until a message actually references this file.
|
|
541
|
-
upsertAttachmentForMessage({
|
|
528
|
+
await cacheProvider().upsertAttachmentForMessage({
|
|
542
529
|
fileId: meta.fileId,
|
|
543
530
|
fileName: meta.fileName ?? fileName,
|
|
544
531
|
label: meta.label ?? args.label ?? fileName,
|
|
545
532
|
mimeType: meta.fileType ?? mime,
|
|
546
|
-
sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes :
|
|
533
|
+
sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : sizeBytes,
|
|
547
534
|
metadata: meta,
|
|
548
535
|
messageId: 0,
|
|
549
536
|
});
|
|
@@ -551,7 +538,7 @@ export function registerMessageTools(server, client) {
|
|
|
551
538
|
fileId: meta.fileId,
|
|
552
539
|
fileName: meta.fileName ?? fileName,
|
|
553
540
|
mimeType: meta.fileType ?? mime,
|
|
554
|
-
sizeBytes: meta.sizeInBytes ??
|
|
541
|
+
sizeBytes: meta.sizeInBytes ?? sizeBytes,
|
|
555
542
|
shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
|
|
556
543
|
note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
|
|
557
544
|
});
|
|
@@ -567,13 +554,14 @@ export function registerMessageTools(server, client) {
|
|
|
567
554
|
},
|
|
568
555
|
}, async (args) => {
|
|
569
556
|
const fileId = args.fileId;
|
|
557
|
+
const cache = cacheProvider();
|
|
570
558
|
const inline = args.inline ?? getDefaultInlineAttachments();
|
|
571
|
-
let cached = getAttachment(fileId);
|
|
559
|
+
let cached = await cache.getAttachment(fileId);
|
|
572
560
|
if (!cached) {
|
|
573
561
|
// Not in cache. Fetch metadata and store under the messageId=0
|
|
574
562
|
// sentinel — gets re-linked if a message later references this file.
|
|
575
|
-
await fetchAttachmentMeta(client, fileId, 0);
|
|
576
|
-
cached = getAttachment(fileId);
|
|
563
|
+
await fetchAttachmentMeta(client, fileId, 0, cache);
|
|
564
|
+
cached = await cache.getAttachment(fileId);
|
|
577
565
|
/* v8 ignore next -- fetchAttachmentMeta persists the row it just fetched; a still-null read here is an unreachable storage failure */
|
|
578
566
|
if (!cached)
|
|
579
567
|
throw new Error(`failed to fetch metadata for fileId ${fileId}`);
|
|
@@ -584,10 +572,7 @@ export function registerMessageTools(server, client) {
|
|
|
584
572
|
let mimeType = cached.mimeType;
|
|
585
573
|
let fileName = cached.fileName;
|
|
586
574
|
if (cached.downloadedPath) {
|
|
587
|
-
|
|
588
|
-
bytes = readFileSync(cached.downloadedPath);
|
|
589
|
-
}
|
|
590
|
-
catch { /* on-disk copy missing; fall through */ }
|
|
575
|
+
bytes = attachmentIO.readDownloaded(cached.downloadedPath);
|
|
591
576
|
}
|
|
592
577
|
if (bytes === null) {
|
|
593
578
|
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
@@ -630,9 +615,8 @@ export function registerMessageTools(server, client) {
|
|
|
630
615
|
});
|
|
631
616
|
}
|
|
632
617
|
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
markAttachmentDownloaded(fileId, dest);
|
|
618
|
+
attachmentIO.writeDownload(dest, response.body);
|
|
619
|
+
await cache.markAttachmentDownloaded(fileId, dest);
|
|
636
620
|
return jsonResponse({
|
|
637
621
|
fileId,
|
|
638
622
|
path: dest,
|
|
@@ -642,19 +626,21 @@ export function registerMessageTools(server, client) {
|
|
|
642
626
|
});
|
|
643
627
|
});
|
|
644
628
|
server.registerTool('ofw_sync_messages', {
|
|
645
|
-
description: 'Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps).',
|
|
629
|
+
description: 'Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. EVERY call re-checks the newest page first, so new messages are picked up promptly even while an old-history backfill is still running; only then does it spend what is left of its budget advancing that backfill. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps). Sync is BOUNDED and RESUMABLE: on hosted deployments a per-call OFW-request budget (env OFW_SYNC_MAX_REQUESTS, or the maxRequests argument) caps how far one call walks; when the budget is hit the response reports done:false with a note — call again with the SAME arguments to resume. done:false means older history is still being backfilled; it does NOT mean recent messages are missing. Local installs are unbounded by default (done is always true).',
|
|
646
630
|
annotations: { readOnlyHint: false },
|
|
647
631
|
inputSchema: {
|
|
648
632
|
folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).describe('Folders to sync (default: all three)').optional(),
|
|
649
633
|
fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.').optional(),
|
|
650
634
|
deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
|
|
635
|
+
maxRequests: z.number().int().min(1).describe('Maximum OFW requests this single call may make before pausing. When hit, the response reports done:false — call again with the same arguments to continue. Omit to use the server default (OFW_SYNC_MAX_REQUESTS, or unbounded on local installs).').optional(),
|
|
651
636
|
},
|
|
652
637
|
}, async (args) => {
|
|
653
638
|
const result = await syncAll(client, {
|
|
654
639
|
folders: args.folders,
|
|
655
640
|
fetchUnreadBodies: args.fetchUnreadBodies,
|
|
656
641
|
deep: args.deep,
|
|
657
|
-
|
|
642
|
+
maxRequests: args.maxRequests ?? getSyncMaxRequests(),
|
|
643
|
+
}, cacheProvider());
|
|
658
644
|
return jsonResponse(result);
|
|
659
645
|
});
|
|
660
646
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"mcpName": "io.github.chrischall/ofw-mcp",
|
|
6
6
|
"description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
"dev": "node --env-file=.env dist/index.js",
|
|
30
30
|
"test": "vitest run",
|
|
31
31
|
"test:coverage": "vitest run --coverage",
|
|
32
|
-
"test:watch": "vitest"
|
|
32
|
+
"test:watch": "vitest",
|
|
33
|
+
"worker:dev": "wrangler dev",
|
|
34
|
+
"worker:deploy": "wrangler deploy",
|
|
35
|
+
"worker:test": "vitest run --config vitest.workers.config.ts"
|
|
33
36
|
},
|
|
34
37
|
"dependencies": {
|
|
35
38
|
"@chrischall/mcp-utils": "^0.13.0",
|
|
@@ -39,10 +42,16 @@
|
|
|
39
42
|
"zod": "^4.4.3"
|
|
40
43
|
},
|
|
41
44
|
"devDependencies": {
|
|
45
|
+
"@chrischall/mcp-connector": "^0.1.0",
|
|
46
|
+
"@cloudflare/vitest-pool-workers": "^0.18.4",
|
|
47
|
+
"@cloudflare/workers-oauth-provider": "^0.0.11",
|
|
48
|
+
"@cloudflare/workers-types": "^5.20260708.1",
|
|
42
49
|
"@types/node": "^26.0.0",
|
|
43
50
|
"@vitest/coverage-v8": "^4.1.7",
|
|
51
|
+
"agents": "^0.17.3",
|
|
44
52
|
"esbuild": "^0.28.0",
|
|
45
53
|
"typescript": "^7.0.2",
|
|
46
|
-
"vitest": "^4.1.7"
|
|
54
|
+
"vitest": "^4.1.7",
|
|
55
|
+
"wrangler": "^4.110.0"
|
|
47
56
|
}
|
|
48
57
|
}
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/ofw-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.6.3",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.6.3",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
package/skills/ofw/SKILL.md
CHANGED
|
@@ -10,6 +10,8 @@ MCP server for OurFamilyWizard — provides read/write access to messages, calen
|
|
|
10
10
|
- **npm:** [npmjs.com/package/ofw-mcp](https://www.npmjs.com/package/ofw-mcp)
|
|
11
11
|
- **Source:** [github.com/chrischall/ofw-mcp](https://github.com/chrischall/ofw-mcp)
|
|
12
12
|
|
|
13
|
+
> These tools are also available via the hosted [claude.ai](https://claude.ai) remote connector (a Cloudflare Worker) — the tool set and behaviour are identical to the local stdio install. See the repo's `docs/DEPLOY-CONNECTOR.md`.
|
|
14
|
+
|
|
13
15
|
## Setup
|
|
14
16
|
|
|
15
17
|
### Option A — Claude Code (direct MCP, no mcporter)
|