ofw-mcp 2.6.7 → 2.7.1

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.
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage, getDraftsCacheStatus } from '../sync.js';
3
+ import { buildFreshness } from './freshness.js';
3
4
  import { checkDraftFreshness, draftRevision, fetchServerDraft, staleDraftPayload, } from './draft-freshness.js';
5
+ import { getFolderVerifiedAt } from '../sync.js';
6
+ import { isHostRenderableImage, resolveDownloadMime } from './attachments.js';
4
7
  import { getAttachmentsDir, getDefaultInlineAttachments, getSyncMaxRequests, getWriteMode } from '../config.js';
5
8
  import { basename, join } from 'node:path';
6
9
  import { ApiRecipientSchema, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
@@ -25,6 +28,8 @@ const SavedDraftDetailSchema = z.looseObject({
25
28
  date: DateSchema.optional(),
26
29
  replyToId: z.number().nullable().optional(),
27
30
  recipients: z.array(ApiRecipientSchema).optional(),
31
+ // Read to audit whether requested myFileIDs actually attached (Defect 3).
32
+ files: z.array(z.number()).optional(),
28
33
  });
29
34
  // ofw_get_message's uncached detail fetch — lenient: a mismatch warns to
30
35
  // stderr and the existing ?? fallbacks keep the tool serving.
@@ -43,6 +48,33 @@ const MessageDetailSchema = z.looseObject({
43
48
  });
44
49
  // Attachment-backfill detail fetch reads only `files`.
45
50
  const DetailFilesSchema = z.looseObject({ files: z.array(z.number()).optional() });
51
+ // ofw_check_freshness' folder probe. `includeFolderCounts=true` returns a
52
+ // per-folder count, but the field name varies across OFW payload versions —
53
+ // accept the known spellings and degrade to a null serverCount rather than
54
+ // guessing, since a wrong count would manufacture a false out-of-sync verdict.
55
+ const FolderCountsSchema = z.looseObject({
56
+ systemFolders: z.array(z.looseObject({
57
+ id: z.string(),
58
+ folderType: z.string(),
59
+ totalCount: z.number().optional(),
60
+ messageCount: z.number().optional(),
61
+ count: z.number().optional(),
62
+ })).optional(),
63
+ });
64
+ const FOLDER_TYPE = {
65
+ inbox: 'INBOX',
66
+ sent: 'SENT_MESSAGES',
67
+ drafts: 'DRAFTS',
68
+ };
69
+ /**
70
+ * Cap on per-id probes in one ofw_check_freshness call.
71
+ *
72
+ * Each id costs one OFW request, and on the hosted Worker every request counts
73
+ * against the subrequest cap (see OFW_SYNC_MAX_REQUESTS). The check has to stay
74
+ * cheap enough that a caller reaches for it freely — that is the entire point
75
+ * of it existing — so it truncates loudly rather than turning into a sync.
76
+ */
77
+ const MAX_FRESHNESS_IDS = 25;
46
78
  // Upload response — STRICT: fileId is the whole point of the call; caching
47
79
  // or returning an undefined/mistyped fileId produces an unusable attachment.
48
80
  const UploadedFileSchema = z.looseObject({
@@ -67,6 +99,32 @@ function listDataHintsAtFiles(listData) {
67
99
  return ld.files.length > 0;
68
100
  return false;
69
101
  }
102
+ /**
103
+ * Freshness for a drafts read, plus the per-draft `serverConfirmed` flag.
104
+ *
105
+ * `serverConfirmed` answers the question that triggered this whole mechanism:
106
+ * "is this draft actually still sitting unsent on OFW?" It is true ONLY when a
107
+ * completed drafts walk verified the cache against OFW within the freshness
108
+ * window (getFreshnessTtlSeconds, default 300s) — NOT a claim about this exact
109
+ * instant, which no cache can make. Anything less — a deferred walk, an aged
110
+ * stamp, a cache that was never checked — is false, meaning the draft's
111
+ * existence and unsent status are remembered, not known. On a false, a caller
112
+ * must not state either as present-tense fact without calling
113
+ * ofw_check_freshness first.
114
+ */
115
+ async function draftsFreshness(cache) {
116
+ const freshness = await buildFreshness(cache, { source: 'cache', folders: ['drafts'] });
117
+ // Reconcile the two signals so a single response can never contradict
118
+ // itself (the same rule withReadState applies to read flags). The drafts
119
+ // meta key says whether the last walk COMPLETED; freshness additionally
120
+ // knows whether that walk has since aged out or been overtaken by a sync
121
+ // that skipped drafts. Downgrade only — this can turn 'fresh' off, never on.
122
+ const completed = await getDraftsCacheStatus(cache);
123
+ const cacheStatus = completed === 'fresh' && freshness.staleness === 'fresh'
124
+ ? 'fresh'
125
+ : 'unverified';
126
+ return { freshness, serverConfirmed: cacheStatus === 'fresh', cacheStatus };
127
+ }
70
128
  export function registerMessageTools(server, client, cacheProvider, attachmentIO) {
71
129
  // OFW_WRITE_MODE gate (see config.ts). Send lands on the court-visible
72
130
  // record, so it is 'all'-only; draft-level writes (save/delete drafts,
@@ -76,11 +134,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
76
134
  const allowSend = writeMode === 'all';
77
135
  const allowDrafts = writeMode !== 'none';
78
136
  server.registerTool('ofw_list_message_folders', {
79
- description: 'List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.',
137
+ description: 'List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Fetched LIVE from OFW, so the counts are current. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.',
80
138
  annotations: { readOnlyHint: true },
81
139
  }, async () => {
82
140
  const data = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
83
- return jsonResponse(data);
141
+ const freshness = await buildFreshness(cacheProvider(), { source: 'live', folders: [] });
142
+ return jsonResponse({ folders: data, freshness });
84
143
  });
85
144
  server.registerTool('ofw_list_messages', {
86
145
  description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages. Call ofw_sync_messages first if the cache is empty or stale.',
@@ -105,9 +164,16 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
105
164
  else if (folderArg === 'both')
106
165
  folder = undefined;
107
166
  else {
167
+ // Still carries freshness: `messages: []` with no age label is exactly
168
+ // the shape this mechanism exists to eliminate, even when the emptiness
169
+ // is caused by a bad argument rather than an empty cache.
108
170
  return jsonResponse({
109
171
  messages: [],
110
- note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.',
172
+ freshness: await buildFreshness(cacheProvider(), {
173
+ source: 'cache',
174
+ folders: ['inbox', 'sent'],
175
+ }),
176
+ note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache. No lookup was performed — this empty result says nothing about what is in the cache.',
111
177
  });
112
178
  }
113
179
  const cache = cacheProvider();
@@ -118,7 +184,14 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
118
184
  // from the record's own `viewedAt`/`fetchedBodyAt` and `listData` is forced
119
185
  // to agree — see withReadState.
120
186
  const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
121
- const payload = { messages, total, page, size };
187
+ // Served from the local cache, so the result must say how old it is and
188
+ // whether anything vouches for it — a caller cannot state current state
189
+ // from this payload without either re-reading or surfacing the caveat.
190
+ const freshness = await buildFreshness(cache, {
191
+ source: 'cache',
192
+ folders: folder === undefined ? ['inbox', 'sent'] : [folder],
193
+ });
194
+ const payload = { messages, total, page, size, freshness };
122
195
  if (total === 0) {
123
196
  payload.note = 'No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.';
124
197
  }
@@ -144,6 +217,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
144
217
  // up — see syncDrafts, which also evicts these stale rows.
145
218
  const draftRow = await cache.getDraft(id);
146
219
  if (draftRow !== null) {
220
+ const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
147
221
  return jsonResponse({
148
222
  id: draftRow.id,
149
223
  folder: 'drafts',
@@ -163,7 +237,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
163
237
  // Concurrency token — pass as expectedRevision to ofw_save_draft /
164
238
  // ofw_delete_draft to assert you are editing THIS version.
165
239
  revision: draftRevision(draftRow),
166
- cacheStatus: await getDraftsCacheStatus(cache),
240
+ cacheStatus,
241
+ // False = this draft's existence and unsent status are remembered from
242
+ // a cache, not confirmed on OFW. Call ofw_check_freshness before
243
+ // stating either as current fact.
244
+ serverConfirmed,
245
+ freshness,
167
246
  });
168
247
  }
169
248
  const cached = await cache.getMessage(id);
@@ -214,7 +293,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
214
293
  // Backfill is best-effort. Fall through with whatever we have.
215
294
  }
216
295
  }
217
- return jsonResponse({ ...withReadState(row), attachments });
296
+ // Cache-served: the body is whatever the last sync stored. Even though
297
+ // this call may have re-hit detail for view status, the message content
298
+ // itself was not re-verified, so report the folder's cache freshness.
299
+ const freshness = await buildFreshness(cache, { source: 'cache', folders: [row.folder] });
300
+ return jsonResponse({ ...withReadState(row), attachments, freshness });
218
301
  }
219
302
  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)' });
220
303
  // Derive the folder for a live-fetched message. A cached row (reached here
@@ -249,7 +332,9 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
249
332
  await fetchAttachmentMetaForMessage(client, detail.id, detail.files, cache);
250
333
  }
251
334
  const attachments = await cache.listAttachmentsForMessage(detail.id);
252
- return jsonResponse({ ...withReadState(row), attachments });
335
+ // Fetched live from OFW in this call — current by construction.
336
+ const freshness = await buildFreshness(cache, { source: 'live', folders: [folder] });
337
+ return jsonResponse({ ...withReadState(row), attachments, freshness });
253
338
  });
