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/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.6.6', // x-release-please-version
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
- resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
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
- await store.setSyncState(folder, {
308
- lastSyncAt: new Date().toISOString(),
309
- newestId,
310
- resumePage,
311
- });
312
- return { synced, unread, done };
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 { synced: 0, done: false };
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 { synced: 0, done: false };
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 folders = opts.folders ?? ['inbox', 'sent', 'drafts'];
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
- synced.inbox = r.synced;
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
- synced.sent = r.synced;
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
- synced.drafts = r.synced;
433
- if (!r.done)
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 { synced, unreadInbox, done, ...(note ? { note } : {}) };
575
+ return {
576
+ synced,
577
+ unreadInbox,
578
+ done,
579
+ syncComplete: done,
580
+ refreshed,
581
+ notRefreshed,
582
+ ...(note ? { note } : {}),
583
+ };
446
584
  }
@@ -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. When we know our own id
78
- * (`selfUserId`), that recipient's `viewedAt` is authoritative; otherwise any
79
- * recipient's `viewedAt` stands in (1:1 co-parent messaging). Fetching the
80
- * body marks the message read on OFW, so a non-null `fetchedBodyAt` is also
81
- * read=true. The stale scrape flag is only a last-resort fallback.
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, selfUserId) {
107
+ export function deriveRead(row) {
108
+ const viewedByAnyone = row.recipients.some((r) => r.viewedAt !== null);
87
109
  if (row.folder === 'inbox') {
88
- const viewed = selfUserId !== undefined
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 row.recipients.some((r) => r.viewedAt !== null) || scrapeSaysRead(row.listData);
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, selfUserId) {
103
- const read = deriveRead(row, selfUserId);
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
+ }