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.
@@ -1,8 +1,12 @@
1
1
  import { z } from 'zod';
2
- import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
2
+ import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage, getDraftsCacheStatus } from '../sync.js';
3
+ import { buildFreshness } from './freshness.js';
4
+ import { checkDraftFreshness, draftRevision, fetchServerDraft, staleDraftPayload, } from './draft-freshness.js';
5
+ import { getFolderVerifiedAt } from '../sync.js';
6
+ import { isHostRenderableImage, resolveDownloadMime } from './attachments.js';
3
7
  import { getAttachmentsDir, getDefaultInlineAttachments, getSyncMaxRequests, getWriteMode } from '../config.js';
4
8
  import { basename, join } from 'node:path';
5
- import { ApiRecipientSchema, expandPath, hasRealView, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
9
+ import { ApiRecipientSchema, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
6
10
  import { parseLenient } from '@chrischall/mcp-utils';
7
11
  // Schemas for the load-bearing fields of each /pub/v3 response this file
8
12
  // reads (issue #83). Loose: unknown keys pass through into cached listData.
@@ -42,6 +46,33 @@ const MessageDetailSchema = z.looseObject({
42
46
  });
43
47
  // Attachment-backfill detail fetch reads only `files`.
44
48
  const DetailFilesSchema = z.looseObject({ files: z.array(z.number()).optional() });
49
+ // ofw_check_freshness' folder probe. `includeFolderCounts=true` returns a
50
+ // per-folder count, but the field name varies across OFW payload versions —
51
+ // accept the known spellings and degrade to a null serverCount rather than
52
+ // guessing, since a wrong count would manufacture a false out-of-sync verdict.
53
+ const FolderCountsSchema = z.looseObject({
54
+ systemFolders: z.array(z.looseObject({
55
+ id: z.string(),
56
+ folderType: z.string(),
57
+ totalCount: z.number().optional(),
58
+ messageCount: z.number().optional(),
59
+ count: z.number().optional(),
60
+ })).optional(),
61
+ });
62
+ const FOLDER_TYPE = {
63
+ inbox: 'INBOX',
64
+ sent: 'SENT_MESSAGES',
65
+ drafts: 'DRAFTS',
66
+ };
67
+ /**
68
+ * Cap on per-id probes in one ofw_check_freshness call.
69
+ *
70
+ * Each id costs one OFW request, and on the hosted Worker every request counts
71
+ * against the subrequest cap (see OFW_SYNC_MAX_REQUESTS). The check has to stay
72
+ * cheap enough that a caller reaches for it freely — that is the entire point
73
+ * of it existing — so it truncates loudly rather than turning into a sync.
74
+ */
75
+ const MAX_FRESHNESS_IDS = 25;
45
76
  // Upload response — STRICT: fileId is the whole point of the call; caching
46
77
  // or returning an undefined/mistyped fileId produces an unusable attachment.
47
78
  const UploadedFileSchema = z.looseObject({
@@ -66,6 +97,32 @@ function listDataHintsAtFiles(listData) {
66
97
  return ld.files.length > 0;
67
98
  return false;
68
99
  }
100
+ /**
101
+ * Freshness for a drafts read, plus the per-draft `serverConfirmed` flag.
102
+ *
103
+ * `serverConfirmed` answers the question that triggered this whole mechanism:
104
+ * "is this draft actually still sitting unsent on OFW?" It is true ONLY when a
105
+ * completed drafts walk verified the cache against OFW within the freshness
106
+ * window (getFreshnessTtlSeconds, default 300s) — NOT a claim about this exact
107
+ * instant, which no cache can make. Anything less — a deferred walk, an aged
108
+ * stamp, a cache that was never checked — is false, meaning the draft's
109
+ * existence and unsent status are remembered, not known. On a false, a caller
110
+ * must not state either as present-tense fact without calling
111
+ * ofw_check_freshness first.
112
+ */
113
+ async function draftsFreshness(cache) {
114
+ const freshness = await buildFreshness(cache, { source: 'cache', folders: ['drafts'] });
115
+ // Reconcile the two signals so a single response can never contradict
116
+ // itself (the same rule withReadState applies to read flags). The drafts
117
+ // meta key says whether the last walk COMPLETED; freshness additionally
118
+ // knows whether that walk has since aged out or been overtaken by a sync
119
+ // that skipped drafts. Downgrade only — this can turn 'fresh' off, never on.
120
+ const completed = await getDraftsCacheStatus(cache);
121
+ const cacheStatus = completed === 'fresh' && freshness.staleness === 'fresh'
122
+ ? 'fresh'
123
+ : 'unverified';
124
+ return { freshness, serverConfirmed: cacheStatus === 'fresh', cacheStatus };
125
+ }
69
126
  export function registerMessageTools(server, client, cacheProvider, attachmentIO) {
70
127
  // OFW_WRITE_MODE gate (see config.ts). Send lands on the court-visible
71
128
  // record, so it is 'all'-only; draft-level writes (save/delete drafts,
@@ -75,11 +132,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
75
132
  const allowSend = writeMode === 'all';
76
133
  const allowDrafts = writeMode !== 'none';
77
134
  server.registerTool('ofw_list_message_folders', {
78
- 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.',
135
+ 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.',
79
136
  annotations: { readOnlyHint: true },
80
137
  }, async () => {
81
138
  const data = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
82
- return jsonResponse(data);
139
+ const freshness = await buildFreshness(cacheProvider(), { source: 'live', folders: [] });
140
+ return jsonResponse({ folders: data, freshness });
83
141
  });
84
142
  server.registerTool('ofw_list_messages', {
85
143
  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.',
@@ -104,9 +162,16 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
104
162
  else if (folderArg === 'both')
105
163
  folder = undefined;
106
164
  else {
165
+ // Still carries freshness: `messages: []` with no age label is exactly
166
+ // the shape this mechanism exists to eliminate, even when the emptiness
167
+ // is caused by a bad argument rather than an empty cache.
107
168
  return jsonResponse({
108
169
  messages: [],
109
- note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.',
170
+ freshness: await buildFreshness(cacheProvider(), {
171
+ source: 'cache',
172
+ folders: ['inbox', 'sent'],
173
+ }),
174
+ 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.',
110
175
  });
111
176
  }
112
177
  const cache = cacheProvider();
@@ -117,7 +182,14 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
117
182
  // from the record's own `viewedAt`/`fetchedBodyAt` and `listData` is forced
118
183
  // to agree — see withReadState.
119
184
  const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
120
- const payload = { messages, total, page, size };
185
+ // Served from the local cache, so the result must say how old it is and
186
+ // whether anything vouches for it — a caller cannot state current state
187
+ // from this payload without either re-reading or surfacing the caveat.
188
+ const freshness = await buildFreshness(cache, {
189
+ source: 'cache',
190
+ folders: folder === undefined ? ['inbox', 'sent'] : [folder],
191
+ });
192
+ const payload = { messages, total, page, size, freshness };
121
193
  if (total === 0) {
122
194
  payload.note = 'No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.';
123
195
  }
@@ -143,6 +215,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
143
215
  // up — see syncDrafts, which also evicts these stale rows.
144
216
  const draftRow = await cache.getDraft(id);
145
217
  if (draftRow !== null) {
218
+ const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
146
219
  return jsonResponse({
147
220
  id: draftRow.id,
148
221
  folder: 'drafts',
@@ -159,6 +232,15 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
159
232
  chainRootId: null,
160
233
  listData: draftRow.listData,
161
234
  attachments: [],
235
+ // Concurrency token — pass as expectedRevision to ofw_save_draft /
236
+ // ofw_delete_draft to assert you are editing THIS version.
237
+ revision: draftRevision(draftRow),
238
+ cacheStatus,
239
+ // False = this draft's existence and unsent status are remembered from
240
+ // a cache, not confirmed on OFW. Call ofw_check_freshness before
241
+ // stating either as current fact.
242
+ serverConfirmed,
243
+ freshness,
162
244
  });
163
245
  }
164
246
  const cached = await cache.getMessage(id);
@@ -209,7 +291,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
209
291
  // Backfill is best-effort. Fall through with whatever we have.
210
292
  }
211
293
  }
212
- return jsonResponse({ ...withReadState(row), attachments });
294
+ // Cache-served: the body is whatever the last sync stored. Even though
295
+ // this call may have re-hit detail for view status, the message content
296
+ // itself was not re-verified, so report the folder's cache freshness.
297
+ const freshness = await buildFreshness(cache, { source: 'cache', folders: [row.folder] });
298
+ return jsonResponse({ ...withReadState(row), attachments, freshness });
213
299
  }
214
300
  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)' });
215
301
  // Derive the folder for a live-fetched message. A cached row (reached here
@@ -244,7 +330,9 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
244
330
  await fetchAttachmentMetaForMessage(client, detail.id, detail.files, cache);
245
331
  }
246
332
  const attachments = await cache.listAttachmentsForMessage(detail.id);
247
- return jsonResponse({ ...withReadState(row), attachments });
333
+ // Fetched live from OFW in this call — current by construction.
334
+ const freshness = await buildFreshness(cache, { source: 'live', folders: [folder] });
335
+ return jsonResponse({ ...withReadState(row), attachments, freshness });
248
336
  });