254
339
  if (allowSend)
255
340
  server.registerTool('ofw_send_message', {
@@ -414,8 +499,15 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
414
499
  };
415
500
  }
416
501
  const verdict = checkDraftFreshness({ server, cached, expectedRevision });
417
- if (verdict.verdict === 'FRESH')
418
- return { ok: true, note: null };
502
+ if (verdict.verdict === 'FRESH') {
503
+ // A metadata-only "conflict" is the connector's own post-save replyToId
504
+ // normalization catching up — safe to proceed, but say so rather than
505
+ // pretending nothing moved.
506
+ const note = verdict.metadataOnly
507
+ ? `NOTE: draft ${draftId} was treated as current for this ${action}. Since you read it, OurFamilyWizard normalized connector-authored metadata (${verdict.changedFields.join(', ')}); the subject, body and recipients are unchanged, so this is not a conflict.`
508
+ : null;
509
+ return { ok: true, note };
510
+ }
419
511
  if (force) {
420
512
  // Loud, and the overwritten content rides along in the response so it is
421
513
  // recoverable from the tool result itself.
@@ -450,23 +542,34 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
450
542
  const page = args.page ?? 1;
451
543
  const size = args.size ?? 50;
452
544
  const cache = cacheProvider();
453
- const cacheStatus = await getDraftsCacheStatus(cache);
545
+ const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
454
546
  const rows = await cache.listDrafts({ page, size });
455
547
  // Every draft carries the concurrency token to echo back on a write, plus
456
- // whether the last sync actually compared this cache against OFW.
457
- const drafts = rows.map((d) => ({ ...d, revision: draftRevision(d), cacheStatus }));
548
+ // whether the last sync actually compared this cache against OFW and
549
+ // whether its presence-on-server is confirmed or merely remembered.
550
+ const drafts = rows.map((d) => ({
551
+ ...d,
552
+ revision: draftRevision(d),
553
+ cacheStatus,
554
+ serverConfirmed,
555
+ asOf: freshness.asOf,
556
+ }));
458
557
  if (drafts.length === 0) {
459
- return jsonResponse({ drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' });
558
+ return jsonResponse({
559
+ drafts: [],
560
+ freshness,
561
+ note: 'No drafts in the local cache. That is NOT proof there are no drafts on OurFamilyWizard — call ofw_sync_messages to populate, or ofw_check_freshness to confirm.',
562
+ });
460
563
  }
461
- const payload = { drafts };
462
- if (cacheStatus !== 'fresh') {
463
- payload.note = 'cacheStatus "unverified": the last ofw_sync_messages did not finish checking the drafts folder against OurFamilyWizard, so these bodies may be behind the server (drafts edited in the OFW web app do not bump any timestamp). Run ofw_sync_messages again before relying on them. Writes are guarded regardless — ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
564
+ const payload = { drafts, freshness };
565
+ if (!serverConfirmed) {
566
+ payload.note = 'serverConfirmed:false — these drafts are remembered from the local cache, NOT confirmed to still exist unsent on OurFamilyWizard right now, and their bodies may be behind the server. Do not state that a draft "is still sitting unsent" on this basis; drafts edited or deleted in the OFW web app bump no timestamp, so the cache cannot detect it on its own. Call ofw_check_freshness (cheap, live) or ofw_sync_messages first. Writes are guarded regardless — ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
464
567
  }
465
568
  return jsonResponse(payload);
466
569
  });
467
570
  if (allowDrafts)
468
571
  server.registerTool('ofw_save_draft', {
469
- description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft — note that under the hood this creates a NEW draft and deletes the old one (OFW\'s update-in-place endpoint silently no-ops while echoing the posted body, so we don\'t use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if it changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). The refusal returns the current server body under serverBody — merge your edit into it and retry with expectedRevision.',
572
+ description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft — note that under the hood this creates a NEW draft and deletes the old one (OFW\'s update-in-place endpoint silently no-ops while echoing the posted body, so we don\'t use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response that also lists which fields (subject/body/recipients/replyToId/attachments) were carried over. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state, and the returned `revision` reflects that authoritative state (so it will match on your next edit). FIELD PRESERVATION: the response echoes the effective threading (replyToId/inReplyTo) and, whenever OFW did not carry over a requested replyToId, recipient or attachment, a `warnings[]` entry naming what was dropped — never a silent null. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody — merge your edit into it and retry with expectedRevision.',
470
573
  annotations: { readOnlyHint: false },
471
574
  inputSchema: {
472
575
  subject: z.string().describe('Message subject'),
@@ -525,26 +628,63 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
525
628
  let replaceNote = null;
526
629
  let verifyNote = null;
527
630
  let newRevision = null;
631
+ // Fields accepted on the write that the saved draft must carry — or their
632
+ // loss must be reported. Never a silent drop (Defect 3).
633
+ const warnings = [];
528
634
  if (newId !== null) {
529
635
  verifyNote = verifyWriteLanded('draft', { subject: args.subject, body: args.body }, detail);
636
+ // Trust the re-fetched server detail as the source of truth for the stored
637
+ // replyToId — NOT `resolvedReplyTo` (what we intended to post). OFW
638
+ // normalizes/drops threading after a save, and masking that with our own
639
+ // intent (the old `detail.replyToId ?? resolvedReplyTo`) both returned a
640
+ // revision that was stale on arrival (Defect 1) and hid a dropped reply
641
+ // link (Defect 3). `?? null` keeps a genuinely-echoed value and reflects a
642
+ // null/absent one honestly.
643
+ const effectiveReplyTo = detail.replyToId ?? null;
644
+ const storedRecipients = mapRecipients(detail.recipients);
530
645
  persisted = {
531
646
  id: newId,
532
647
  subject: detail.subject ?? args.subject,
533
648
  body: detail.body ?? '',
534
- recipients: mapRecipients(detail.recipients),
535
- replyToId: detail.replyToId ?? resolvedReplyTo,
649
+ recipients: storedRecipients,
650
+ replyToId: effectiveReplyTo,
536
651
  modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
537
652
  listData: detail,
538
653
  };
539
654
  await cache.upsertDraft(persisted);
655
+ // The revision is now computed from the server-authoritative detail, so it
656
+ // is the value a subsequent read/verify will observe (Defect 1).
540
657
  newRevision = draftRevision(persisted);
658
+ // Audit every field the caller supplied against what actually landed, so a
659
+ // silent normalization becomes a visible warning rather than a surprise.
660
+ if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
661
+ const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : '';
662
+ warnings.push(`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? 'null' : effectiveReplyTo} — OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped. If threading matters, verify on ourfamilywizard.com.`);
663
+ }
664
+ // Only warn on recipients/attachments when the detail actually reported
665
+ // them — an omitted array is "not echoed", not "dropped", and crying wolf
666
+ // there would desensitize the caller to the real drops.
667
+ if (args.recipientIds !== undefined && Array.isArray(detail.recipients)) {
668
+ const requested = [...new Set(args.recipientIds)].sort((a, b) => a - b);
669
+ const stored = [...new Set(storedRecipients.map((r) => r.userId))].sort((a, b) => a - b);
670
+ if (requested.join(',') !== stored.join(',')) {
671
+ warnings.push(`recipientIds were requested as [${requested.join(', ')}] but the saved draft has [${stored.join(', ')}]. Verify the recipients on ourfamilywizard.com.`);
672
+ }
673
+ }
674
+ if (myFileIDs.length > 0 && Array.isArray(detail.files)) {
675
+ const storedFiles = new Set(detail.files);
676
+ const missing = myFileIDs.filter((id) => !storedFiles.has(id));
677
+ if (missing.length > 0) {
678
+ warnings.push(`Attachment fileId(s) ${missing.join(', ')} were requested in myFileIDs but are not attached to the saved draft. Re-upload or re-attach if needed.`);
679
+ }
680
+ }
541
681
  // Replace-path: caller passed messageId, so they want the old draft
542
682
  // gone. Delete it after the new one is safely created+cached.
543
683
  if (args.messageId !== undefined && args.messageId !== newId) {
544
684
  try {
545
685
  await deleteOFWMessages(client, [args.messageId]);
546
686
  await cache.deleteDraft(args.messageId);
547
- 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.)`;
687
+ 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.) Fields carried over to the new draft: subject, body, recipients (${persisted.recipients.length}), replyToId (${persisted.replyToId === null ? 'none' : persisted.replyToId}), attachments (${myFileIDs.length}).${warnings.length > 0 ? ' See warnings above for any field OurFamilyWizard did not carry over.' : ''}`;
548
688
  }
549
689
  catch (e) {
550
690
  // Partial-failure safety: the new draft is already created and
@@ -554,11 +694,26 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
554
694
  }
555
695
  }
556
696
  }
697
+ // The draft was just re-fetched from OFW by postMessageAndRefetch, so this
698
+ // one row IS server-confirmed regardless of the drafts folder's overall
699
+ // cache freshness. `inReplyTo` echoes the effective threading alongside the
700
+ // volatile `id`, and `warnings` names any requested field that did not land.
557
701
  const responseObj = persisted !== null
558
- ? { ...persisted, revision: newRevision, cacheStatus: 'fresh' }
702
+ ? {
703
+ ...persisted,
704
+ inReplyTo: persisted.replyToId,
705
+ revision: newRevision,
706
+ cacheStatus: 'fresh',
707
+ serverConfirmed: true,
708
+ ...(warnings.length > 0 ? { warnings } : {}),
709
+ }
559
710
  : raw;
560
711
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
561
- const notes = [forceNote, rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join('\n\n');
712
+ const warnNote = warnings.length > 0
713
+ ? `WARNING: ${warnings.join('\n\n')}`
714
+ : null;
715
+ const notes = [forceNote, rewriteNote, verifyNote, warnNote, replaceNote]
716
+ .filter((n) => n !== null).join('\n\n');
562
717
  return textResponse(notes ? `${notes}\n\n${text}` : text);
563
718
  });
564
719
  if (allowDrafts)
@@ -596,9 +751,18 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
596
751
  }, async (args) => {
597
752
  const page = args.page ?? 1;
598
753
  const size = args.size ?? 50;
599
- const sent = await cacheProvider().listMessages({ folder: 'sent', page, size });
754
+ const cache = cacheProvider();
755
+ const sent = await cache.listMessages({ folder: 'sent', page, size });
756
+ // "Nobody has read it yet" is a present-tense claim drawn entirely from
757
+ // cached view timestamps, which only move when a sync refreshes them —
758
+ // so it needs the same age label as any other cached read.
759
+ const freshness = await buildFreshness(cache, { source: 'cache', folders: ['sent'] });
600
760
  if (sent.length === 0) {
601
- return jsonResponse({ note: 'Sent cache is empty. Call ofw_sync_messages to populate.' });
761
+ return jsonResponse({
762
+ unread: [],
763
+ freshness,
764
+ note: 'Sent cache is empty. Call ofw_sync_messages to populate. An empty cache is NOT evidence that no sent messages exist.',
765
+ });
602
766
  }
603
767
  const unread = [];
604
768
  for (const msg of sent) {
@@ -608,9 +772,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
608
772
  }
609
773
  }
610
774
  if (unread.length === 0) {
611
- return jsonResponse({ message: 'All scanned sent messages have been read.' });
775
+ return jsonResponse({
776
+ unread: [],
777
+ freshness,
778
+ message: 'All scanned sent messages had been read as of the timestamp in `freshness.asOf`. A recipient may have read a message since without the cache hearing about it.',
779
+ });
612
780
  }
613
- return jsonResponse(unread);
781
+ return jsonResponse({ unread, freshness });
614
782
  });
615
783
  if (allowDrafts)
616
784
  server.registerTool('ofw_upload_attachment', {
@@ -657,18 +825,24 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
657
825
  });
658
826
  });
