ofw-mcp 2.5.0 → 2.6.4
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 +909 -523
- 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/_shared.js +68 -2
- package/dist/tools/attachments.js +66 -0
- package/dist/tools/messages.js +74 -84
- package/package.json +12 -3
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +2 -0
- package/skills/ofw-fpx/references/requests.md +1 -1
- package/dist/cache.js +0 -345
package/dist/tools/messages.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { basename, dirname, extname, join } from 'node:path';
|
|
7
|
-
import { fileBlob } from '@chrischall/mcp-utils';
|
|
8
|
-
import { ApiRecipientSchema, expandPath, hasRealView, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
|
|
3
|
+
import { getAttachmentsDir, getDefaultInlineAttachments, getSyncMaxRequests, getWriteMode } from '../config.js';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import { ApiRecipientSchema, expandPath, hasRealView, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } 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
|
|
11
8
|
// reads (issue #83). Loose: unknown keys pass through into cached listData.
|
|
@@ -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,14 @@ 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
|
-
|
|
114
|
+
const total = await cache.countMessages(filter);
|
|
115
|
+
// Reconcile each row's read state at read time: the cached list flags can be
|
|
116
|
+
// stale (a message read after it was first scraped), so `read` is derived
|
|
117
|
+
// from the record's own `viewedAt`/`fetchedBodyAt` and `listData` is forced
|
|
118
|
+
// to agree — see withReadState.
|
|
119
|
+
const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
|
|
141
120
|
const payload = { messages, total, page, size };
|
|
142
121
|
if (total === 0) {
|
|
143
122
|
payload.note = 'No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.';
|
|
@@ -155,13 +134,14 @@ export function registerMessageTools(server, client) {
|
|
|
155
134
|
},
|
|
156
135
|
}, async (args) => {
|
|
157
136
|
const id = Number(args.messageId);
|
|
137
|
+
const cache = cacheProvider();
|
|
158
138
|
// Draft routing: if this id is in the drafts cache, return a
|
|
159
139
|
// MessageRow-shaped synthesis built from the draft. The drafts table
|
|
160
140
|
// is the source of truth for draft bodies (sync keeps it fresh);
|
|
161
141
|
// the messages-table cache for the same id is stale by construction
|
|
162
142
|
// when ofw_get_message was called on a draft id before sync caught
|
|
163
143
|
// up — see syncDrafts, which also evicts these stale rows.
|
|
164
|
-
const draftRow = getDraft(id);
|
|
144
|
+
const draftRow = await cache.getDraft(id);
|
|
165
145
|
if (draftRow !== null) {
|
|
166
146
|
return jsonResponse({
|
|
167
147
|
id: draftRow.id,
|
|
@@ -181,7 +161,7 @@ export function registerMessageTools(server, client) {
|
|
|
181
161
|
attachments: [],
|
|
182
162
|
});
|
|
183
163
|
}
|
|
184
|
-
const cached = getMessage(id);
|
|
164
|
+
const cached = await cache.getMessage(id);
|
|
185
165
|
if (cached && cached.body !== null) {
|
|
186
166
|
let row = cached;
|
|
187
167
|
// Refresh view status for a sent message we still believe is unviewed:
|
|
@@ -203,13 +183,13 @@ export function registerMessageTools(server, client) {
|
|
|
203
183
|
recipients,
|
|
204
184
|
listData: { ...cached.listData, showNeverViewed: !hasRealView(recipients) },
|
|
205
185
|
};
|
|
206
|
-
upsertMessage(row);
|
|
186
|
+
await cache.upsertMessage(row);
|
|
207
187
|
}
|
|
208
188
|
catch {
|
|
209
189
|
// Best-effort: fall back to the cached row on any fetch/parse error.
|
|
210
190
|
}
|
|
211
191
|
}
|
|
212
|
-
let attachments = listAttachmentsForMessage(id);
|
|
192
|
+
let attachments = await cache.listAttachmentsForMessage(id);
|
|
213
193
|
// Lazy attachment backfill. The list-endpoint payload (stored in
|
|
214
194
|
// listData) hints at attachments via `files: <count>` but doesn't
|
|
215
195
|
// expose the fileIds — those live only on /pub/v3/messages/{id}.
|
|
@@ -221,18 +201,31 @@ export function registerMessageTools(server, client) {
|
|
|
221
201
|
try {
|
|
222
202
|
const detail = parseLenient(DetailFilesSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (attachment backfill)' });
|
|
223
203
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
224
|
-
await fetchAttachmentMetaForMessage(client, id, detail.files);
|
|
225
|
-
attachments = listAttachmentsForMessage(id);
|
|
204
|
+
await fetchAttachmentMetaForMessage(client, id, detail.files, cache);
|
|
205
|
+
attachments = await cache.listAttachmentsForMessage(id);
|
|
226
206
|
}
|
|
227
207
|
}
|
|
228
208
|
catch {
|
|
229
209
|
// Backfill is best-effort. Fall through with whatever we have.
|
|
230
210
|
}
|
|
231
211
|
}
|
|
232
|
-
return jsonResponse({ ...row, attachments });
|
|
212
|
+
return jsonResponse({ ...withReadState(row), attachments });
|
|
233
213
|
}
|
|
234
214
|
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
|
-
|
|
215
|
+
// Derive the folder for a live-fetched message. A cached row (reached here
|
|
216
|
+
// only when its body was NULL) already knows its folder, so keep it.
|
|
217
|
+
// Otherwise use the detail's own folder id, matched against the sent folder
|
|
218
|
+
// id persisted by the last resolveFolderIds — a sent message must not be
|
|
219
|
+
// mislabeled 'inbox' (which would also hide it from ofw_get_unread_sent and
|
|
220
|
+
// a sent-scoped ofw_list_messages). When that mapping isn't known yet (no
|
|
221
|
+
// sync has run in this cache), fall back to 'inbox' as before.
|
|
222
|
+
let folder = cached?.folder ?? 'inbox';
|
|
223
|
+
if (!cached) {
|
|
224
|
+
const sentFolderId = await cache.getMeta('sent_folder_id');
|
|
225
|
+
if (sentFolderId !== null && detail.folder?.id != null && String(detail.folder.id) === sentFolderId) {
|
|
226
|
+
folder = 'sent';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
236
229
|
const row = {
|
|
237
230
|
id: detail.id,
|
|
238
231
|
folder,
|
|
@@ -246,12 +239,12 @@ export function registerMessageTools(server, client) {
|
|
|
246
239
|
chainRootId: cached?.chainRootId ?? null,
|
|
247
240
|
listData: cached?.listData ?? detail,
|
|
248
241
|
};
|
|
249
|
-
upsertMessage(row);
|
|
242
|
+
await cache.upsertMessage(row);
|
|
250
243
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
251
|
-
await fetchAttachmentMetaForMessage(client, detail.id, detail.files);
|
|
244
|
+
await fetchAttachmentMetaForMessage(client, detail.id, detail.files, cache);
|
|
252
245
|
}
|
|
253
|
-
const attachments = listAttachmentsForMessage(detail.id);
|
|
254
|
-
return jsonResponse({ ...row, attachments });
|
|
246
|
+
const attachments = await cache.listAttachmentsForMessage(detail.id);
|
|
247
|
+
return jsonResponse({ ...withReadState(row), attachments });
|
|
255
248
|
});
|
|
256
249
|
if (allowSend)
|
|
257
250
|
server.registerTool('ofw_send_message', {
|
|
@@ -271,6 +264,7 @@ export function registerMessageTools(server, client) {
|
|
|
271
264
|
throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
|
|
272
265
|
}
|
|
273
266
|
const draftRef = args.messageId ?? args.draftId;
|
|
267
|
+
const cache = cacheProvider();
|
|
274
268
|
// Best-effort draft lookup: when draftRef points at a cached draft, use
|
|
275
269
|
// its stored fields (including replyToId) as defaults for anything the
|
|
276
270
|
// caller didn't supply. The "missing draft" case only matters when we
|
|
@@ -284,7 +278,7 @@ export function registerMessageTools(server, client) {
|
|
|
284
278
|
let draftFound = false;
|
|
285
279
|
if (draftRef !== undefined) {
|
|
286
280
|
draftLookupAttempted = true;
|
|
287
|
-
const draft = getDraft(draftRef);
|
|
281
|
+
const draft = await cache.getDraft(draftRef);
|
|
288
282
|
if (draft !== null) {
|
|
289
283
|
draftFound = true;
|
|
290
284
|
subject = subject ?? draft.subject;
|
|
@@ -312,11 +306,11 @@ export function registerMessageTools(server, client) {
|
|
|
312
306
|
let chainRootId = null;
|
|
313
307
|
let rewriteNote = null;
|
|
314
308
|
if (requestedReplyTo !== null) {
|
|
315
|
-
resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
|
|
309
|
+
resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
|
|
316
310
|
if (resolvedReplyTo !== requestedReplyTo) {
|
|
317
311
|
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
318
312
|
}
|
|
319
|
-
const parent = getMessage(resolvedReplyTo);
|
|
313
|
+
const parent = await cache.getMessage(resolvedReplyTo);
|
|
320
314
|
chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
|
|
321
315
|
}
|
|
322
316
|
const myFileIDs = args.myFileIDs ?? [];
|
|
@@ -346,13 +340,13 @@ export function registerMessageTools(server, client) {
|
|
|
346
340
|
chainRootId,
|
|
347
341
|
listData: detail,
|
|
348
342
|
};
|
|
349
|
-
upsertMessage(persisted);
|
|
343
|
+
await cache.upsertMessage(persisted);
|
|
350
344
|
// Link attached files to the new message in the attachments cache.
|
|
351
345
|
// We may not have full metadata if the upload happened in a prior
|
|
352
346
|
// session — fall back to what we know.
|
|
353
347
|
for (const fileId of myFileIDs) {
|
|
354
|
-
const existing = getAttachment(fileId);
|
|
355
|
-
upsertAttachmentForMessage({
|
|
348
|
+
const existing = await cache.getAttachment(fileId);
|
|
349
|
+
await cache.upsertAttachmentForMessage({
|
|
356
350
|
fileId,
|
|
357
351
|
fileName: existing?.fileName ?? `file-${fileId}`,
|
|
358
352
|
label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
|
|
@@ -375,7 +369,7 @@ export function registerMessageTools(server, client) {
|
|
|
375
369
|
}
|
|
376
370
|
else if (draftRef !== undefined) {
|
|
377
371
|
await deleteOFWMessages(client, [draftRef]);
|
|
378
|
-
deleteDraft(draftRef);
|
|
372
|
+
await cache.deleteDraft(draftRef);
|
|
379
373
|
}
|
|
380
374
|
const responseObj = persisted ?? raw;
|
|
381
375
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
|
|
@@ -392,7 +386,7 @@ export function registerMessageTools(server, client) {
|
|
|
392
386
|
}, async (args) => {
|
|
393
387
|
const page = args.page ?? 1;
|
|
394
388
|
const size = args.size ?? 50;
|
|
395
|
-
const drafts = listDrafts({ page, size });
|
|
389
|
+
const drafts = await cacheProvider().listDrafts({ page, size });
|
|
396
390
|
const payload = drafts.length === 0
|
|
397
391
|
? { drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' }
|
|
398
392
|
: { drafts };
|
|
@@ -411,11 +405,12 @@ export function registerMessageTools(server, client) {
|
|
|
411
405
|
myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
|
|
412
406
|
},
|
|
413
407
|
}, async (args) => {
|
|
408
|
+
const cache = cacheProvider();
|
|
414
409
|
const requestedReplyTo = args.replyToId ?? null;
|
|
415
410
|
let resolvedReplyTo = requestedReplyTo;
|
|
416
411
|
let rewriteNote = null;
|
|
417
412
|
if (requestedReplyTo !== null) {
|
|
418
|
-
resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
|
|
413
|
+
resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
|
|
419
414
|
if (resolvedReplyTo !== requestedReplyTo) {
|
|
420
415
|
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
421
416
|
}
|
|
@@ -451,13 +446,13 @@ export function registerMessageTools(server, client) {
|
|
|
451
446
|
modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
|
|
452
447
|
listData: detail,
|
|
453
448
|
};
|
|
454
|
-
upsertDraft(persisted);
|
|
449
|
+
await cache.upsertDraft(persisted);
|
|
455
450
|
// Replace-path: caller passed messageId, so they want the old draft
|
|
456
451
|
// gone. Delete it after the new one is safely created+cached.
|
|
457
452
|
if (args.messageId !== undefined && args.messageId !== newId) {
|
|
458
453
|
try {
|
|
459
454
|
await deleteOFWMessages(client, [args.messageId]);
|
|
460
|
-
deleteDraft(args.messageId);
|
|
455
|
+
await cache.deleteDraft(args.messageId);
|
|
461
456
|
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
457
|
}
|
|
463
458
|
catch (e) {
|
|
@@ -479,7 +474,7 @@ export function registerMessageTools(server, client) {
|
|
|
479
474
|
},
|
|
480
475
|
}, async (args) => {
|
|
481
476
|
const data = await deleteOFWMessages(client, [args.messageId]);
|
|
482
|
-
deleteDraft(args.messageId);
|
|
477
|
+
await cacheProvider().deleteDraft(args.messageId);
|
|
483
478
|
return data ? jsonResponse(data) : textResponse('Draft deleted.');
|
|
484
479
|
});
|
|
485
480
|
server.registerTool('ofw_get_unread_sent', {
|
|
@@ -492,7 +487,7 @@ export function registerMessageTools(server, client) {
|
|
|
492
487
|
}, async (args) => {
|
|
493
488
|
const page = args.page ?? 1;
|
|
494
489
|
const size = args.size ?? 50;
|
|
495
|
-
const sent = listMessages({ folder: 'sent', page, size });
|
|
490
|
+
const sent = await cacheProvider().listMessages({ folder: 'sent', page, size });
|
|
496
491
|
if (sent.length === 0) {
|
|
497
492
|
return jsonResponse({ note: 'Sent cache is empty. Call ofw_sync_messages to populate.' });
|
|
498
493
|
}
|
|
@@ -519,16 +514,12 @@ export function registerMessageTools(server, client) {
|
|
|
519
514
|
description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
|
|
520
515
|
},
|
|
521
516
|
}, async (args) => {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
throw new Error(`Not a file: ${abs}`);
|
|
526
|
-
const fileName = basename(abs);
|
|
527
|
-
const mime = mimeFromName(fileName);
|
|
517
|
+
// Resolve the upload source through the injected attachment-I/O boundary
|
|
518
|
+
// (disk read on node; an in-memory source on the hosted connector).
|
|
519
|
+
const { blob, fileName, mimeType: mime, sizeBytes } = await attachmentIO.resolveUpload(args.path);
|
|
528
520
|
// Build the multipart payload matching the OFW web UI's request shape.
|
|
529
521
|
const form = new FormData();
|
|
530
|
-
|
|
531
|
-
form.append('file', await fileBlob(abs, { type: mime }), fileName);
|
|
522
|
+
form.append('file', blob, fileName);
|
|
532
523
|
form.append('source', 'message');
|
|
533
524
|
form.append('description', args.description ?? fileName);
|
|
534
525
|
form.append('label', args.label ?? fileName);
|
|
@@ -538,12 +529,12 @@ export function registerMessageTools(server, client) {
|
|
|
538
529
|
// Cache metadata so subsequent ofw_get_message calls can surface it and
|
|
539
530
|
// ofw_download_attachment can short-circuit. messageId is 0 (the
|
|
540
531
|
// not-yet-linked sentinel) until a message actually references this file.
|
|
541
|
-
upsertAttachmentForMessage({
|
|
532
|
+
await cacheProvider().upsertAttachmentForMessage({
|
|
542
533
|
fileId: meta.fileId,
|
|
543
534
|
fileName: meta.fileName ?? fileName,
|
|
544
535
|
label: meta.label ?? args.label ?? fileName,
|
|
545
536
|
mimeType: meta.fileType ?? mime,
|
|
546
|
-
sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes :
|
|
537
|
+
sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : sizeBytes,
|
|
547
538
|
metadata: meta,
|
|
548
539
|
messageId: 0,
|
|
549
540
|
});
|
|
@@ -551,7 +542,7 @@ export function registerMessageTools(server, client) {
|
|
|
551
542
|
fileId: meta.fileId,
|
|
552
543
|
fileName: meta.fileName ?? fileName,
|
|
553
544
|
mimeType: meta.fileType ?? mime,
|
|
554
|
-
sizeBytes: meta.sizeInBytes ??
|
|
545
|
+
sizeBytes: meta.sizeInBytes ?? sizeBytes,
|
|
555
546
|
shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
|
|
556
547
|
note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
|
|
557
548
|
});
|
|
@@ -567,13 +558,14 @@ export function registerMessageTools(server, client) {
|
|
|
567
558
|
},
|
|
568
559
|
}, async (args) => {
|
|
569
560
|
const fileId = args.fileId;
|
|
561
|
+
const cache = cacheProvider();
|
|
570
562
|
const inline = args.inline ?? getDefaultInlineAttachments();
|
|
571
|
-
let cached = getAttachment(fileId);
|
|
563
|
+
let cached = await cache.getAttachment(fileId);
|
|
572
564
|
if (!cached) {
|
|
573
565
|
// Not in cache. Fetch metadata and store under the messageId=0
|
|
574
566
|
// sentinel — gets re-linked if a message later references this file.
|
|
575
|
-
await fetchAttachmentMeta(client, fileId, 0);
|
|
576
|
-
cached = getAttachment(fileId);
|
|
567
|
+
await fetchAttachmentMeta(client, fileId, 0, cache);
|
|
568
|
+
cached = await cache.getAttachment(fileId);
|
|
577
569
|
/* v8 ignore next -- fetchAttachmentMeta persists the row it just fetched; a still-null read here is an unreachable storage failure */
|
|
578
570
|
if (!cached)
|
|
579
571
|
throw new Error(`failed to fetch metadata for fileId ${fileId}`);
|
|
@@ -584,10 +576,7 @@ export function registerMessageTools(server, client) {
|
|
|
584
576
|
let mimeType = cached.mimeType;
|
|
585
577
|
let fileName = cached.fileName;
|
|
586
578
|
if (cached.downloadedPath) {
|
|
587
|
-
|
|
588
|
-
bytes = readFileSync(cached.downloadedPath);
|
|
589
|
-
}
|
|
590
|
-
catch { /* on-disk copy missing; fall through */ }
|
|
579
|
+
bytes = attachmentIO.readDownloaded(cached.downloadedPath);
|
|
591
580
|
}
|
|
592
581
|
if (bytes === null) {
|
|
593
582
|
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
@@ -630,9 +619,8 @@ export function registerMessageTools(server, client) {
|
|
|
630
619
|
});
|
|
631
620
|
}
|
|
632
621
|
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
markAttachmentDownloaded(fileId, dest);
|
|
622
|
+
attachmentIO.writeDownload(dest, response.body);
|
|
623
|
+
await cache.markAttachmentDownloaded(fileId, dest);
|
|
636
624
|
return jsonResponse({
|
|
637
625
|
fileId,
|
|
638
626
|
path: dest,
|
|
@@ -642,19 +630,21 @@ export function registerMessageTools(server, client) {
|
|
|
642
630
|
});
|
|
643
631
|
});
|
|
644
632
|
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).',
|
|
633
|
+
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
634
|
annotations: { readOnlyHint: false },
|
|
647
635
|
inputSchema: {
|
|
648
636
|
folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).describe('Folders to sync (default: all three)').optional(),
|
|
649
637
|
fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.').optional(),
|
|
650
638
|
deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
|
|
639
|
+
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
640
|
},
|
|
652
641
|
}, async (args) => {
|
|
653
642
|
const result = await syncAll(client, {
|
|
654
643
|
folders: args.folders,
|
|
655
644
|
fetchUnreadBodies: args.fetchUnreadBodies,
|
|
656
645
|
deep: args.deep,
|
|
657
|
-
|
|
646
|
+
maxRequests: args.maxRequests ?? getSyncMaxRequests(),
|
|
647
|
+
}, cacheProvider());
|
|
658
648
|
return jsonResponse(result);
|
|
659
649
|
});
|
|
660
650
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.4",
|
|
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.4",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.6.4",
|
|
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)
|
|
@@ -55,7 +55,7 @@ read on OFW):
|
|
|
55
55
|
|
|
56
56
|
```sh
|
|
57
57
|
curl -s "https://ofw.ourfamilywizard.com/pub/v3/messages/${ID}" "${AUTH_HEADERS[@]}" \
|
|
58
|
-
| jq '{id, subject, body, sentAt: .date.dateTime, from: .from.name, files, recipients: [.recipients[] | {id: .user.
|
|
58
|
+
| jq '{id, subject, body, sentAt: .date.dateTime, from: .from.name, files, recipients: [.recipients[] | {id: .user.userId, name: .user.name, viewedAt: .viewed.dateTime}]}'
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
## 3. Send a message / save a draft (write — confirm-by-re-GET)
|