249
337
  if (allowSend)
250
338
  server.registerTool('ofw_send_message', {
@@ -376,6 +464,64 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
376
464
  const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join('\n\n');
377
465
  return textResponse(notes ? `${notes}\n\n${text}` : text);
378
466
  });
467
+ async function guardDestructiveDraftOp(input) {
468
+ const { cache, draftId, expectedRevision, force, action } = input;
469
+ const cachedRow = await cache.getDraft(draftId);
470
+ const cached = cachedRow === null ? null : {
471
+ subject: cachedRow.subject,
472
+ body: cachedRow.body,
473
+ recipients: cachedRow.recipients,
474
+ replyToId: cachedRow.replyToId,
475
+ };
476
+ let server;
477
+ try {
478
+ server = await fetchServerDraft(client, draftId);
479
+ }
480
+ catch (e) {
481
+ // fetchServerDraft funnels every non-404 failure into DraftFreshnessError,
482
+ // so anything landing here means the check could not RUN. That is not
483
+ // permission to proceed: a transient 5xx must not degrade into a blind
484
+ // overwrite.
485
+ const reason = e.message;
486
+ if (force) {
487
+ return { ok: true, note: `WARNING: force:true — proceeded with ${action} on draft ${draftId} even though its current state could not be read from OurFamilyWizard (${reason}). Any newer server-side version was destroyed and is NOT recoverable from this response.` };
488
+ }
489
+ return {
490
+ ok: false,
491
+ response: jsonErrorResponse({
492
+ error: 'FRESHNESS_CHECK_FAILED',
493
+ draftId,
494
+ reason,
495
+ recovery: 'Nothing was changed. This is usually transient — retry. If it persists, verify the draft on ourfamilywizard.com. Pass force:true only if you accept overwriting a version you have not seen.',
496
+ }),
497
+ };
498
+ }
499
+ const verdict = checkDraftFreshness({ server, cached, expectedRevision });
500
+ if (verdict.verdict === 'FRESH')
501
+ return { ok: true, note: null };
502
+ if (force) {
503
+ // Loud, and the overwritten content rides along in the response so it is
504
+ // recoverable from the tool result itself.
505
+ console.error(`[ofw-mcp] WARNING: force:true overrode a ${verdict.verdict} verdict on draft ${draftId} (${action}). ${verdict.reason}`);
506
+ const echoed = server === null
507
+ ? 'The draft no longer existed on OurFamilyWizard.'
508
+ : `The server version that was overwritten is preserved below under "overwrittenServerDraft".`;
509
+ return {
510
+ ok: true,
511
+ note: `WARNING: force:true overrode a ${verdict.verdict} freshness verdict on draft ${draftId}. ${verdict.reason} ${echoed}\n\n${JSON.stringify({ overwrittenServerDraft: server === null ? null : { ...server, revision: draftRevision(server) } }, null, 2)}`,
512
+ };
513
+ }
514
+ return {
515
+ ok: false,
516
+ response: jsonErrorResponse(staleDraftPayload({
517
+ error: verdict.verdict === 'MISSING' ? 'MISSING_DRAFT' : 'STALE_DRAFT',
518
+ draftId,
519
+ verdict,
520
+ server,
521
+ cached,
522
+ })),
523
+ };
524
+ }
379
525
  server.registerTool('ofw_list_drafts', {
380
526
  description: 'List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.',
381
527
  annotations: { readOnlyHint: true },
@@ -386,15 +532,35 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
386
532
  }, async (args) => {
387
533
  const page = args.page ?? 1;
388
534
  const size = args.size ?? 50;
389
- const drafts = await cacheProvider().listDrafts({ page, size });
390
- const payload = drafts.length === 0
391
- ? { drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' }
392
- : { drafts };
535
+ const cache = cacheProvider();
536
+ const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
537
+ const rows = await cache.listDrafts({ page, size });
538
+ // Every draft carries the concurrency token to echo back on a write, plus
539
+ // whether the last sync actually compared this cache against OFW and
540
+ // whether its presence-on-server is confirmed or merely remembered.
541
+ const drafts = rows.map((d) => ({
542
+ ...d,
543
+ revision: draftRevision(d),
544
+ cacheStatus,
545
+ serverConfirmed,
546
+ asOf: freshness.asOf,
547
+ }));
548
+ if (drafts.length === 0) {
549
+ return jsonResponse({
550
+ drafts: [],
551
+ freshness,
552
+ 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.',
553
+ });
554
+ }
555
+ const payload = { drafts, freshness };
556
+ if (!serverConfirmed) {
557
+ 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.';
558
+ }
393
559
  return jsonResponse(payload);
394
560
  });
395
561
  if (allowDrafts)
396
562
  server.registerTool('ofw_save_draft', {
397
- 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.',
563
+ 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.',
398
564
  annotations: { readOnlyHint: false },
399
565
  inputSchema: {
400
566
  subject: z.string().describe('Message subject'),
@@ -403,9 +569,26 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
403
569
  messageId: z.number().describe('ID of an existing draft to replace (the new draft will have a new id; the old is deleted)').optional(),
404
570
  replyToId: z.number().describe('ID of the message this draft replies to').optional(),
405
571
  myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
572
+ expectedRevision: z.string().describe('With messageId: the `revision` you got from ofw_list_drafts/ofw_get_message for that draft. Asserts you are replacing THAT version. If the draft changed on OFW since, the write is refused and the current server body is returned. Omit and the tool compares the server against the local cache instead — omitting never means "overwrite anyway".').optional(),
573
+ force: z.boolean().describe('Default false. Overwrite even when the draft changed on OurFamilyWizard since you read it. The discarded server version is echoed back in the response. Only use after showing the user the conflict.').optional(),
406
574
  },
407
575
  }, async (args) => {
408
576
  const cache = cacheProvider();
577
+ // Guard BEFORE the POST: refusing after creating a replacement would leave
578
+ // a stray draft behind for a write we then decline to finish.
579
+ let forceNote = null;
580
+ if (args.messageId !== undefined) {
581
+ const guard = await guardDestructiveDraftOp({
582
+ cache,
583
+ draftId: args.messageId,
584
+ expectedRevision: args.expectedRevision,
585
+ force: args.force ?? false,
586
+ action: 'replace',
587
+ });
588
+ if (!guard.ok)
589
+ return guard.response;
590
+ forceNote = guard.note;
591
+ }
409
592
  const requestedReplyTo = args.replyToId ?? null;
410
593
  let resolvedReplyTo = requestedReplyTo;
411
594
  let rewriteNote = null;
@@ -435,6 +618,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
435
618
  let persisted = null;
436
619
  let replaceNote = null;
437
620
  let verifyNote = null;
621
+ let newRevision = null;
438
622
  if (newId !== null) {
439
623
  verifyNote = verifyWriteLanded('draft', { subject: args.subject, body: args.body }, detail);
440
624
  persisted = {
@@ -447,6 +631,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
447
631
  listData: detail,
448
632
  };
449
633
  await cache.upsertDraft(persisted);
634
+ newRevision = draftRevision(persisted);
450
635
  // Replace-path: caller passed messageId, so they want the old draft
451
636
  // gone. Delete it after the new one is safely created+cached.
452
637
  if (args.messageId !== undefined && args.messageId !== newId) {
@@ -456,26 +641,47 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
456
641
  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.)`;
457
642
  }
458
643
  catch (e) {
459
- replaceNote = `WARNING: New draft ${newId} created successfully, but failed to delete the old draft (${args.messageId}): ${e.message}. You may want to clean it up manually with ofw_delete_draft.`;
644
+ // Partial-failure safety: the new draft is already created and
645
+ // cached, so BOTH drafts now exist. That is the correct end state —
646
+ // deleting first and failing to create would have lost the content.
647
+ replaceNote = `WARNING: New draft ${newId} was created successfully, but the old draft ${args.messageId} could NOT be deleted: ${e.message}. BOTH drafts now exist on OurFamilyWizard and nothing was lost. Verify ${newId} reads correctly, then remove ${args.messageId} with ofw_delete_draft.`;
460
648
  }
461
649
  }
462
650
  }
463
- const responseObj = persisted ?? raw;
651
+ // The draft was just re-fetched from OFW by postMessageAndRefetch, so this
652
+ // one row IS server-confirmed regardless of the drafts folder's overall
653
+ // cache freshness.
654
+ const responseObj = persisted !== null
655
+ ? { ...persisted, revision: newRevision, cacheStatus: 'fresh', serverConfirmed: true }
656
+ : raw;
464
657
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
465
- const notes = [rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join('\n\n');
658
+ const notes = [forceNote, rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join('\n\n');
466
659
  return textResponse(notes ? `${notes}\n\n${text}` : text);
467
660
  });
468
661
  if (allowDrafts)
469
662
  server.registerTool('ofw_delete_draft', {
470
- description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.',
663
+ description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache. Before deleting, the draft is re-read from OFW and the delete is REFUSED if it changed since you last read it (the current server body is returned so nothing is lost) — pass expectedRevision to assert which version you mean, or force:true to delete regardless.',
471
664
  annotations: { destructiveHint: true },
472
665
  inputSchema: {
473
666
  messageId: z.number().describe('Draft message ID to delete'),
667
+ expectedRevision: z.string().describe('The `revision` you got from ofw_list_drafts/ofw_get_message. Asserts you are deleting THAT version; if the draft changed on OFW since, the delete is refused and the current server body returned.').optional(),
668
+ force: z.boolean().describe('Default false. Delete even if the draft changed on OurFamilyWizard since you read it. The discarded server version is echoed back in the response.').optional(),
474
669
  },
475
670
  }, async (args) => {
671
+ const cache = cacheProvider();
672
+ const guard = await guardDestructiveDraftOp({
673
+ cache,
674
+ draftId: args.messageId,
675
+ expectedRevision: args.expectedRevision,
676
+ force: args.force ?? false,
677
+ action: 'delete',
678
+ });
679
+ if (!guard.ok)
680
+ return guard.response;
476
681
  const data = await deleteOFWMessages(client, [args.messageId]);
477
- await cacheProvider().deleteDraft(args.messageId);
478
- return data ? jsonResponse(data) : textResponse('Draft deleted.');
682
+ await cache.deleteDraft(args.messageId);
683
+ const text = data ? JSON.stringify(data, null, 2) : 'Draft deleted.';
684
+ return textResponse(guard.note ? `${guard.note}\n\n${text}` : text);
479
685
  });
480
686
  server.registerTool('ofw_get_unread_sent', {
481
687
  description: 'List sent messages that have not been read by one or more recipients. Reads from local cache; call ofw_sync_messages first if cache is stale.',
@@ -487,9 +693,18 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
487
693
  }, async (args) => {
488
694
  const page = args.page ?? 1;
489
695
  const size = args.size ?? 50;
490
- const sent = await cacheProvider().listMessages({ folder: 'sent', page, size });
696
+ const cache = cacheProvider();
697
+ const sent = await cache.listMessages({ folder: 'sent', page, size });
698
+ // "Nobody has read it yet" is a present-tense claim drawn entirely from
699
+ // cached view timestamps, which only move when a sync refreshes them —
700
+ // so it needs the same age label as any other cached read.
701
+ const freshness = await buildFreshness(cache, { source: 'cache', folders: ['sent'] });
491
702
  if (sent.length === 0) {
492
- return jsonResponse({ note: 'Sent cache is empty. Call ofw_sync_messages to populate.' });
703
+ return jsonResponse({
704
+ unread: [],
705
+ freshness,
706
+ note: 'Sent cache is empty. Call ofw_sync_messages to populate. An empty cache is NOT evidence that no sent messages exist.',
707
+ });
493
708
  }
494
709
  const unread = [];
495
710
  for (const msg of sent) {
@@ -499,9 +714,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
499
714
  }
500
715
  }
501
716
  if (unread.length === 0) {
502
- return jsonResponse({ message: 'All scanned sent messages have been read.' });
717
+ return jsonResponse({
718
+ unread: [],
719
+ freshness,
720
+ 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.',
721
+ });
503
722
  }
504
- return jsonResponse(unread);
723
+ return jsonResponse({ unread, freshness });
505
724
  });
506
725
  if (allowDrafts)
507
726
  server.registerTool('ofw_upload_attachment', {
@@ -548,18 +767,24 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
548
767
  });
549
768
  });
550
769
  server.registerTool('ofw_download_attachment', {
551
- 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).',
770
+ 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).',
552
771
  annotations: { readOnlyHint: false },
553
772
  inputSchema: {
554
773
  fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
555
- 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(),
556
- 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(),
774
+ 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(),
775
+ 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(),
557
776
  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(),
558
777
  },
559
778
  }, async (args) => {
560
779
  const fileId = args.fileId;
561
780
  const cache = cacheProvider();
562
- const inline = args.inline ?? getDefaultInlineAttachments();
781
+ const requestedInline = args.inline ?? getDefaultInlineAttachments();
782
+ // When the deployment has no filesystem (hosted connector), inline is the
783
+ // ONLY path to the bytes — force it rather than erroring on a disk write.
784
+ // `forcedInline` records that we overrode an explicit `inline:false` so the
785
+ // response is honest about it instead of silently ignoring the argument.
786
+ const inline = requestedInline || !attachmentIO.supportsDisk;
787
+ const forcedInline = inline && !requestedInline;
563
788
  let cached = await cache.getAttachment(fileId);
564
789
  if (!cached) {
565
790
  // Not in cache. Fetch metadata and store under the messageId=0
@@ -573,7 +798,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
573
798
  if (inline) {
574
799
  // Reuse on-disk bytes if we already have them; otherwise fetch fresh.
575
800
  let bytes = null;
576
- let mimeType = cached.mimeType;
801
+ let headerMime = cached.mimeType;
577
802
  let fileName = cached.fileName;
578
803
  if (cached.downloadedPath) {
579
804
  bytes = attachmentIO.readDownloaded(cached.downloadedPath);
@@ -581,14 +806,25 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
581
806
  if (bytes === null) {
582
807
  const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
583
808
  bytes = response.body;
584
- mimeType = response.contentType ?? cached.mimeType;
809
+ headerMime = response.contentType ?? cached.mimeType;
585
810
  fileName = response.suggestedFileName ?? cached.fileName;
586
811
  }
812
+ // Normalize to a bare media type: sniff the bytes first (OFW tacks a bogus
813
+ // charset onto binaries), then fall back to the stripped header, then the
814
+ // extension. A parameter suffix would make the host reject an image.
815
+ const mimeType = resolveDownloadMime(bytes, headerMime, fileName);
587
816
  const base64 = bytes.toString('base64');
588
- const metaBlock = { type: 'text', text: JSON.stringify({
589
- fileId, fileName, mimeType, sizeBytes: bytes.length, mode: 'inline',
590
- }, null, 2) };
591
- if (mimeType.startsWith('image/')) {
817
+ const meta = {
818
+ fileId, fileName, mimeType, sizeBytes: bytes.length, mode: 'inline',
819
+ };
820
+ if (forcedInline)
821
+ meta.forcedInline = true;
822
+ const metaBlock = { type: 'text', text: JSON.stringify(meta, null, 2) };
823
+ // Only host-renderable image types go back as ImageContent (with the bare
824
+ // media type the renderer accepts); everything else — non-renderable
825
+ // images included — goes back as an EmbeddedResource so the caller always
826
+ // gets the bytes.
827
+ if (isHostRenderableImage(mimeType)) {
592
828
  return { content: [metaBlock, { type: 'image', data: base64, mimeType }] };
593
829
  }
594
830
  return { content: [metaBlock, { type: 'resource', resource: {
@@ -614,38 +850,172 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
614
850
  }
615
851
  if (!args.force && cached.downloadedPath === dest) {
616
852
  return jsonResponse({
617
- fileId, path: dest, mimeType: cached.mimeType, sizeBytes: cached.sizeBytes,
618
- fileName: cached.fileName, note: 'already downloaded',
853
+ // No bytes on hand for the no-op case: normalize the cached/extension
854
+ // MIME (empty buffer sniffs nothing) so a stored `image/png;charset=…`
855
+ // still reports bare.
856
+ fileId, path: dest, mimeType: resolveDownloadMime(Buffer.alloc(0), cached.mimeType, cached.fileName),
857
+ sizeBytes: cached.sizeBytes, fileName: cached.fileName, note: 'already downloaded',
619
858
  });
620
859
  }
621
860
  const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
622
861
  attachmentIO.writeDownload(dest, response.body);
623
862
  await cache.markAttachmentDownloaded(fileId, dest);
863
+ const fileName = response.suggestedFileName ?? cached.fileName;
624
864
  return jsonResponse({
625
865
  fileId,
626
866
  path: dest,
627
- mimeType: response.contentType ?? cached.mimeType,
867
+ mimeType: resolveDownloadMime(response.body, response.contentType ?? cached.mimeType, fileName),
628
868
  sizeBytes: response.body.length,
629
- fileName: response.suggestedFileName ?? cached.fileName,
869
+ fileName,
630
870
  });
631
871
  });
632
872
  server.registerTool('ofw_sync_messages', {
633
873
  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).',
634
874
  annotations: { readOnlyHint: false },
635
875
  inputSchema: {
636
- folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).describe('Folders to sync (default: all three)').optional(),
876
+ 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(),
637
877
  fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.').optional(),
638
878
  deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
639
879
  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(),
640
880
  },
641
881
  }, async (args) => {
882
+ const cache = cacheProvider();
642
883
  const result = await syncAll(client, {
643
884
  folders: args.folders,
644
885
  fetchUnreadBodies: args.fetchUnreadBodies,
645
886
  deep: args.deep,
646
887
  maxRequests: args.maxRequests ?? getSyncMaxRequests(),
647
- }, cacheProvider());
648
- return jsonResponse(result);
888
+ }, cache);
889
+ // Freshness of the cache AS OF this sync completing — so a paused call
890
+ // that skipped a folder says so here too, not just in `notRefreshed`.
891
+ const freshness = await buildFreshness(cache, {
892
+ source: 'cache',
893
+ folders: args.folders ?? ['inbox', 'sent', 'drafts'],
894
+ });
895
+ return jsonResponse({ ...result, freshness });
896
+ });
897
+ server.registerTool('ofw_check_freshness', {
898
+ 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.',
899
+ annotations: { readOnlyHint: true },
900
+ inputSchema: {
901
+ 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(),
902
+ 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(),
903
+ 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(),
904
+ },
905
+ }, async (args) => {
906
+ const cache = cacheProvider();
907
+ const allowMarkRead = args.allowMarkRead ?? false;
908
+ const requestedIds = args.messageIds ?? [];
909
+ const ids = requestedIds.slice(0, MAX_FRESHNESS_IDS);
910
+ // Folders default to "all three" only when the caller asked about nothing
911
+ // else; an ids-only call shouldn't silently spend a request on folders.
912
+ const wantFolders = args.folders
913
+ ?? (requestedIds.length > 0 ? [] : ['inbox', 'sent', 'drafts']);
914
+ let requestsUsed = 0;
915
+ const folders = [];
916
+ if (wantFolders.length > 0) {
917
+ requestsUsed++;
918
+ const data = parseLenient(FolderCountsSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), { label: 'ofw-mcp', context: 'GET /pub/v1/messageFolders (ofw_check_freshness)' });
919
+ const sys = data.systemFolders ?? [];
920
+ for (const folder of wantFolders) {
921
+ const entry = sys.find((x) => x.folderType === FOLDER_TYPE[folder]);
922
+ const serverCount = entry?.totalCount ?? entry?.messageCount ?? entry?.count ?? null;
923
+ const cachedCount = folder === 'drafts'
924
+ ? (await cache.listDraftIds()).length
925
+ : await cache.countMessages({ folder });
926
+ const state = await cache.getSyncState(folder);
927
+ const historyComplete = state !== null && state.resumePage === null;
928
+ // A partially backfilled folder legitimately holds fewer messages than
929
+ // the server, so a count mismatch there proves nothing. Report both
930
+ // numbers and leave the verdict null rather than crying wolf for the
931
+ // entire duration of a backfill.
932
+ const inSync = serverCount === null || !historyComplete
933
+ ? null
934
+ : serverCount === cachedCount;
935
+ folders.push({
936
+ folder,
937
+ existsOnServer: entry !== undefined,
938
+ serverCount,
939
+ cachedCount,
940
+ historyComplete,
941
+ lastVerifiedAt: await getFolderVerifiedAt(cache, folder),
942
+ inSync,
943
+ ...(inSync === null
944
+ ? { note: serverCount === null
945
+ ? 'OFW did not report a count for this folder, so cached-vs-server cannot be compared. Use the per-id check instead.'
946
+ : 'Older history is still being backfilled, so a lower cachedCount is expected and does not indicate drift.' }
947
+ : {}),
948
+ });
949
+ }
950
+ }
951
+ const items = [];
952
+ for (const id of ids) {
953
+ const cachedDraft = await cache.getDraft(id);
954
+ // Drafts have no read state, so probing one is genuinely side-effect
955
+ // free. Any other id means GET /pub/v3/messages/{id}, which marks an
956
+ // unread inbox message read on OFW — a permanent change to a
957
+ // court-visible record. Refuse by default rather than quietly doing it.
958
+ if (cachedDraft === null && !allowMarkRead) {
959
+ items.push({
960
+ id,
961
+ skipped: true,
962
+ reason: 'NOT_A_CACHED_DRAFT',
963
+ 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.',
964
+ });
965
+ continue;
966
+ }
967
+ requestsUsed++;
968
+ try {
969
+ const server = await fetchServerDraft(client, id);
970
+ const cacheRevision = cachedDraft === null ? null : draftRevision(cachedDraft);
971
+ if (server === null) {
972
+ items.push({
973
+ id,
974
+ existsOnServer: false,
975
+ inSync: false,
976
+ cacheRevision,
977
+ serverRevision: null,
978
+ note: cachedDraft === null
979
+ ? 'Not found on OurFamilyWizard.'
980
+ : '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.',
981
+ });
982
+ continue;
983
+ }
984
+ const serverRevision = draftRevision(server);
985
+ items.push({
986
+ id,
987
+ existsOnServer: true,
988
+ cacheRevision,
989
+ serverRevision,
990
+ inSync: cacheRevision !== null && cacheRevision === serverRevision,
991
+ ...(cacheRevision === null
992
+ ? { note: 'Exists on OurFamilyWizard but is not in the local cache.' }
993
+ : cacheRevision !== serverRevision
994
+ ? { note: 'Content differs from the cache — it was edited on OurFamilyWizard since the last sync. Run ofw_sync_messages before reading or writing it.' }
995
+ : {}),
996
+ });
997
+ }
998
+ catch (e) {
999
+ // A check that could not run must not read as "in sync".
1000
+ items.push({
1001
+ id,
1002
+ error: 'FRESHNESS_CHECK_FAILED',
1003
+ message: e.message,
1004
+ inSync: null,
1005
+ note: 'The freshness check itself failed, so nothing is confirmed either way.',
1006
+ });
1007
+ }
1008
+ }
1009
+ const payload = {
1010
+ checkedAt: new Date().toISOString(),
1011
+ requestsUsed,
1012
+ ...(folders.length > 0 ? { folders } : {}),
1013
+ ...(items.length > 0 ? { items } : {}),
1014
+ };
1015
+ if (requestedIds.length > ids.length) {
1016
+ 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.`;
1017
+ }
1018
+ return jsonResponse(payload);
649
1019
  });
650
1020
  }
651
1021
  // OFW's bulk-delete endpoint takes a multipart form with `messageIds`.