659
827
  server.registerTool('ofw_download_attachment', {
660
- description: 'Download an OFW message attachment by fileId. By default, bytes are saved to disk (~/Downloads/ofw-mcp/) and the response carries the absolute path, mime type, and size for the caller to read back. Pass inline:true to skip disk entirely and return the bytes as MCP content blocks — images come back as ImageContent (the model sees them directly); other files come back as an EmbeddedResource blob. Use inline for small files where you want the model to read content immediately and the host is sandboxed; use disk for large files or when you want a persistent local copy. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var (set to "true" to make inline the default). fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo. Re-downloading to the same path is a no-op (disk mode only).',
828
+ description: 'Download an OFW message attachment by fileId. By default, bytes are saved to disk (~/Downloads/ofw-mcp/) and the response carries the absolute path, mime type, and size for the caller to read back. Pass inline:true to skip disk entirely and return the bytes as MCP content blocks — host-renderable images (PNG/JPEG/GIF/WEBP) come back as ImageContent (the model sees them directly); every other file comes back as an EmbeddedResource blob carrying the bytes. Reported mime types are always normalized to a bare media type (no charset/name parameters). Use inline for small files where you want the model to read content immediately and the host is sandboxed; use disk for large files or when you want a persistent local copy. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var (set to "true" to make inline the default). On a hosted deployment with no filesystem, disk mode is unavailable, so inline is forced (the response is marked forcedInline:true) rather than failing. fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo. Re-downloading to the same path is a no-op (disk mode only).',
661
829
  annotations: { readOnlyHint: false },
662
830
  inputSchema: {
663
831
  fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
664
- inline: z.boolean().describe('If true, return bytes inline as MCP content (image for image/*, embedded resource blob otherwise) and skip the disk write. If false, write to disk and return the path. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).').optional(),
665
- saveTo: z.string().describe('Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/Downloads/ofw-mcp/<fileId>-<filename>. Ignored when inline:true.').optional(),
832
+ inline: z.boolean().describe('If true, return bytes inline as MCP content (ImageContent for host-renderable images, embedded resource blob otherwise) and skip the disk write. If false, write to disk and return the path — except on a hosted deployment with no filesystem, where inline is forced (forcedInline:true) so the bytes are still returned. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).').optional(),
833
+ saveTo: z.string().describe('Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/Downloads/ofw-mcp/<fileId>-<filename>. Ignored when inline is in effect.').optional(),
666
834
  force: z.boolean().describe('Re-download even if already on disk. Default false. Ignored when inline:true (inline always fetches fresh bytes, or reuses an on-disk copy if present).').optional(),
667
835
  },
668
836
  }, async (args) => {
669
837
  const fileId = args.fileId;
670
838
  const cache = cacheProvider();
671
- const inline = args.inline ?? getDefaultInlineAttachments();
839
+ const requestedInline = args.inline ?? getDefaultInlineAttachments();
840
+ // When the deployment has no filesystem (hosted connector), inline is the
841
+ // ONLY path to the bytes — force it rather than erroring on a disk write.
842
+ // `forcedInline` records that we overrode an explicit `inline:false` so the
843
+ // response is honest about it instead of silently ignoring the argument.
844
+ const inline = requestedInline || !attachmentIO.supportsDisk;
845
+ const forcedInline = inline && !requestedInline;
672
846
  let cached = await cache.getAttachment(fileId);
673
847
  if (!cached) {
674
848
  // Not in cache. Fetch metadata and store under the messageId=0
@@ -682,7 +856,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
682
856
  if (inline) {
683
857
  // Reuse on-disk bytes if we already have them; otherwise fetch fresh.
684
858
  let bytes = null;
685
- let mimeType = cached.mimeType;
859
+ let headerMime = cached.mimeType;
686
860
  let fileName = cached.fileName;
687
861
  if (cached.downloadedPath) {
688
862
  bytes = attachmentIO.readDownloaded(cached.downloadedPath);
@@ -690,14 +864,25 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
690
864
  if (bytes === null) {
691
865
  const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
692
866
  bytes = response.body;
693
- mimeType = response.contentType ?? cached.mimeType;
867
+ headerMime = response.contentType ?? cached.mimeType;
694
868
  fileName = response.suggestedFileName ?? cached.fileName;
695
869
  }
870
+ // Normalize to a bare media type: sniff the bytes first (OFW tacks a bogus
871
+ // charset onto binaries), then fall back to the stripped header, then the
872
+ // extension. A parameter suffix would make the host reject an image.
873
+ const mimeType = resolveDownloadMime(bytes, headerMime, fileName);
696
874
  const base64 = bytes.toString('base64');
697
- const metaBlock = { type: 'text', text: JSON.stringify({
698
- fileId, fileName, mimeType, sizeBytes: bytes.length, mode: 'inline',
699
- }, null, 2) };
700
- if (mimeType.startsWith('image/')) {
875
+ const meta = {
876
+ fileId, fileName, mimeType, sizeBytes: bytes.length, mode: 'inline',
877
+ };
878
+ if (forcedInline)
879
+ meta.forcedInline = true;
880
+ const metaBlock = { type: 'text', text: JSON.stringify(meta, null, 2) };
881
+ // Only host-renderable image types go back as ImageContent (with the bare
882
+ // media type the renderer accepts); everything else — non-renderable
883
+ // images included — goes back as an EmbeddedResource so the caller always
884
+ // gets the bytes.
885
+ if (isHostRenderableImage(mimeType)) {
701
886
  return { content: [metaBlock, { type: 'image', data: base64, mimeType }] };
702
887
  }
703
888
  return { content: [metaBlock, { type: 'resource', resource: {
@@ -723,38 +908,172 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
723
908
  }
724
909
  if (!args.force && cached.downloadedPath === dest) {
725
910
  return jsonResponse({
726
- fileId, path: dest, mimeType: cached.mimeType, sizeBytes: cached.sizeBytes,
727
- fileName: cached.fileName, note: 'already downloaded',
911
+ // No bytes on hand for the no-op case: normalize the cached/extension
912
+ // MIME (empty buffer sniffs nothing) so a stored `image/png;charset=…`
913
+ // still reports bare.
914
+ fileId, path: dest, mimeType: resolveDownloadMime(Buffer.alloc(0), cached.mimeType, cached.fileName),
915
+ sizeBytes: cached.sizeBytes, fileName: cached.fileName, note: 'already downloaded',
728
916
  });
729
917
  }
730
918
  const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
731
919
  attachmentIO.writeDownload(dest, response.body);
732
920
  await cache.markAttachmentDownloaded(fileId, dest);
921
+ const fileName = response.suggestedFileName ?? cached.fileName;
733
922
  return jsonResponse({
734
923
  fileId,
735
924
  path: dest,
736
- mimeType: response.contentType ?? cached.mimeType,
925
+ mimeType: resolveDownloadMime(response.body, response.contentType ?? cached.mimeType, fileName),
737
926
  sizeBytes: response.body.length,
738
- fileName: response.suggestedFileName ?? cached.fileName,
927
+ fileName,
739
928
  });
740
929
  });
741
930
  server.registerTool('ofw_sync_messages', {
742
931
  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).',
743
932
  annotations: { readOnlyHint: false },
744
933
  inputSchema: {
745
- folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).describe('Folders to sync (default: all three)').optional(),
934
+ folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).min(1).describe('Folders to sync (default: all three). Must be non-empty if given — an empty list would sync nothing while reporting success.').optional(),
746
935
  fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.').optional(),
747
936
  deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
748
937
  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(),
749
938
  },
750
939
  }, async (args) => {
940
+ const cache = cacheProvider();
751
941
  const result = await syncAll(client, {
752
942
  folders: args.folders,
753
943
  fetchUnreadBodies: args.fetchUnreadBodies,
754
944
  deep: args.deep,
755
945
  maxRequests: args.maxRequests ?? getSyncMaxRequests(),
756
- }, cacheProvider());
757
- return jsonResponse(result);
946
+ }, cache);
947
+ // Freshness of the cache AS OF this sync completing — so a paused call
948
+ // that skipped a folder says so here too, not just in `notRefreshed`.
949
+ const freshness = await buildFreshness(cache, {
950
+ source: 'cache',
951
+ folders: args.folders ?? ['inbox', 'sent', 'drafts'],
952
+ });
953
+ return jsonResponse({ ...result, freshness });
954
+ });
955
+ server.registerTool('ofw_check_freshness', {
956
+ description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state — especially "draft X is still sitting unsent" — when a read returned serverConfirmed:false or freshness.staleness other than "fresh". Costs one OFW request for the folder check plus one per messageId. For each folder it returns the live server count next to the cached count; for each id, whether it still exists on OFW and whether its content matches the cache (compared by content revision, because OFW draft timestamps do NOT change when a draft is edited in the web app). Does not fetch bodies into the cache, does not touch attachments, and does not depend on sync state.',
957
+ annotations: { readOnlyHint: true },
958
+ inputSchema: {
959
+ folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).min(1).describe('Folders to compare cached vs live counts for. Defaults to all three when messageIds is not given. Must be non-empty if given.').optional(),
960
+ messageIds: z.array(z.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}). By default only ids present in the drafts cache are probed — see allowMarkRead.`).optional(),
961
+ allowMarkRead: z.boolean().describe('Default false. Probing an id that is NOT a cached draft requires fetching its detail, which marks an unread inbox message as READ on OurFamilyWizard — an irreversible change to the record. Such ids are skipped unless you set this to true.').optional(),
962
+ },
963
+ }, async (args) => {
964
+ const cache = cacheProvider();
965
+ const allowMarkRead = args.allowMarkRead ?? false;
966
+ const requestedIds = args.messageIds ?? [];
967
+ const ids = requestedIds.slice(0, MAX_FRESHNESS_IDS);
968
+ // Folders default to "all three" only when the caller asked about nothing
969
+ // else; an ids-only call shouldn't silently spend a request on folders.
970
+ const wantFolders = args.folders
971
+ ?? (requestedIds.length > 0 ? [] : ['inbox', 'sent', 'drafts']);
972
+ let requestsUsed = 0;
973
+ const folders = [];
974
+ if (wantFolders.length > 0) {
975
+ requestsUsed++;
976
+ const data = parseLenient(FolderCountsSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), { label: 'ofw-mcp', context: 'GET /pub/v1/messageFolders (ofw_check_freshness)' });
977
+ const sys = data.systemFolders ?? [];
978
+ for (const folder of wantFolders) {
979
+ const entry = sys.find((x) => x.folderType === FOLDER_TYPE[folder]);
980
+ const serverCount = entry?.totalCount ?? entry?.messageCount ?? entry?.count ?? null;
981
+ const cachedCount = folder === 'drafts'
982
+ ? (await cache.listDraftIds()).length
983
+ : await cache.countMessages({ folder });
984
+ const state = await cache.getSyncState(folder);
985
+ const historyComplete = state !== null && state.resumePage === null;
986
+ // A partially backfilled folder legitimately holds fewer messages than
987
+ // the server, so a count mismatch there proves nothing. Report both
988
+ // numbers and leave the verdict null rather than crying wolf for the
989
+ // entire duration of a backfill.
990
+ const inSync = serverCount === null || !historyComplete
991
+ ? null
992
+ : serverCount === cachedCount;
993
+ folders.push({
994
+ folder,
995
+ existsOnServer: entry !== undefined,
996
+ serverCount,
997
+ cachedCount,
998
+ historyComplete,
999
+ lastVerifiedAt: await getFolderVerifiedAt(cache, folder),
1000
+ inSync,
1001
+ ...(inSync === null
1002
+ ? { note: serverCount === null
1003
+ ? 'OFW did not report a count for this folder, so cached-vs-server cannot be compared. Use the per-id check instead.'
1004
+ : 'Older history is still being backfilled, so a lower cachedCount is expected and does not indicate drift.' }
1005
+ : {}),
1006
+ });
1007
+ }
1008
+ }
1009
+ const items = [];
1010
+ for (const id of ids) {
1011
+ const cachedDraft = await cache.getDraft(id);
1012
+ // Drafts have no read state, so probing one is genuinely side-effect
1013
+ // free. Any other id means GET /pub/v3/messages/{id}, which marks an
1014
+ // unread inbox message read on OFW — a permanent change to a
1015
+ // court-visible record. Refuse by default rather than quietly doing it.
1016
+ if (cachedDraft === null && !allowMarkRead) {
1017
+ items.push({
1018
+ id,
1019
+ skipped: true,
1020
+ reason: 'NOT_A_CACHED_DRAFT',
1021
+ note: 'Not in the drafts cache. Verifying it requires fetching its detail from OFW, which would mark an unread inbox message as READ on OurFamilyWizard. Pass allowMarkRead:true if that is acceptable.',
1022
+ });
1023
+ continue;
1024
+ }
1025
+ requestsUsed++;
1026
+ try {
1027
+ const server = await fetchServerDraft(client, id);
1028
+ const cacheRevision = cachedDraft === null ? null : draftRevision(cachedDraft);
1029
+ if (server === null) {
1030
+ items.push({
1031
+ id,
1032
+ existsOnServer: false,
1033
+ inSync: false,
1034
+ cacheRevision,
1035
+ serverRevision: null,
1036
+ note: cachedDraft === null
1037
+ ? 'Not found on OurFamilyWizard.'
1038
+ : 'This draft is in the local cache but NO LONGER EXISTS on OurFamilyWizard — it was sent or deleted elsewhere. Do not describe it as still unsent.',
1039
+ });
1040
+ continue;
1041
+ }
1042
+ const serverRevision = draftRevision(server);
1043
+ items.push({
1044
+ id,
1045
+ existsOnServer: true,
1046
+ cacheRevision,
1047
+ serverRevision,
1048
+ inSync: cacheRevision !== null && cacheRevision === serverRevision,
1049
+ ...(cacheRevision === null
1050
+ ? { note: 'Exists on OurFamilyWizard but is not in the local cache.' }
1051
+ : cacheRevision !== serverRevision
1052
+ ? { note: 'Content differs from the cache — it was edited on OurFamilyWizard since the last sync. Run ofw_sync_messages before reading or writing it.' }
1053
+ : {}),
1054
+ });
1055
+ }
1056
+ catch (e) {
1057
+ // A check that could not run must not read as "in sync".
1058
+ items.push({
1059
+ id,
1060
+ error: 'FRESHNESS_CHECK_FAILED',
1061
+ message: e.message,
1062
+ inSync: null,
1063
+ note: 'The freshness check itself failed, so nothing is confirmed either way.',
1064
+ });
1065
+ }
1066
+ }
1067
+ const payload = {
1068
+ checkedAt: new Date().toISOString(),
1069
+ requestsUsed,
1070
+ ...(folders.length > 0 ? { folders } : {}),
1071
+ ...(items.length > 0 ? { items } : {}),
1072
+ };
1073
+ if (requestedIds.length > ids.length) {
1074
+ payload.note = `Only the first ${MAX_FRESHNESS_IDS} of ${requestedIds.length} messageIds were checked (per-call cap). The remaining ${requestedIds.length - ids.length} were NOT verified — call again with the rest.`;
1075
+ }
1076
+ return jsonResponse(payload);
758
1077
  });
759
1078
  }
760
1079
  // OFW's bulk-delete endpoint takes a multipart form with `messageIds`.