ofw-mcp 2.6.6 → 2.7.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -1
- package/dist/bundle.js +720 -127
- package/dist/config.js +25 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +161 -23
- package/dist/tools/_shared.js +32 -13
- package/dist/tools/attachments.js +61 -0
- package/dist/tools/draft-freshness.js +166 -0
- package/dist/tools/freshness.js +147 -0
- package/dist/tools/messages.js +410 -40
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +16 -1
package/dist/config.js
CHANGED
|
@@ -110,3 +110,28 @@ export function getSyncMaxRequests() {
|
|
|
110
110
|
return Number.POSITIVE_INFINITY;
|
|
111
111
|
return n;
|
|
112
112
|
}
|
|
113
|
+
/** Default for getFreshnessTtlSeconds() when OFW_FRESHNESS_TTL_SECONDS is unset. */
|
|
114
|
+
export const DEFAULT_FRESHNESS_TTL_SECONDS = 300;
|
|
115
|
+
/**
|
|
116
|
+
* How long a folder's verified-against-OFW state stays labelled `fresh`.
|
|
117
|
+
*
|
|
118
|
+
* Read tools serve from the local cache, so every read result carries a
|
|
119
|
+
* `freshness` block saying when the data was last actually compared against
|
|
120
|
+
* OFW. Past this age the block downgrades to `unverified` and grows a warning,
|
|
121
|
+
* because a co-parent can send a message — or edit a draft in the OFW web app,
|
|
122
|
+
* which bumps no timestamp at all — at any moment without us hearing about it.
|
|
123
|
+
*
|
|
124
|
+
* Set OFW_FRESHNESS_TTL_SECONDS to a positive integer to tune it. Anything
|
|
125
|
+
* else (unset / blank / zero / negative / non-integer) falls back to the
|
|
126
|
+
* default: a bad value must not silently widen the window in which stale data
|
|
127
|
+
* is presented as current.
|
|
128
|
+
*/
|
|
129
|
+
export function getFreshnessTtlSeconds() {
|
|
130
|
+
const raw = readEnvVar('OFW_FRESHNESS_TTL_SECONDS');
|
|
131
|
+
if (raw === undefined)
|
|
132
|
+
return DEFAULT_FRESHNESS_TTL_SECONDS;
|
|
133
|
+
const n = Number(raw);
|
|
134
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
135
|
+
return DEFAULT_FRESHNESS_TTL_SECONDS;
|
|
136
|
+
return n;
|
|
137
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -35,7 +35,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
|
|
|
35
35
|
// always succeeds before any credential check runs.
|
|
36
36
|
await runMcp({
|
|
37
37
|
name: 'ofw',
|
|
38
|
-
version: '2.
|
|
38
|
+
version: '2.7.0', // x-release-please-version
|
|
39
39
|
deps: client,
|
|
40
40
|
tools: [
|
|
41
41
|
registerUserTools,
|
package/dist/sync.js
CHANGED
|
@@ -126,17 +126,19 @@ async function walkPages(client, folder, folderId, opts, store) {
|
|
|
126
126
|
let page = opts.startPage;
|
|
127
127
|
let newestId = null;
|
|
128
128
|
let synced = 0;
|
|
129
|
+
let pagesFetched = 0;
|
|
129
130
|
const unread = [];
|
|
130
131
|
while (true) {
|
|
131
132
|
// One unit per list-page fetch. Out of budget → pause and resume at `page`.
|
|
132
133
|
if (!budget.take()) {
|
|
133
|
-
return { synced, unread, newestId, done: false, nextPage: page };
|
|
134
|
+
return { synced, unread, newestId, pagesFetched, done: false, nextPage: page };
|
|
134
135
|
}
|
|
135
136
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
136
137
|
const list = parseLenient(ListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: `GET /pub/v3/messages?folders={${folder}}` });
|
|
138
|
+
pagesFetched++;
|
|
137
139
|
const items = list.data ?? [];
|
|
138
140
|
if (items.length === 0) {
|
|
139
|
-
return { synced, unread, newestId, done: true, nextPage: null };
|
|
141
|
+
return { synced, unread, newestId, pagesFetched, done: true, nextPage: null };
|
|
140
142
|
}
|
|
141
143
|
// One batch read of this page's ids (S1) instead of a per-item getMessage.
|
|
142
144
|
const existingById = new Map((await store.getMessages(items.map((it) => it.id))).map((row) => [row.id, row]));
|
|
@@ -225,14 +227,14 @@ async function walkPages(client, folder, folderId, opts, store) {
|
|
|
225
227
|
if (pageBudgetHit) {
|
|
226
228
|
// Paused mid-page. Resume at THIS page: the partial rows are cached, so
|
|
227
229
|
// getMessages skips them next time and upserts are idempotent.
|
|
228
|
-
return { synced, unread, newestId, done: false, nextPage: page };
|
|
230
|
+
return { synced, unread, newestId, pagesFetched, done: false, nextPage: page };
|
|
229
231
|
}
|
|
230
232
|
// Reached cached history (see `stopAtCachedPage`). Report THIS page as the
|
|
231
233
|
// resume point rather than the next one: it costs one redundant (cheap,
|
|
232
234
|
// all-cached) fetch if a backfill later starts here, and it cannot skip a
|
|
233
235
|
// message the way an off-by-one `page + 1` could.
|
|
234
236
|
if (opts.stopAtCachedPage && !pageHadNewItem) {
|
|
235
|
-
return { synced, unread, newestId, done: true, nextPage: page };
|
|
237
|
+
return { synced, unread, newestId, pagesFetched, done: true, nextPage: page };
|
|
236
238
|
}
|
|
237
239
|
page++;
|
|
238
240
|
}
|
|
@@ -273,12 +275,29 @@ export async function syncMessageFolder(client, folder, folderId, opts, store) {
|
|
|
273
275
|
let done;
|
|
274
276
|
let resumePage;
|
|
275
277
|
if (!fwd.done) {
|
|
276
|
-
// The forward pass itself ran out of budget, so it never reached cached
|
|
277
|
-
// history — everything from `fwd.nextPage` down is unverified. Park the
|
|
278
|
-
// backfill at whichever cursor is higher up the folder, so no page that
|
|
279
|
-
// still owes us messages ends up above the resume point.
|
|
280
278
|
done = false;
|
|
281
|
-
|
|
279
|
+
if (fwd.pagesFetched === 0) {
|
|
280
|
+
// The budget was already spent when the forward pass started, so it
|
|
281
|
+
// fetched nothing and observed NOTHING about this folder. Leave the
|
|
282
|
+
// parked cursor exactly as it was: moving it on zero information is a
|
|
283
|
+
// pure loss.
|
|
284
|
+
//
|
|
285
|
+
// This was a real starvation bug. `fwd.nextPage` is just the start page
|
|
286
|
+
// (1) when nothing was fetched, so the `Math.min` below would silently
|
|
287
|
+
// reset a deep backfill — e.g. resumePage 87 → 1 — discarding 86 pages
|
|
288
|
+
// of progress. On the hosted Worker (OFW_SYNC_MAX_REQUESTS=40) a user
|
|
289
|
+
// with enough drafts to consume the whole budget, with drafts running
|
|
290
|
+
// first, hit this on EVERY call: inbox/sent never got budget, their
|
|
291
|
+
// cursor was reset every time, and the backfill could never advance.
|
|
292
|
+
resumePage = savedResume;
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
// The forward pass did look, and ran out before reaching cached history
|
|
296
|
+
// — everything from `fwd.nextPage` down is unverified. Park the backfill
|
|
297
|
+
// at whichever cursor is higher up the folder, so no page that still
|
|
298
|
+
// owes us messages ends up above the resume point.
|
|
299
|
+
resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
|
|
300
|
+
}
|
|
282
301
|
}
|
|
283
302
|
else if (fwd.nextPage === null) {
|
|
284
303
|
// The forward pass walked clean off the end of the folder — by definition
|
|
@@ -304,12 +323,16 @@ export async function syncMessageFolder(client, folder, folderId, opts, store) {
|
|
|
304
323
|
done = bf.done;
|
|
305
324
|
resumePage = bf.done ? null : bf.nextPage;
|
|
306
325
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
326
|
+
const now = new Date().toISOString();
|
|
327
|
+
await store.setSyncState(folder, { lastSyncAt: now, newestId, resumePage });
|
|
328
|
+
// The FORWARD pass is what proves our picture of the present is current: it
|
|
329
|
+
// always starts at page 1 and stops only once it reaches cached history. Its
|
|
330
|
+
// completion — not the backfill's — is what makes reads `fresh`. Same `now`
|
|
331
|
+
// as lastSyncAt so a verified folder never looks a millisecond behind its own
|
|
332
|
+
// sync (buildFreshness downgrades when lastSyncAt runs ahead of verifiedAt).
|
|
333
|
+
if (fwd.done)
|
|
334
|
+
await markFolderVerified(store, folder, now);
|
|
335
|
+
return { synced, unread, done, verified: fwd.done };
|
|
313
336
|
}
|
|
314
337
|
const DraftListItemSchema = z.looseObject({
|
|
315
338
|
id: z.number(),
|
|
@@ -323,9 +346,69 @@ const DraftDetailSchema = z.looseObject({
|
|
|
323
346
|
body: z.string().optional(),
|
|
324
347
|
subject: z.string().optional(),
|
|
325
348
|
});
|
|
349
|
+
/**
|
|
350
|
+
* Meta key holding whether the drafts cache has been compared against OFW.
|
|
351
|
+
* `'fresh'` only after a COMPLETE drafts walk; `'unverified'` whenever a walk
|
|
352
|
+
* was deferred for budget. Read by ofw_list_drafts / ofw_get_message to stamp
|
|
353
|
+
* each draft's `cacheStatus`, and by the destructive draft tools to decide how
|
|
354
|
+
* loudly to warn. Absent (never synced) reads as unverified.
|
|
355
|
+
*/
|
|
356
|
+
export const DRAFTS_CACHE_STATUS_KEY = 'drafts_cache_status';
|
|
357
|
+
export async function getDraftsCacheStatus(store) {
|
|
358
|
+
return (await store.getMeta(DRAFTS_CACHE_STATUS_KEY)) === 'fresh' ? 'fresh' : 'unverified';
|
|
359
|
+
}
|
|
360
|
+
export async function setDraftsCacheStatus(store, status) {
|
|
361
|
+
await store.setMeta(DRAFTS_CACHE_STATUS_KEY, status);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Meta key holding when a folder was last actually COMPARED against OFW.
|
|
365
|
+
*
|
|
366
|
+
* Deliberately distinct from `sync_state.last_sync_at`, which is written on
|
|
367
|
+
* every call including one that paused mid-walk — so `last_sync_at` alone
|
|
368
|
+
* cannot mean "this folder is current", only "we tried". This key advances
|
|
369
|
+
* only when the folder was verified:
|
|
370
|
+
*
|
|
371
|
+
* inbox/sent the FORWARD pass completed, i.e. it walked from page 1 down to
|
|
372
|
+
* cached history (or off the end of the folder). That is exactly
|
|
373
|
+
* the pass that proves no new message is missing. A parked
|
|
374
|
+
* BACKFILL does not hold it back: incomplete old history says
|
|
375
|
+
* nothing about whether our picture of the present is current,
|
|
376
|
+
* and letting it downgrade every read would make the whole
|
|
377
|
+
* freshness signal noise during a long backfill.
|
|
378
|
+
* drafts the full walk + reconciliation ran (the same moment
|
|
379
|
+
* DRAFTS_CACHE_STATUS_KEY goes 'fresh').
|
|
380
|
+
*
|
|
381
|
+
* Absent = never verified, which reads as `stale`, not `fresh`.
|
|
382
|
+
*/
|
|
383
|
+
export function folderVerifiedAtKey(folder) {
|
|
384
|
+
return `folder_verified_at:${folder}`;
|
|
385
|
+
}
|
|
386
|
+
export async function getFolderVerifiedAt(store, folder) {
|
|
387
|
+
return (await store.getMeta(folderVerifiedAtKey(folder))) ?? null;
|
|
388
|
+
}
|
|
389
|
+
export async function markFolderVerified(store, folder, at = new Date().toISOString()) {
|
|
390
|
+
await store.setMeta(folderVerifiedAtKey(folder), at);
|
|
391
|
+
}
|
|
326
392
|
export async function syncDrafts(client, draftsFolderId, store, budget) {
|
|
327
393
|
// No budget → unbounded (local stdio): identical to the original walk.
|
|
328
394
|
const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
|
|
395
|
+
// Deferring means we never compared the drafts cache to OFW on this call.
|
|
396
|
+
// Mark it unverified so reads can say so and the destructive draft tools
|
|
397
|
+
// know the cache is not a trustworthy base — the count we return here is
|
|
398
|
+
// "nothing applied", NOT "nothing changed on the server".
|
|
399
|
+
const defer = async () => {
|
|
400
|
+
await setDraftsCacheStatus(store, 'unverified');
|
|
401
|
+
// Record the ATTEMPT but not a verification: buildFreshness compares the
|
|
402
|
+
// two and downgrades when a sync ran without verifying this folder, so a
|
|
403
|
+
// deferred walk actively marks reads unverified rather than leaving them
|
|
404
|
+
// coasting on an older stamp.
|
|
405
|
+
await store.setSyncState('drafts', {
|
|
406
|
+
lastSyncAt: new Date().toISOString(),
|
|
407
|
+
newestId: null,
|
|
408
|
+
resumePage: null,
|
|
409
|
+
});
|
|
410
|
+
return { synced: 0, done: false };
|
|
411
|
+
};
|
|
329
412
|
// The reconciliation step below DELETES any cached draft not seen in the
|
|
330
413
|
// listing, so a partial walk must apply NOTHING. We therefore buffer the
|
|
331
414
|
// entire walk (all list pages + every detail) BEFORE touching the cache: if
|
|
@@ -337,7 +420,7 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
|
|
|
337
420
|
let page = 1;
|
|
338
421
|
while (true) {
|
|
339
422
|
if (!b.take())
|
|
340
|
-
return
|
|
423
|
+
return defer();
|
|
341
424
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
342
425
|
const list = parseLenient(DraftListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: 'GET /pub/v3/messages?folders={drafts}' });
|
|
343
426
|
const pageItems = list.data ?? [];
|
|
@@ -352,7 +435,7 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
|
|
|
352
435
|
const rows = [];
|
|
353
436
|
for (const item of items) {
|
|
354
437
|
if (!b.take())
|
|
355
|
-
return
|
|
438
|
+
return defer();
|
|
356
439
|
const detail = parseLenient(DraftDetailSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (drafts sync)' });
|
|
357
440
|
rows.push({
|
|
358
441
|
id: item.id,
|
|
@@ -390,10 +473,29 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
|
|
|
390
473
|
if (!seenIds.has(id))
|
|
391
474
|
await store.deleteDraft(id);
|
|
392
475
|
}
|
|
476
|
+
// The complete walk fetched every draft's DETAIL and reconciled deletions, so
|
|
477
|
+
// the cache is now known-equal to the server. Only here is `synced: 0`
|
|
478
|
+
// truthful as "verified no changes" — and only here may reads report the
|
|
479
|
+
// drafts as server-confirmed.
|
|
480
|
+
const now = new Date().toISOString();
|
|
481
|
+
await setDraftsCacheStatus(store, 'fresh');
|
|
482
|
+
await store.setSyncState('drafts', { lastSyncAt: now, newestId: null, resumePage: null });
|
|
483
|
+
await markFolderVerified(store, 'drafts', now);
|
|
393
484
|
return { synced, done: true };
|
|
394
485
|
}
|
|
395
486
|
export async function syncAll(client, opts, store) {
|
|
396
|
-
const
|
|
487
|
+
const requested = opts.folders ?? ['inbox', 'sent', 'drafts'];
|
|
488
|
+
// Drafts go FIRST. They are the only folder a destructive tool
|
|
489
|
+
// (ofw_save_draft / ofw_delete_draft) reads as its base, and they are cheap
|
|
490
|
+
// and bounded — one list page plus one detail per draft. Running them last,
|
|
491
|
+
// behind inbox and sent, meant a bounded call (the Worker's
|
|
492
|
+
// OFW_SYNC_MAX_REQUESTS=40) spent its whole budget backfilling history and
|
|
493
|
+
// deferred drafts on every single call, so server-side draft edits stayed
|
|
494
|
+
// invisible indefinitely while the response reported `drafts: 0`.
|
|
495
|
+
const folders = [
|
|
496
|
+
...requested.filter((f) => f === 'drafts'),
|
|
497
|
+
...requested.filter((f) => f !== 'drafts'),
|
|
498
|
+
];
|
|
397
499
|
// ONE budget shared across resolveFolderIds and every requested folder, in
|
|
398
500
|
// order — so the whole invocation stays under the hosting subrequest cap.
|
|
399
501
|
const budget = makeBudget(opts.maxRequests ?? Number.POSITIVE_INFINITY);
|
|
@@ -405,6 +507,22 @@ export async function syncAll(client, opts, store) {
|
|
|
405
507
|
const synced = {};
|
|
406
508
|
let unreadInbox = [];
|
|
407
509
|
let done = true;
|
|
510
|
+
let draftsUnverified = false;
|
|
511
|
+
const refreshed = [];
|
|
512
|
+
const notRefreshed = [];
|
|
513
|
+
// Same rule for every folder: a count is reported, and the folder is listed
|
|
514
|
+
// as refreshed, ONLY when it was actually diffed against OFW. A folder the
|
|
515
|
+
// budget never reached reports no number at all — `inbox: 0` reads as
|
|
516
|
+
// "verified, no new messages", and that lie is the whole bug this guards.
|
|
517
|
+
const record = (folder, verified, count) => {
|
|
518
|
+
if (verified) {
|
|
519
|
+
synced[folder] = count;
|
|
520
|
+
refreshed.push(folder);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
notRefreshed.push(folder);
|
|
524
|
+
}
|
|
525
|
+
};
|
|
408
526
|
for (const folder of folders) {
|
|
409
527
|
if (folder === 'inbox') {
|
|
410
528
|
const r = await syncMessageFolder(client, 'inbox', ids.inbox, {
|
|
@@ -412,7 +530,7 @@ export async function syncAll(client, opts, store) {
|
|
|
412
530
|
deep: opts.deep ?? false,
|
|
413
531
|
budget,
|
|
414
532
|
}, store);
|
|
415
|
-
|
|
533
|
+
record('inbox', r.verified, r.synced);
|
|
416
534
|
unreadInbox = r.unread;
|
|
417
535
|
if (!r.done)
|
|
418
536
|
done = false;
|
|
@@ -423,24 +541,44 @@ export async function syncAll(client, opts, store) {
|
|
|
423
541
|
deep: opts.deep ?? false,
|
|
424
542
|
budget,
|
|
425
543
|
}, store);
|
|
426
|
-
|
|
544
|
+
record('sent', r.verified, r.synced);
|
|
427
545
|
if (!r.done)
|
|
428
546
|
done = false;
|
|
429
547
|
}
|
|
430
548
|
else if (folder === 'drafts') {
|
|
431
549
|
const r = await syncDrafts(client, ids.drafts, store, budget);
|
|
432
|
-
|
|
433
|
-
|
|
550
|
+
// Only report a drafts count when the walk actually compared against
|
|
551
|
+
// OFW. A deferred walk applied nothing, and reporting its `0` as
|
|
552
|
+
// `drafts: 0` reads as "verified, no changes" — the exact lie that let a
|
|
553
|
+
// server-side draft edit be overwritten. Omit the number instead.
|
|
554
|
+
record('drafts', r.done, r.synced);
|
|
555
|
+
if (!r.done) {
|
|
556
|
+
draftsUnverified = true;
|
|
434
557
|
done = false;
|
|
558
|
+
}
|
|
435
559
|
}
|
|
436
560
|
}
|
|
437
561
|
const notes = [];
|
|
562
|
+
if (draftsUnverified) {
|
|
563
|
+
notes.push('The drafts folder was NOT checked against OurFamilyWizard on this call (the request budget ran out first), so no drafts count is reported and the cached drafts are marked "unverified". Cached draft bodies may be behind the server. Call ofw_sync_messages again — or ofw_sync_messages with folders:["drafts"] — before editing or deleting a draft.');
|
|
564
|
+
}
|
|
438
565
|
if (unreadInbox.length > 0) {
|
|
439
566
|
notes.push(`${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them — this will mark them as read on OFW.`);
|
|
440
567
|
}
|
|
568
|
+
if (notRefreshed.length > 0) {
|
|
569
|
+
notes.push(`NOT checked against OurFamilyWizard on this call: ${notRefreshed.join(', ')}. No count is reported for ${notRefreshed.length > 1 ? 'those folders' : 'that folder'} — absence of a count means "not looked at", not "no changes". Cached contents may be behind the server; call ofw_sync_messages again to finish, or ofw_check_freshness for a cheap live confirmation.`);
|
|
570
|
+
}
|
|
441
571
|
if (!done) {
|
|
442
572
|
notes.push('Paused after the request budget to stay within the hosting limit; more pages remain — call ofw_sync_messages again with the same arguments to resume where it left off and continue the backfill.');
|
|
443
573
|
}
|
|
444
574
|
const note = notes.length > 0 ? notes.join('\n\n') : undefined;
|
|
445
|
-
return {
|
|
575
|
+
return {
|
|
576
|
+
synced,
|
|
577
|
+
unreadInbox,
|
|
578
|
+
done,
|
|
579
|
+
syncComplete: done,
|
|
580
|
+
refreshed,
|
|
581
|
+
notRefreshed,
|
|
582
|
+
...(note ? { note } : {}),
|
|
583
|
+
};
|
|
446
584
|
}
|
package/dist/tools/_shared.js
CHANGED
|
@@ -6,6 +6,13 @@ import { parseLenient } from '@chrischall/mcp-utils';
|
|
|
6
6
|
export const jsonResponse = textResult;
|
|
7
7
|
// Raw-string tool result. Wrapper over @chrischall/mcp-utils' `rawTextResult`.
|
|
8
8
|
export const textResponse = rawTextResult;
|
|
9
|
+
// A STRUCTURED failure: the machine-readable payload of `jsonResponse` plus
|
|
10
|
+
// `isError`, so a refusal can carry recovery data (e.g. the server draft body
|
|
11
|
+
// we declined to overwrite) without being mistaken for a successful write.
|
|
12
|
+
// mcp-utils' `errorResult` only carries a string.
|
|
13
|
+
export function jsonErrorResponse(data) {
|
|
14
|
+
return { ...textResult(data), isError: true };
|
|
15
|
+
}
|
|
9
16
|
// OFW API shape for `recipients[]` on message/draft list and detail
|
|
10
17
|
// responses. Used wherever we validate the response of a `/pub/v3/messages*`
|
|
11
18
|
// call. Loose: unknown keys pass through (and survive into cached listData).
|
|
@@ -74,23 +81,35 @@ function scrapeSaysRead(listData) {
|
|
|
74
81
|
* resync (which re-scrapes the list flags) can never flip a read message back
|
|
75
82
|
* to unread:
|
|
76
83
|
*
|
|
77
|
-
* - INBOX: the account holder is the recipient
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
84
|
+
* - INBOX: the account holder is the recipient, so ANY recipient's `viewedAt`
|
|
85
|
+
* counts. OFW co-parent threads are 1:1 — the sole inbox recipient is us —
|
|
86
|
+
* so this is exact, not an approximation. Fetching the body marks the message
|
|
87
|
+
* read on OFW, so a non-null `fetchedBodyAt` is also read=true. The stale
|
|
88
|
+
* scrape flag is only a last-resort fallback.
|
|
82
89
|
* - SENT: "read" means a *recipient* has opened it — tracked via their
|
|
83
90
|
* `viewedAt` (the detail endpoint's real timestamp) — never our own body
|
|
84
91
|
* fetch, which is always set for sent messages.
|
|
92
|
+
*
|
|
93
|
+
* This deliberately does NOT discriminate by the account holder's own userId.
|
|
94
|
+
* An earlier `selfUserId` parameter did, but nothing ever passed it, so the
|
|
95
|
+
* branch was dead in production. Reviving it is not as simple as threading the
|
|
96
|
+
* argument through, for two reasons:
|
|
97
|
+
* 1. No non-mutating endpoint exposes our numeric id. /pub/v2/profiles returns
|
|
98
|
+
* name/address/contact and no id at all; /pub/v1/users/useraccountstatus
|
|
99
|
+
* updates last-seen status as a side effect, and view timestamps are
|
|
100
|
+
* evidentiary in custody matters — not something to touch for a read flag.
|
|
101
|
+
* 2. Rows cached before the `user.userId` parse fix (see ApiRecipientSchema)
|
|
102
|
+
* normalized every recipient to `userId: 0`, so an id match would silently
|
|
103
|
+
* fail on historical data until a full re-sync.
|
|
104
|
+
* If OFW ever adds third-party recipients (lawyer, parenting coordinator), both
|
|
105
|
+
* problems need solving together — a bare parameter would regress to dead code.
|
|
85
106
|
*/
|
|
86
|
-
export function deriveRead(row
|
|
107
|
+
export function deriveRead(row) {
|
|
108
|
+
const viewedByAnyone = row.recipients.some((r) => r.viewedAt !== null);
|
|
87
109
|
if (row.folder === 'inbox') {
|
|
88
|
-
|
|
89
|
-
? row.recipients.some((r) => r.userId === selfUserId && r.viewedAt !== null)
|
|
90
|
-
: row.recipients.some((r) => r.viewedAt !== null);
|
|
91
|
-
return viewed || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
|
|
110
|
+
return viewedByAnyone || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
|
|
92
111
|
}
|
|
93
|
-
return
|
|
112
|
+
return viewedByAnyone || scrapeSaysRead(row.listData);
|
|
94
113
|
}
|
|
95
114
|
/**
|
|
96
115
|
* Return the row augmented with an authoritative top-level `read` boolean and a
|
|
@@ -99,8 +118,8 @@ export function deriveRead(row, selfUserId) {
|
|
|
99
118
|
* carrying `listData.read: false` alongside a populated recipient `viewedAt`).
|
|
100
119
|
* A non-object `listData` (null / legacy string) is passed through untouched.
|
|
101
120
|
*/
|
|
102
|
-
export function withReadState(row
|
|
103
|
-
const read = deriveRead(row
|
|
121
|
+
export function withReadState(row) {
|
|
122
|
+
const read = deriveRead(row);
|
|
104
123
|
const listData = (typeof row.listData === 'object' && row.listData !== null)
|
|
105
124
|
? { ...row.listData, read, showNeverViewed: !read }
|
|
106
125
|
: row.listData;
|
|
@@ -38,8 +38,69 @@ const MIME_BY_EXT = {
|
|
|
38
38
|
export function mimeFromName(name) {
|
|
39
39
|
return MIME_BY_EXT[extname(name).toLowerCase()] ?? 'application/octet-stream';
|
|
40
40
|
}
|
|
41
|
+
const OCTET_STREAM = 'application/octet-stream';
|
|
42
|
+
// The media types a host's inline image renderer accepts. Anything else — even
|
|
43
|
+
// a valid image type like image/heic — must go back as an EmbeddedResource, and
|
|
44
|
+
// a parameter suffix (image/png;charset=UTF-8) is rejected outright, which is
|
|
45
|
+
// exactly the bug this normalization boundary exists to prevent.
|
|
46
|
+
const HOST_RENDERABLE_IMAGE_MIMES = new Set([
|
|
47
|
+
'image/png', 'image/jpeg', 'image/gif', 'image/webp',
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* Strip a MIME type down to its bare `type/subtype`: drop any `;`-delimited
|
|
51
|
+
* parameters (`charset`, `name`, …), lowercase, and trim. An empty/absent value
|
|
52
|
+
* becomes `application/octet-stream`. OFW hands back `image/png;charset=UTF-8`
|
|
53
|
+
* on binary attachments, and a host's image renderer rejects the parameter
|
|
54
|
+
* suffix — so no derived MIME must ever carry one.
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeMimeType(raw) {
|
|
57
|
+
if (!raw)
|
|
58
|
+
return OCTET_STREAM;
|
|
59
|
+
const bare = raw.split(';', 1)[0].trim().toLowerCase();
|
|
60
|
+
return bare || OCTET_STREAM;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Detect a host-renderable image type from the leading bytes (magic numbers).
|
|
64
|
+
* OFW's `Content-Type` is unreliable for binaries (it tacks a text `charset`
|
|
65
|
+
* onto them), so the actual bytes are the authoritative signal. Returns the
|
|
66
|
+
* bare media type, or null when the bytes aren't a PNG/JPEG/GIF/WEBP.
|
|
67
|
+
*/
|
|
68
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
69
|
+
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
|
|
70
|
+
export function sniffImageMime(bytes) {
|
|
71
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(PNG_MAGIC))
|
|
72
|
+
return 'image/png';
|
|
73
|
+
if (bytes.length >= 3 && bytes.subarray(0, 3).equals(JPEG_MAGIC))
|
|
74
|
+
return 'image/jpeg';
|
|
75
|
+
if (bytes.length >= 6 && bytes.toString('ascii', 0, 4) === 'GIF8')
|
|
76
|
+
return 'image/gif';
|
|
77
|
+
if (bytes.length >= 12 &&
|
|
78
|
+
bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') {
|
|
79
|
+
return 'image/webp';
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the MIME type to report for downloaded bytes, in priority order:
|
|
85
|
+
* magic-number sniff (bytes never lie) → parameter-stripped upstream header →
|
|
86
|
+
* filename extension. The result is always bare (never carries a `;` parameter).
|
|
87
|
+
*/
|
|
88
|
+
export function resolveDownloadMime(bytes, headerMime, fileName) {
|
|
89
|
+
const sniffed = sniffImageMime(bytes);
|
|
90
|
+
if (sniffed)
|
|
91
|
+
return sniffed;
|
|
92
|
+
const fromHeader = normalizeMimeType(headerMime);
|
|
93
|
+
if (fromHeader !== OCTET_STREAM)
|
|
94
|
+
return fromHeader;
|
|
95
|
+
return mimeFromName(fileName);
|
|
96
|
+
}
|
|
97
|
+
/** True only for the bare media types a host renders as inline ImageContent. */
|
|
98
|
+
export function isHostRenderableImage(mime) {
|
|
99
|
+
return HOST_RENDERABLE_IMAGE_MIMES.has(mime);
|
|
100
|
+
}
|
|
41
101
|
/** Disk-backed attachment I/O for the stdio/desktop server. */
|
|
42
102
|
export class NodeAttachmentIO {
|
|
103
|
+
supportsDisk = true;
|
|
43
104
|
async resolveUpload(path) {
|
|
44
105
|
const abs = expandPath(path);
|
|
45
106
|
const stat = statSync(abs); // throws if missing
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { parseLenient } from '@chrischall/mcp-utils';
|
|
3
|
+
import { ApiRecipientSchema, mapRecipients } from './_shared.js';
|
|
4
|
+
/** Thrown when the freshness check itself could not be completed. */
|
|
5
|
+
export class DraftFreshnessError extends Error {
|
|
6
|
+
}
|
|
7
|
+
// FNV-1a (64-bit) over a canonical encoding. Not cryptographic — this is a
|
|
8
|
+
// change detector, and it is never the sole guard: an unsupplied token falls
|
|
9
|
+
// back to a full field-by-field comparison against the cached base.
|
|
10
|
+
// BigInt keeps it byte-identical on node and on the Workers runtime.
|
|
11
|
+
const FNV_OFFSET = 0xcbf29ce484222325n;
|
|
12
|
+
const FNV_PRIME = 0x100000001b3n;
|
|
13
|
+
const MASK64 = 0xffffffffffffffffn;
|
|
14
|
+
function fnv1a64(s) {
|
|
15
|
+
let h = FNV_OFFSET;
|
|
16
|
+
for (let i = 0; i < s.length; i++) {
|
|
17
|
+
h = (h ^ BigInt(s.charCodeAt(i))) * FNV_PRIME & MASK64;
|
|
18
|
+
}
|
|
19
|
+
return h.toString(16).padStart(16, '0');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A stable content revision for a draft. Callers get this back from
|
|
23
|
+
* `ofw_list_drafts` / `ofw_get_message` and pass it to `ofw_save_draft` /
|
|
24
|
+
* `ofw_delete_draft` as `expectedRevision`.
|
|
25
|
+
*
|
|
26
|
+
* Recipients reduce to a SORTED set of user ids: their display names and
|
|
27
|
+
* `viewedAt` are presentation detail that differs between a list-sourced and a
|
|
28
|
+
* detail-sourced copy of the same draft, and would otherwise produce a false
|
|
29
|
+
* STALE. Fields are length-prefixed so content cannot shift across a field
|
|
30
|
+
* boundary without changing the hash.
|
|
31
|
+
*/
|
|
32
|
+
export function draftRevision(d) {
|
|
33
|
+
const ids = [...new Set(d.recipients.map((r) => r.userId))].sort((a, b) => a - b);
|
|
34
|
+
const parts = [d.subject, d.body, String(d.replyToId ?? ''), ids.join(',')];
|
|
35
|
+
return `r1:${fnv1a64(parts.map((p) => `${p.length}:${p}`).join('|'))}`;
|
|
36
|
+
}
|
|
37
|
+
const ServerDraftSchema = z.looseObject({
|
|
38
|
+
subject: z.string().optional(),
|
|
39
|
+
body: z.string().optional(),
|
|
40
|
+
replyToId: z.number().nullable().optional(),
|
|
41
|
+
recipients: z.array(ApiRecipientSchema).optional(),
|
|
42
|
+
});
|
|
43
|
+
function isNotFound(e) {
|
|
44
|
+
return e instanceof Error && /OFW API error: 404\b/.test(e.message);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Read a draft's AUTHORITATIVE state straight from OFW, bypassing the cache.
|
|
48
|
+
*
|
|
49
|
+
* Returns `null` when the draft no longer exists (404). Any other failure
|
|
50
|
+
* throws `DraftFreshnessError`: a freshness check that could not run must
|
|
51
|
+
* abort the write, never wave it through — see the callers in messages.ts.
|
|
52
|
+
*/
|
|
53
|
+
export async function fetchServerDraft(client, id) {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = await client.request('GET', `/pub/v3/messages/${id}`);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
if (isNotFound(e))
|
|
60
|
+
return null;
|
|
61
|
+
throw new DraftFreshnessError(`could not read the current state of draft ${id} from OurFamilyWizard: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
// An empty/null body is OFW's other way of saying "no such message". Treat
|
|
64
|
+
// it as MISSING — which still ABORTS the write — rather than letting the
|
|
65
|
+
// strict parse throw an opaque shape error.
|
|
66
|
+
if (raw === null || raw === undefined)
|
|
67
|
+
return null;
|
|
68
|
+
const detail = parseLenient(ServerDraftSchema, raw, {
|
|
69
|
+
label: 'ofw-mcp',
|
|
70
|
+
context: 'GET /pub/v3/messages/{id} (draft freshness check)',
|
|
71
|
+
mode: 'strict',
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
subject: detail.subject ?? '',
|
|
75
|
+
body: detail.body ?? '',
|
|
76
|
+
replyToId: detail.replyToId ?? null,
|
|
77
|
+
recipients: mapRecipients(detail.recipients),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function diffFields(a, b) {
|
|
81
|
+
const changed = [];
|
|
82
|
+
if (a.subject !== b.subject)
|
|
83
|
+
changed.push('subject');
|
|
84
|
+
if (a.body !== b.body)
|
|
85
|
+
changed.push('body');
|
|
86
|
+
if (a.replyToId !== b.replyToId)
|
|
87
|
+
changed.push('replyToId');
|
|
88
|
+
const ids = (d) => [...new Set(d.recipients.map((r) => r.userId))].sort((x, y) => x - y).join(',');
|
|
89
|
+
if (ids(a) !== ids(b))
|
|
90
|
+
changed.push('recipients');
|
|
91
|
+
return changed;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Decide whether it is safe to destroy the server's copy of a draft.
|
|
95
|
+
*
|
|
96
|
+
* Two independent ways to earn FRESH, in priority order:
|
|
97
|
+
*
|
|
98
|
+
* 1. `expectedRevision` matches the live server revision. The caller has named
|
|
99
|
+
* the exact server state it edited from, which is what optimistic
|
|
100
|
+
* concurrency asserts. A stale cached copy alongside a matching token is
|
|
101
|
+
* not evidence of a conflict, so the token wins.
|
|
102
|
+
* 2. No token supplied → the cached base must match the server EXACTLY. This
|
|
103
|
+
* is the safe default: "no token" never means "force".
|
|
104
|
+
*
|
|
105
|
+
* Everything else — server ahead of cache, no cached base to compare, draft
|
|
106
|
+
* gone from the server — refuses.
|
|
107
|
+
*/
|
|
108
|
+
export function checkDraftFreshness(input) {
|
|
109
|
+
const { server, cached, expectedRevision } = input;
|
|
110
|
+
if (server === null) {
|
|
111
|
+
return {
|
|
112
|
+
verdict: 'MISSING',
|
|
113
|
+
reason: 'The draft no longer exists on OurFamilyWizard — it may have been sent or deleted elsewhere.',
|
|
114
|
+
changedFields: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (expectedRevision !== undefined) {
|
|
118
|
+
const actual = draftRevision(server);
|
|
119
|
+
if (expectedRevision === actual) {
|
|
120
|
+
return { verdict: 'FRESH', reason: 'expectedRevision matches the live server draft.', changedFields: [] };
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
verdict: 'STALE',
|
|
124
|
+
reason: `expectedRevision ${expectedRevision} does not match the live server draft (${actual}) — it changed after you read it.`,
|
|
125
|
+
changedFields: cached === null ? [] : diffFields(server, cached),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (cached === null) {
|
|
129
|
+
return {
|
|
130
|
+
verdict: 'STALE',
|
|
131
|
+
reason: 'This draft is not in the local cache, so there is no base to confirm the edit against.',
|
|
132
|
+
changedFields: [],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const changedFields = diffFields(server, cached);
|
|
136
|
+
if (changedFields.length === 0) {
|
|
137
|
+
return { verdict: 'FRESH', reason: 'The cached draft matches the live server draft.', changedFields: [] };
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
verdict: 'STALE',
|
|
141
|
+
reason: `The draft on OurFamilyWizard differs from the cached copy (${changedFields.join(', ')}) — it was edited outside this tool.`,
|
|
142
|
+
changedFields,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Build the structured refusal returned when a destructive draft op is blocked.
|
|
147
|
+
* ALWAYS carries the current server body when there is one, so the content we
|
|
148
|
+
* declined to overwrite is recoverable from the tool result itself.
|
|
149
|
+
*/
|
|
150
|
+
export function staleDraftPayload(input) {
|
|
151
|
+
const { error, draftId, verdict, server, cached } = input;
|
|
152
|
+
return {
|
|
153
|
+
error,
|
|
154
|
+
draftId,
|
|
155
|
+
verdict: verdict.verdict,
|
|
156
|
+
reason: verdict.reason,
|
|
157
|
+
...(verdict.changedFields.length > 0 ? { changedFields: verdict.changedFields } : {}),
|
|
158
|
+
...(server !== null
|
|
159
|
+
? { serverBody: server.body, serverSubject: server.subject, serverRevision: draftRevision(server) }
|
|
160
|
+
: {}),
|
|
161
|
+
...(cached !== null ? { cachedBody: cached.body } : {}),
|
|
162
|
+
recovery: server === null
|
|
163
|
+
? 'The draft is gone from OurFamilyWizard. Nothing was changed. If you still want this content saved, call ofw_save_draft WITHOUT messageId to create a new draft.'
|
|
164
|
+
: 'Nothing was changed. Merge your edit into serverBody above, then retry with expectedRevision set to serverRevision. Pass force:true only if you intend to discard the server copy shown here.',
|
|
165
|
+
};
|
|
166
|
+
}
|