ofw-mcp 2.7.1 → 2.9.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.
@@ -2,11 +2,13 @@ import { z } from 'zod';
2
2
  import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage, getDraftsCacheStatus } from '../sync.js';
3
3
  import { buildFreshness } from './freshness.js';
4
4
  import { checkDraftFreshness, draftRevision, fetchServerDraft, staleDraftPayload, } from './draft-freshness.js';
5
+ import { FOLDER_TYPE, newDraftKey, persistFolderIds, probeIds, resolveDraftKey } from './lifecycle.js';
5
6
  import { getFolderVerifiedAt } from '../sync.js';
6
- import { isHostRenderableImage, resolveDownloadMime } from './attachments.js';
7
- import { getAttachmentsDir, getDefaultInlineAttachments, getSyncMaxRequests, getWriteMode } from '../config.js';
7
+ import { buildInlineDelivery, tryExtract } from './delivery.js';
8
+ import { resolveDownloadMime } from './attachments.js';
9
+ import { getAllowMarkRead, getAttachmentsDir, getAutoRefreshStaleReads, getDefaultInlineAttachments, getFetchUnreadBodies, getSyncMaxRequests, getWriteMode, } from '../config.js';
8
10
  import { basename, join } from 'node:path';
9
- import { ApiRecipientSchema, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
11
+ import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
10
12
  import { parseLenient } from '@chrischall/mcp-utils';
11
13
  // Schemas for the load-bearing fields of each /pub/v3 response this file
12
14
  // reads (issue #83). Loose: unknown keys pass through into cached listData.
@@ -44,7 +46,10 @@ const MessageDetailSchema = z.looseObject({
44
46
  // The detail payload carries its own owning folder ({id, name}). We read the
45
47
  // id to label a live-fetched message sent-vs-inbox instead of blindly
46
48
  // defaulting to inbox — see the folder derivation in ofw_get_message.
47
- folder: z.looseObject({ id: z.number() }).optional(),
49
+ // Same union as ServerDraftSchema's, for the same reason — OFW types this id
50
+ // as a string on the folders listing and a number on message detail. Lenient
51
+ // here, so a mismatch only warns, but it would warn on EVERY live fetch.
52
+ folder: z.looseObject({ id: z.union([z.string(), z.number()]) }).optional(),
48
53
  });
49
54
  // Attachment-backfill detail fetch reads only `files`.
50
55
  const DetailFilesSchema = z.looseObject({ files: z.array(z.number()).optional() });
@@ -61,11 +66,6 @@ const FolderCountsSchema = z.looseObject({
61
66
  count: z.number().optional(),
62
67
  })).optional(),
63
68
  });
64
- const FOLDER_TYPE = {
65
- inbox: 'INBOX',
66
- sent: 'SENT_MESSAGES',
67
- drafts: 'DRAFTS',
68
- };
69
69
  /**
70
70
  * Cap on per-id probes in one ofw_check_freshness call.
71
71
  *
@@ -125,6 +125,106 @@ async function draftsFreshness(cache) {
125
125
  : 'unverified';
126
126
  return { freshness, serverConfirmed: cacheStatus === 'fresh', cacheStatus };
127
127
  }
128
+ /**
129
+ * Description shared by every read tool's `autoRefresh` argument, so the escape
130
+ * hatch from an UNVERIFIED_EMPTY refusal reads identically wherever it appears.
131
+ */
132
+ const AUTO_REFRESH_DESC = 'If the result comes back EMPTY from a cache that is not verified-fresh, sync the backing folders first and answer from the refreshed cache instead of refusing. Defaults to the OFW_AUTO_REFRESH env var (false unless set), in which case the call refuses with result:"UNVERIFIED_EMPTY" and names the remedy. Costs OFW requests when it fires.';
133
+ /**
134
+ * Run a cached read, and never let it answer "nothing there" on the strength of
135
+ * a cache that cannot vouch for itself.
136
+ *
137
+ * The failure this closes: an empty result set carrying a 207-minute-old
138
+ * `freshness` block is shaped IDENTICALLY to a verified "nothing matched". Skim
139
+ * past the warning and the natural next sentence is "no, that message was never
140
+ * sent" — a false negative stated as fact about a court-visible record. A false
141
+ * negative is more dangerous than a refusal precisely because it reads as a
142
+ * definitive answer, so the bias is: refuse, and say how to get a real one.
143
+ *
144
+ * `autoRefresh` turns the refusal into a sync-then-retry. If that still cannot
145
+ * make the read verifiable (the budget paused, OFW was unreachable), the refusal
146
+ * fires anyway — a refresh that did not work must not be treated as one that did.
147
+ *
148
+ * Non-empty results are never touched: a stale cache that DID find something is
149
+ * evidence of presence, and its `freshness` block already labels its age.
150
+ */
151
+ async function guardedCacheRead(o) {
152
+ let value = await o.read();
153
+ let refreshed = false;
154
+ const unverifiable = (v) => o.isEmpty(v) && v.freshness.staleness !== 'fresh';
155
+ if (unverifiable(value) && o.autoRefresh) {
156
+ await syncAll(o.client, {
157
+ folders: o.folders,
158
+ // Same ceiling ofw_sync_messages applies: an automatic refresh must never
159
+ // stamp unread inbox messages as a side effect of a list read.
160
+ fetchUnreadBodies: getAllowMarkRead() && getFetchUnreadBodies(),
161
+ maxRequests: getSyncMaxRequests(),
162
+ }, o.cache);
163
+ refreshed = true;
164
+ value = await o.read();
165
+ }
166
+ return { value, refreshed, unverifiedEmpty: unverifiable(value) };
167
+ }
168
+ /** The structured non-result returned instead of an unverifiable empty list. */
169
+ function unverifiedEmptyResponse(input) {
170
+ const { freshness } = input;
171
+ const age = freshness.ageSeconds === null
172
+ ? 'it has never been checked against OurFamilyWizard'
173
+ : `it was last verified ${freshness.ageSeconds < 60 ? `${freshness.ageSeconds} sec` : `${Math.round(freshness.ageSeconds / 60)} min`} ago`;
174
+ const refreshClause = input.refreshed
175
+ ? ' An automatic refresh ran on this call and did NOT make the result verifiable (the sync paused or skipped this folder), so the refusal stands.'
176
+ : '';
177
+ return jsonErrorResponse({
178
+ result: 'UNVERIFIED_EMPTY',
179
+ reason: `No ${input.what} were found, but the backing cache is "${freshness.staleness}" — ${age}. Refusing to report absence from unverified data: an empty result from a stale cache is indistinguishable from a verified "nothing there", and repeating it as one asserts a false negative about a legal record.${refreshClause}`,
180
+ remedy: input.remedy,
181
+ complete: false,
182
+ freshness,
183
+ ...input.extra,
184
+ });
185
+ }
186
+ /**
187
+ * Decide whether fetching this message's body from OFW would stamp the record,
188
+ * and refuse when the caller (or the deployment) has opted out of that.
189
+ *
190
+ * Returns null to proceed, or a structured refusal to return as-is.
191
+ *
192
+ * Only ONE case actually stamps: fetching the body of an UNREAD INBOX message.
193
+ * Everything else is waved through, because refusing a read that changes
194
+ * nothing would be friction with no safety to show for it:
195
+ * - a SENT message — the "First Viewed" times on it belong to the recipient,
196
+ * and our own fetch never writes one;
197
+ * - an already-read inbox message — the stamp exists; re-reading cannot add
198
+ * a second one (`deriveRead` is monotonic, so this cannot flip back);
199
+ * - a cached body — this function is never reached, the cache served it.
200
+ *
201
+ * An id with NO cached row is refused: whether it would stamp is exactly what
202
+ * we cannot know without making the request that stamps it. Syncing first
203
+ * (which reads list pages, not bodies) resolves it.
204
+ */
205
+ export function markReadVerdict(cached, requested) {
206
+ const ceiling = getAllowMarkRead();
207
+ if (ceiling && (requested ?? true))
208
+ return null;
209
+ const wouldStamp = cached === null
210
+ || (cached.folder === 'inbox' && !deriveRead(cached));
211
+ if (!wouldStamp)
212
+ return null;
213
+ const because = ceiling
214
+ ? 'you passed allowMarkRead:false'
215
+ : 'this server runs with OFW_ALLOW_MARK_READ=false';
216
+ return jsonErrorResponse({
217
+ error: 'MARK_READ_BLOCKED',
218
+ messageId: cached?.id ?? null,
219
+ reason: cached === null
220
+ ? 'This id is not in the cache, so whether reading it would mark it read is unknowable without making the request that would.'
221
+ : 'This is an unread inbox message; fetching its body would mark it read on OurFamilyWizard.',
222
+ note: `Refused because ${because}. Reading a message for the first time stamps a "First Viewed" timestamp that your co-parent can see and that forms part of the record — it cannot be undone. To read it anyway, call again with allowMarkRead:true${ceiling ? '' : ' (which this deployment does not permit — clear OFW_ALLOW_MARK_READ to re-enable)'}.`,
223
+ ...(cached === null
224
+ ? { hint: 'Run ofw_sync_messages first: it walks list pages, not bodies, so it can tell you what this id is without stamping anything.' }
225
+ : { subject: cached.subject, fromUser: cached.fromUser, sentAt: cached.sentAt }),
226
+ });
227
+ }
128
228
  export function registerMessageTools(server, client, cacheProvider, attachmentIO) {
129
229
  // OFW_WRITE_MODE gate (see config.ts). Send lands on the court-visible
130
230
  // record, so it is 'all'-only; draft-level writes (save/delete drafts,
@@ -142,8 +242,8 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
142
242
  return jsonResponse({ folders: data, freshness });
143
243
  });
144
244
  server.registerTool('ofw_list_messages', {
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.',
146
- annotations: { readOnlyHint: true },
245
+ 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. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" — check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
246
+ annotations: { readOnlyHint: false },
147
247
  inputSchema: {
148
248
  folderId: z.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
149
249
  page: z.number().int().min(1).describe('Page number (default 1)').optional(),
@@ -151,6 +251,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
151
251
  since: z.string().describe('ISO date or datetime — only messages with sent_at >= since (inclusive)').optional(),
152
252
  until: z.string().describe('ISO date or datetime — only messages with sent_at < until (exclusive)').optional(),
153
253
  q: z.string().describe('Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.').optional(),
254
+ autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
154
255
  },
155
256
  }, async (args) => {
156
257
  const page = args.page ?? 1;
@@ -164,47 +265,84 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
164
265
  else if (folderArg === 'both')
165
266
  folder = undefined;
166
267
  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.
170
- return jsonResponse({
171
- messages: [],
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.',
268
+ // A rejected argument is an ERROR, not a result. Returning `messages: []`
269
+ // here — even with a note attached — hands back the one shape this whole
270
+ // mechanism exists to eliminate: an empty list that looks like an answer.
271
+ return jsonErrorResponse({
272
+ result: 'INVALID_FOLDER',
273
+ reason: `folderId must be "inbox", "sent", or "both" (got ${JSON.stringify(folderArg)}). Numeric OFW folder IDs are not supported by the cache.`,
274
+ remedy: 'Re-call with folderId omitted (searches both) or set to one of the three accepted names.',
275
+ complete: false,
276
+ note: 'No lookup was performed. This says NOTHING about what is in the cache — do not read it as "no messages".',
177
277
  });
178
278
  }
179
279
  const cache = cacheProvider();
280
+ const folders = folder === undefined ? ['inbox', 'sent'] : [folder];
180
281
  const filter = { folder, since: args.since, until: args.until, q: args.q };
181
- const total = await cache.countMessages(filter);
182
- // Reconcile each row's read state at read time: the cached list flags can be
183
- // stale (a message read after it was first scraped), so `read` is derived
184
- // from the record's own `viewedAt`/`fetchedBodyAt` and `listData` is forced
185
- // to agree — see withReadState.
186
- const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
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],
282
+ const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
283
+ client,
284
+ cache,
285
+ folders,
286
+ autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
287
+ isEmpty: (v) => v.total === 0,
288
+ read: async () => {
289
+ const total = await cache.countMessages(filter);
290
+ // Reconcile each row's read state at read time: the cached list flags
291
+ // can be stale (a message read after it was first scraped), so `read`
292
+ // is derived from the record's own `viewedAt`/`fetchedBodyAt` and
293
+ // `listData` is forced to agree — see withReadState.
294
+ const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
295
+ // Served from the local cache, so the result must say how old it is and
296
+ // whether anything vouches for it — a caller cannot state current state
297
+ // from this payload without either re-reading or surfacing the caveat.
298
+ const freshness = await buildFreshness(cache, { source: 'cache', folders });
299
+ return { messages, total, freshness };
300
+ },
193
301
  });
194
- const payload = { messages, total, page, size, freshness };
302
+ if (unverifiedEmpty) {
303
+ return unverifiedEmptyResponse({
304
+ what: 'messages matching these filters',
305
+ freshness: value.freshness,
306
+ refreshed,
307
+ remedy: `Call ofw_sync_messages(folders:${JSON.stringify(folders)}) and retry, or re-call this tool with autoRefresh:true. ofw_check_freshness is the cheap live alternative when you only need to confirm a specific message.`,
308
+ extra: { page, size, filters: { folderId: folderArg, since: args.since, until: args.until, q: args.q } },
309
+ });
310
+ }
311
+ const { messages, total, freshness } = value;
312
+ // `complete` describes the RESULT SET, not the sync: true only when this
313
+ // payload holds every matching message the server has. `syncComplete` /
314
+ // `historyComplete` in `freshness` describe the walk that filled the cache
315
+ // and cannot answer "have I now seen all of them?" on their own — a caller
316
+ // needs one boolean to check before saying "you have N messages".
317
+ const fullSlice = page === 1 && messages.length === total;
318
+ const complete = fullSlice && freshness.staleness === 'fresh' && freshness.historyComplete;
319
+ const payload = { messages, total, page, size, complete, freshness };
320
+ if (!complete) {
321
+ payload.completeNote = [
322
+ !fullSlice ? `this page holds ${messages.length} of ${total} matching cached messages` : null,
323
+ freshness.staleness !== 'fresh' ? `the cache is "${freshness.staleness}", so newer messages may exist on OurFamilyWizard` : null,
324
+ !freshness.historyComplete ? 'older history is still being backfilled, so the cache does not yet hold every message' : null,
325
+ ].filter((r) => r !== null)
326
+ .join('; ')
327
+ .concat('. Do not state a total or an absence from this result without resolving that first.');
328
+ }
195
329
  if (total === 0) {
196
- payload.note = 'No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.';
330
+ payload.note = 'No messages match these filters, and the cache IS verified-fresh for these folders — so this is a real "nothing matched", not a stale-cache artefact. If you expected results, relax the filters.';
197
331
  }
198
332
  else if (page * size < total) {
199
333
  payload.note = `Showing ${(page - 1) * size + 1}–${(page - 1) * size + messages.length} of ${total}. Increase 'page' to see more, or narrow with since/until/q.`;
200
334
  }
335
+ if (refreshed) {
336
+ payload.autoRefreshed = true;
337
+ }
201
338
  return jsonResponse(payload);
202
339
  });
203
340
  server.registerTool('ofw_get_message', {
204
- description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW (which will mark unread inbox messages as read on OFW). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) — drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
341
+ description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW — and for an UNREAD INBOX message that fetch marks it read and stamps a "First Viewed" time the co-parent can see, which is part of the record and cannot be undone. Pass allowMarkRead:false to refuse such a fetch instead (cached bodies, sent messages and already-read messages are unaffected, because none of them stamp anything). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) — drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
205
342
  annotations: { readOnlyHint: false },
206
343
  inputSchema: {
207
344
  messageId: z.string().describe('Message ID (also accepts draft IDs — drafts are routed via the drafts cache)'),
345
+ allowMarkRead: z.boolean().describe('Default true (the long-standing behaviour). Set false to refuse a fetch that would mark an unread INBOX message as READ on OurFamilyWizard — an irreversible, co-parent-visible change to the record. Reads that cannot stamp anything (a cached body, a sent message, an already-read message) still succeed. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this argument cannot raise.').optional(),
208
346
  },
209
347
  }, async (args) => {
210
348
  const id = Number(args.messageId);
@@ -237,6 +375,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
237
375
  // Concurrency token — pass as expectedRevision to ofw_save_draft /
238
376
  // ofw_delete_draft to assert you are editing THIS version.
239
377
  revision: draftRevision(draftRow),
378
+ // Stable logical identity. Survives the create-then-delete id churn of
379
+ // editing AND the transition to sent — pass it to ofw_status to ask
380
+ // "what happened to the thing I was working on?". Null when this draft
381
+ // was never written through this tool (e.g. authored in the web app).
382
+ draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
240
383
  cacheStatus,
241
384
  // False = this draft's existence and unsent status are remembered from
242
385
  // a cache, not confirmed on OFW. Call ofw_check_freshness before
@@ -299,6 +442,14 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
299
442
  const freshness = await buildFreshness(cache, { source: 'cache', folders: [row.folder] });
300
443
  return jsonResponse({ ...withReadState(row), attachments, freshness });
301
444
  }
445
+ // Everything above this line was served without asking OFW for a body.
446
+ // This is the one path that fetches one — and fetching the body of an
447
+ // unread INBOX message marks it read on OFW, stamping a "First Viewed"
448
+ // time the co-parent can see. That is a court-visible, irreversible change
449
+ // made as a side effect of an ordinary read, so it gets an explicit gate.
450
+ const markReadCheck = markReadVerdict(cached, args.allowMarkRead);
451
+ if (markReadCheck !== null)
452
+ return markReadCheck;
302
453
  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)' });
303
454
  // Derive the folder for a live-fetched message. A cached row (reached here
304
455
  // only when its body was NULL) already knows its folder, so keep it.
@@ -415,6 +566,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
415
566
  }, SentDetailSchema, 'ofw_send_message');
416
567
  let persisted = null;
417
568
  let verifyNote = null;
569
+ let sentDraftKey = null;
418
570
  if (newId !== null) {
419
571
  verifyNote = verifyWriteLanded('message', { subject, body }, detail);
420
572
  persisted = {
@@ -431,6 +583,21 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
431
583
  listData: detail,
432
584
  };
433
585
  await cache.upsertMessage(persisted);
586
+ // Extend the draft's identity chain onto the SENT message. Without this
587
+ // the chain would dead-end at the last draft id and "what happened to the
588
+ // draft I was editing?" would answer `deleted` — technically true of that
589
+ // id, and the exact wrong impression. With it, resolving the key lands on
590
+ // the sent message and reports state:"sent" with its sentAt.
591
+ if (draftRef !== undefined) {
592
+ const prior = await cache.getDraftLineageById(draftRef);
593
+ const now = new Date().toISOString();
594
+ const key = prior?.draftKey ?? newDraftKey();
595
+ if (prior === null) {
596
+ await cache.recordDraftLineage({ id: draftRef, draftKey: key, previousId: null, recordedAt: now });
597
+ }
598
+ await cache.recordDraftLineage({ id: newId, draftKey: key, previousId: draftRef, recordedAt: now });
599
+ sentDraftKey = key;
600
+ }
434
601
  // Link attached files to the new message in the attachments cache.
435
602
  // We may not have full metadata if the upload happened in a prior
436
603
  // session — fall back to what we know.
@@ -461,7 +628,9 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
461
628
  await deleteOFWMessages(client, [draftRef]);
462
629
  await cache.deleteDraft(draftRef);
463
630
  }
464
- const responseObj = persisted ?? raw;
631
+ const responseObj = persisted === null
632
+ ? raw
633
+ : { ...persisted, ...(sentDraftKey !== null ? { draftKey: sentDraftKey, previousId: draftRef } : {}) };
465
634
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
466
635
  const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join('\n\n');
467
636
  return textResponse(notes ? `${notes}\n\n${text}` : text);
@@ -480,10 +649,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
480
649
  server = await fetchServerDraft(client, draftId);
481
650
  }
482
651
  catch (e) {
483
- // fetchServerDraft funnels every non-404 failure into DraftFreshnessError,
484
- // so anything landing here means the check could not RUN. That is not
485
- // permission to proceed: a transient 5xx must not degrade into a blind
486
- // overwrite.
652
+ // Anything landing here means the check could not RUN. Most failures
653
+ // arrive as DraftFreshnessError from fetchServerDraft, but not all of
654
+ // them: a strict parseLenient mismatch on the server draft throws
655
+ // McpToolError instead. Both are caught, and both abort — which is the
656
+ // point. A failed check is not permission to proceed: a transient 5xx
657
+ // must not degrade into a blind overwrite. (The cast below only reads
658
+ // `.message`, which every Error carries.)
487
659
  const reason = e.message;
488
660
  if (force) {
489
661
  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.` };
@@ -532,38 +704,72 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
532
704
  };
533
705
  }
534
706
  server.registerTool('ofw_list_drafts', {
535
- description: 'List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.',
536
- annotations: { readOnlyHint: true },
707
+ description: 'List draft messages from the local OurFamilyWizard cache. Returns an explicit `complete` boolean describing the RESULT SET: true means "these are ALL the drafts on OurFamilyWizard as of freshness.asOf" — check it before saying "you have N drafts". Each draft carries its `draftKey` (stable across the create-then-delete churn of editing) when one is known. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY"); pass autoRefresh:true to sync and answer instead. For a live, one-call answer prefer ofw_status(includeDraftInventory:true).',
708
+ annotations: { readOnlyHint: false },
537
709
  inputSchema: {
538
710
  page: z.number().int().min(1).describe('Page number (default 1)').optional(),
539
711
  size: z.number().int().min(1).describe('Drafts per page (default 50)').optional(),
712
+ autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
540
713
  },
541
714
  }, async (args) => {
542
715
  const page = args.page ?? 1;
543
716
  const size = args.size ?? 50;
544
717
  const cache = cacheProvider();
545
- const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
546
- const rows = await cache.listDrafts({ page, size });
547
- // Every draft carries the concurrency token to echo back on a write, plus
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
- }));
557
- if (drafts.length === 0) {
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.',
718
+ const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
719
+ client,
720
+ cache,
721
+ folders: ['drafts'],
722
+ autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
723
+ isEmpty: (v) => v.total === 0,
724
+ read: async () => {
725
+ const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
726
+ const rows = await cache.listDrafts({ page, size });
727
+ const total = await cache.countDrafts();
728
+ // One batch lookup for the whole page — on the Durable Object backend a
729
+ // per-draft lineage read would be a subrequest each.
730
+ const keyById = new Map((await cache.getDraftLineageByIds(rows.map((d) => d.id))).map((l) => [l.id, l.draftKey]));
731
+ // Every draft carries the concurrency token to echo back on a write, its
732
+ // stable identity across edits, and whether the last sync actually
733
+ // compared this cache against OFW.
734
+ const drafts = rows.map((d) => ({
735
+ ...d,
736
+ revision: draftRevision(d),
737
+ draftKey: keyById.get(d.id) ?? null,
738
+ cacheStatus,
739
+ serverConfirmed,
740
+ asOf: freshness.asOf,
741
+ }));
742
+ return { drafts, total, freshness, serverConfirmed };
743
+ },
744
+ });
745
+ if (unverifiedEmpty) {
746
+ return unverifiedEmptyResponse({
747
+ what: 'drafts',
748
+ freshness: value.freshness,
749
+ refreshed,
750
+ remedy: 'Call ofw_sync_messages(folders:["drafts"]) and retry, re-call with autoRefresh:true, or use ofw_status(includeDraftInventory:true) for a single live answer.',
751
+ extra: { page, size },
562
752
  });
563
753
  }
564
- const payload = { drafts, freshness };
754
+ const { drafts, total, freshness, serverConfirmed } = value;
755
+ // True only when this payload IS the full server-side draft set: verified
756
+ // against OFW inside the freshness window AND not a slice of a larger list.
757
+ const fullSlice = page === 1 && drafts.length === total;
758
+ const complete = serverConfirmed && fullSlice;
759
+ const payload = { drafts, total, page, size, complete, freshness };
760
+ if (!complete) {
761
+ payload.completeNote = [
762
+ !fullSlice ? `this page holds ${drafts.length} of ${total} cached drafts` : null,
763
+ !serverConfirmed ? 'the drafts cache has not been confirmed against OurFamilyWizard inside the freshness window' : null,
764
+ ].filter((r) => r !== null)
765
+ .join('; ')
766
+ .concat('. Do NOT state a draft count from this result — call ofw_status(includeDraftInventory:true) for a live, complete one.');
767
+ }
565
768
  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.';
769
+ 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, deleted or SENT in the OFW web app bump no timestamp, so the cache cannot detect it on its own. Call ofw_status / 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.';
770
+ }
771
+ if (refreshed) {
772
+ payload.autoRefreshed = true;
567
773
  }
568
774
  return jsonResponse(payload);
569
775
  });
@@ -628,6 +834,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
628
834
  let replaceNote = null;
629
835
  let verifyNote = null;
630
836
  let newRevision = null;
837
+ let draftKey = null;
631
838
  // Fields accepted on the write that the saved draft must carry — or their
632
839
  // loss must be reported. Never a silent drop (Defect 3).
633
840
  const warnings = [];
@@ -655,11 +862,48 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
655
862
  // The revision is now computed from the server-authoritative detail, so it
656
863
  // is the value a subsequent read/verify will observe (Defect 1).
657
864
  newRevision = draftRevision(persisted);
865
+ // Carry the logical identity across the id change. Replacing a draft mints
866
+ // a NEW OFW id every time (create-then-delete — see the note below), so
867
+ // ten edits produced ten unrelated ids and there was no way to ask what
868
+ // became of the one you started with. The key is minted on first sight —
869
+ // including retroactively for the draft being replaced, so a draft that
870
+ // predates this mechanism joins a chain the moment it is edited.
871
+ const now = new Date().toISOString();
872
+ if (args.messageId !== undefined) {
873
+ const prior = await cache.getDraftLineageById(args.messageId);
874
+ if (prior !== null) {
875
+ draftKey = prior.draftKey;
876
+ }
877
+ else {
878
+ draftKey = newDraftKey();
879
+ await cache.recordDraftLineage({
880
+ id: args.messageId, draftKey, previousId: null, recordedAt: now,
881
+ });
882
+ }
883
+ }
884
+ else {
885
+ draftKey = newDraftKey();
886
+ }
887
+ await cache.recordDraftLineage({
888
+ id: newId,
889
+ draftKey,
890
+ previousId: args.messageId ?? null,
891
+ recordedAt: now,
892
+ });
658
893
  // Audit every field the caller supplied against what actually landed, so a
659
894
  // silent normalization becomes a visible warning rather than a surprise.
660
895
  if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
661
896
  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.`);
897
+ // Two different outcomes reach this branch, and they need different
898
+ // warnings. OFW either DROPPED the link (null) or RE-TARGETED it to
899
+ // another message in the thread. Describing both as "did not thread
900
+ // this draft (its inReplyTo/showContext will be empty)" contradicted
901
+ // the non-null inReplyTo the same response echoes — and a warning the
902
+ // caller can see is false is a warning it learns to skip.
903
+ const outcome = effectiveReplyTo === null
904
+ ? 'OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped.'
905
+ : `OurFamilyWizard re-targeted the reply to message ${effectiveReplyTo} instead. The draft IS threaded — to that message, not the one requested — and the inReplyTo in this response reflects where it actually landed.`;
906
+ warnings.push(`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? 'null' : effectiveReplyTo} — ${outcome} If threading matters, verify on ourfamilywizard.com.`);
663
907
  }
664
908
  // Only warn on recipients/attachments when the detail actually reported
665
909
  // them — an omitted array is "not echoed", not "dropped", and crying wolf
@@ -703,6 +947,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
703
947
  ...persisted,
704
948
  inReplyTo: persisted.replyToId,
705
949
  revision: newRevision,
950
+ // The id above is volatile — it changes on every edit. `draftKey` is
951
+ // not: pass it to ofw_status to resolve the chain's CURRENT id, or to
952
+ // find out that the draft was sent and when.
953
+ draftKey,
954
+ previousId: args.messageId ?? null,
706
955
  cacheStatus: 'fresh',
707
956
  serverConfirmed: true,
708
957
  ...(warnings.length > 0 ? { warnings } : {}),
@@ -742,28 +991,49 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
742
991
  return textResponse(guard.note ? `${guard.note}\n\n${text}` : text);
743
992
  });
744
993
  server.registerTool('ofw_get_unread_sent', {
745
- 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.',
746
- annotations: { readOnlyHint: true },
994
+ description: 'List sent messages that have not been read by one or more recipients. Reads from local cache. Returns `complete` describing whether every sent message was scanned. An empty SENT cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as "nothing sent"; pass autoRefresh:true to sync and answer instead.',
995
+ annotations: { readOnlyHint: false },
747
996
  inputSchema: {
748
997
  page: z.number().int().min(1).describe('Page (default 1)').optional(),
749
998
  size: z.number().int().min(1).describe('Per page (default 50)').optional(),
999
+ autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
750
1000
  },
751
1001
  }, async (args) => {
752
1002
  const page = args.page ?? 1;
753
1003
  const size = args.size ?? 50;
754
1004
  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'] });
760
- if (sent.length === 0) {
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.',
1005
+ const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
1006
+ client,
1007
+ cache,
1008
+ folders: ['sent'],
1009
+ autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
1010
+ // The guard is about the CACHE being empty, not the verdict. "You have
1011
+ // no sent messages" is an absence claim a stale cache cannot support;
1012
+ // "all of them are read" is a verdict over messages we did see, and it is
1013
+ // labelled by `freshness` and `complete` as before.
1014
+ isEmpty: (v) => v.total === 0,
1015
+ read: async () => {
1016
+ const sent = await cache.listMessages({ folder: 'sent', page, size });
1017
+ const total = await cache.countMessages({ folder: 'sent' });
1018
+ // "Nobody has read it yet" is a present-tense claim drawn entirely from
1019
+ // cached view timestamps, which only move when a sync refreshes them —
1020
+ // so it needs the same age label as any other cached read.
1021
+ const freshness = await buildFreshness(cache, { source: 'cache', folders: ['sent'] });
1022
+ return { sent, total, freshness };
1023
+ },
1024
+ });
1025
+ if (unverifiedEmpty) {
1026
+ return unverifiedEmptyResponse({
1027
+ what: 'sent messages in the local cache',
1028
+ freshness: value.freshness,
1029
+ refreshed,
1030
+ remedy: 'Call ofw_sync_messages(folders:["sent"]) and retry, or re-call with autoRefresh:true.',
1031
+ extra: { page, size },
765
1032
  });
766
1033
  }
1034
+ const { sent, total, freshness } = value;
1035
+ const fullSlice = page === 1 && sent.length === total;
1036
+ const complete = fullSlice && freshness.staleness === 'fresh' && freshness.historyComplete;
767
1037
  const unread = [];
768
1038
  for (const msg of sent) {
769
1039
  const unreadBy = msg.recipients.filter((r) => r.viewedAt === null).map((r) => r.name);
@@ -771,14 +1041,17 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
771
1041
  unread.push({ id: msg.id, subject: msg.subject, sentAt: msg.sentAt, unreadBy });
772
1042
  }
773
1043
  }
1044
+ const payload = { unread, scanned: sent.length, total, complete, freshness };
1045
+ if (!complete) {
1046
+ payload.completeNote = `This verdict covers the ${sent.length} of ${total} cached sent messages on this page${freshness.staleness === 'fresh' ? '' : `, from a cache that is "${freshness.staleness}"`}. It is not a statement about every message you have sent.`;
1047
+ }
774
1048
  if (unread.length === 0) {
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
- });
1049
+ payload.message = 'Every sent message scanned had been read as of the timestamp in `freshness.asOf`. A recipient may have read — or not read — a message since without the cache hearing about it.';
780
1050
  }
781
- return jsonResponse({ unread, freshness });
1051
+ if (refreshed) {
1052
+ payload.autoRefreshed = true;
1053
+ }
1054
+ return jsonResponse(payload);
782
1055
  });
783
1056
  if (allowDrafts)
784
1057
  server.registerTool('ofw_upload_attachment', {
@@ -825,13 +1098,16 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
825
1098
  });
826
1099
  });
827
1100
  server.registerTool('ofw_download_attachment', {
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).',
1101
+ description: 'Download an OFW message attachment by fileId and return content you can actually read. Inline delivery walks a ladder and returns the first rung that works: (1) host-renderable images (PNG/JPEG/GIF/WEBP) come back as ImageContent; (2) .xlsx/.csv/.tsv, .pdf, .docx, .pptx and text files come back as EXTRACTED CONTENT — per-sheet CSV, per-page/slide text, document text — in the response JSON under `extracted`; (3) anything else comes back as an EmbeddedResource blob of the raw bytes. The meta block names the rung as `deliveredVia` and, when it falls through to bytes, lists what was tried in `deliveryAttempts`. Reported mime types are always normalized to a bare media type (no charset/name parameters). In disk mode the bytes are saved to ~/Downloads/ofw-mcp/ and the response carries the absolute path; pass extract:true to ALSO get the extracted content in that response. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var. On a hosted deployment with no filesystem, disk mode is unavailable, so inline is forced (forcedInline:true) rather than failing — a saveTo path never costs you the content. 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).',
829
1102
  annotations: { readOnlyHint: false },
830
1103
  inputSchema: {
831
1104
  fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
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(),
1105
+ inline: z.boolean().describe('If true, return content inline as MCP content blocks 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 content is still returned. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).').optional(),
833
1106
  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(),
834
1107
  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(),
1108
+ extract: z.boolean().describe('Whether to extract readable content from the file. Default: on for inline delivery of any non-image type, off in disk mode. Set false to get the raw bytes inline instead of extracted text (e.g. to hash or re-upload the file); set true in disk mode to get both the saved path and the extracted content.').optional(),
1109
+ maxChars: z.number().int().min(500).max(500_000).describe('Ceiling on extracted characters (default 50000). Over it, content is clipped on a row/line boundary, `truncated` is set, and anything dropped whole is listed in `extracted.omitted`.').optional(),
1110
+ parts: z.string().describe('Which sheets / slides / pages to extract, e.g. "1-3,5" (1-based positions) or a sheet name like "2026". A bare number matches either a position or a name. Omit for everything. Unselected parts are listed in `extracted.omitted`.').optional(),
835
1111
  },
836
1112
  }, async (args) => {
837
1113
  const fileId = args.fileId;
@@ -843,6 +1119,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
843
1119
  // response is honest about it instead of silently ignoring the argument.
844
1120
  const inline = requestedInline || !attachmentIO.supportsDisk;
845
1121
  const forcedInline = inline && !requestedInline;
1122
+ const deliveryOptions = { extract: args.extract, maxChars: args.maxChars, parts: args.parts };
846
1123
  let cached = await cache.getAttachment(fileId);
847
1124
  if (!cached) {
848
1125
  // Not in cache. Fetch metadata and store under the messageId=0
@@ -871,25 +1148,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
871
1148
  // charset onto binaries), then fall back to the stripped header, then the
872
1149
  // extension. A parameter suffix would make the host reject an image.
873
1150
  const mimeType = resolveDownloadMime(bytes, headerMime, fileName);
874
- const base64 = bytes.toString('base64');
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)) {
886
- return { content: [metaBlock, { type: 'image', data: base64, mimeType }] };
887
- }
888
- return { content: [metaBlock, { type: 'resource', resource: {
889
- uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName)}`,
890
- mimeType,
891
- blob: base64,
892
- } }] };
1151
+ // The ladder decides between rendering, extracted content, and raw bytes
1152
+ // — see src/tools/delivery.ts. Whatever the host can draw, the caller
1153
+ // ends up holding something readable.
1154
+ return await buildInlineDelivery({
1155
+ fileId, fileName, mimeType, bytes, forcedInline, options: deliveryOptions,
1156
+ });
893
1157
  }
894
1158
  let dest;
895
1159
  // The filename comes from OFW file metadata — i.e. it is controlled by the
@@ -906,25 +1170,38 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
906
1170
  else {
907
1171
  dest = join(getAttachmentsDir(), `${fileId}-${safeName}`);
908
1172
  }
1173
+ // Disk mode extracts only on request: the caller already has a real file to
1174
+ // open, so extraction is an add-on here rather than the point.
1175
+ const extractOnDisk = args.extract === true;
909
1176
  if (!args.force && cached.downloadedPath === dest) {
910
- return jsonResponse({
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',
916
- });
1177
+ // Nothing was re-fetched, so an extraction has to come off the copy on
1178
+ // disk. If that copy has gone missing, fall through and download again
1179
+ // rather than answering "already downloaded" with no content.
1180
+ const onDisk = extractOnDisk ? attachmentIO.readDownloaded(dest) : null;
1181
+ if (!extractOnDisk || onDisk) {
1182
+ // No bytes on hand for the plain no-op case: normalize the
1183
+ // cached/extension MIME (an empty buffer sniffs nothing) so a stored
1184
+ // `image/png;charset=…` still reports bare.
1185
+ const mimeType = resolveDownloadMime(onDisk ?? Buffer.alloc(0), cached.mimeType, cached.fileName);
1186
+ return jsonResponse({
1187
+ fileId, path: dest, mimeType,
1188
+ sizeBytes: cached.sizeBytes, fileName: cached.fileName, note: 'already downloaded',
1189
+ ...(onDisk ? await tryExtract(onDisk, mimeType, cached.fileName, deliveryOptions) : {}),
1190
+ });
1191
+ }
917
1192
  }
918
1193
  const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
919
1194
  attachmentIO.writeDownload(dest, response.body);
920
1195
  await cache.markAttachmentDownloaded(fileId, dest);
921
1196
  const fileName = response.suggestedFileName ?? cached.fileName;
1197
+ const mimeType = resolveDownloadMime(response.body, response.contentType ?? cached.mimeType, fileName);
922
1198
  return jsonResponse({
923
1199
  fileId,
924
1200
  path: dest,
925
- mimeType: resolveDownloadMime(response.body, response.contentType ?? cached.mimeType, fileName),
1201
+ mimeType,
926
1202
  sizeBytes: response.body.length,
927
1203
  fileName,
1204
+ ...(extractOnDisk ? await tryExtract(response.body, mimeType, fileName, deliveryOptions) : {}),
928
1205
  });
929
1206
  });
930
1207
  server.registerTool('ofw_sync_messages', {
@@ -932,7 +1209,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
932
1209
  annotations: { readOnlyHint: false },
933
1210
  inputSchema: {
934
1211
  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(),
935
- fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.').optional(),
1212
+ fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages — which marks each one READ on OurFamilyWizard and stamps a co-parent-visible "First Viewed" time that cannot be undone. Defaults to the OFW_FETCH_UNREAD_BODIES env var (false unless set), and is forced off entirely when OFW_ALLOW_MARK_READ=false.').optional(),
936
1213
  deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
937
1214
  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(),
938
1215
  },
@@ -940,7 +1217,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
940
1217
  const cache = cacheProvider();
941
1218
  const result = await syncAll(client, {
942
1219
  folders: args.folders,
943
- fetchUnreadBodies: args.fetchUnreadBodies,
1220
+ // Default from OFW_FETCH_UNREAD_BODIES (false unless set), and capped by
1221
+ // the OFW_ALLOW_MARK_READ ceiling — fetching those bodies is exactly what
1222
+ // stamps a First Viewed time on every unread message it touches.
1223
+ fetchUnreadBodies: getAllowMarkRead() && (args.fetchUnreadBodies ?? getFetchUnreadBodies()),
944
1224
  deep: args.deep,
945
1225
  maxRequests: args.maxRequests ?? getSyncMaxRequests(),
946
1226
  }, cache);
@@ -953,16 +1233,19 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
953
1233
  return jsonResponse({ ...result, freshness });
954
1234
  });
955
1235
  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 },
1236
+ 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". 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 it returns a LIVE lifecycle `state` — "draft" | "sent" | "received" | "deleted" | "unknown" — alongside `folder`, `sentAt`, `existsOnServer` and a content comparison. `state` is the field that answers "is this still a draft?": a draft that has been SENT still exists on the server, so existsOnServer:true never distinguished the two. A cached draft whose state is no longer "draft" reports inSync:false even when its text is byte-identical. Content is compared by revision hash, 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. For draftKeys, or a full live draft inventory, use ofw_status.',
1237
+ annotations: { readOnlyHint: false },
958
1238
  inputSchema: {
959
1239
  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(),
1240
+ messageIds: z.array(z.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}). Ids cached as drafts, as sent messages, or as already-read inbox messages are probed freely — none of those can stamp the record. Anything else is skipped — see allowMarkRead.`).optional(),
1241
+ allowMarkRead: z.boolean().describe('Default false. Probing an id whose cached state cannot rule out an unread INBOX message requires fetching its detail, which marks it READ on OurFamilyWizard and stamps a co-parent-visible "First Viewed" time — irreversible. Such ids are skipped (reason:"WOULD_MARK_READ") unless you set this to true. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this cannot raise.').optional(),
962
1242
  },
963
1243
  }, async (args) => {
964
1244
  const cache = cacheProvider();
965
- const allowMarkRead = args.allowMarkRead ?? false;
1245
+ // The server-wide ceiling wins: a per-call allowMarkRead:true (or an
1246
+ // instruction injected into one) must not be able to stamp the record on a
1247
+ // deployment configured to never do so.
1248
+ const allowMarkRead = getAllowMarkRead() && (args.allowMarkRead ?? false);
966
1249
  const requestedIds = args.messageIds ?? [];
967
1250
  const ids = requestedIds.slice(0, MAX_FRESHNESS_IDS);
968
1251
  // Folders default to "all three" only when the caller asked about nothing
@@ -975,6 +1258,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
975
1258
  requestsUsed++;
976
1259
  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
1260
  const sys = data.systemFolders ?? [];
1261
+ // Take the folder ids while we have them. Without this a call asking
1262
+ // about BOTH folders and ids, on a cache that has never synced, fetched
1263
+ // this exact endpoint twice — once here for the counts and again inside
1264
+ // probeIds' ensureFolderIdMap for the ids it already had in hand.
1265
+ await persistFolderIds(cache, sys);
978
1266
  for (const folder of wantFolders) {
979
1267
  const entry = sys.find((x) => x.folderType === FOLDER_TYPE[folder]);
980
1268
  const serverCount = entry?.totalCount ?? entry?.messageCount ?? entry?.count ?? null;
@@ -982,7 +1270,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
982
1270
  ? (await cache.listDraftIds()).length
983
1271
  : await cache.countMessages({ folder });
984
1272
  const state = await cache.getSyncState(folder);
985
- const historyComplete = state !== null && state.resumePage === null;
1273
+ // Never synced at all is a DIFFERENT state from "backfill in progress",
1274
+ // and both leave historyComplete false. Conflating them told the caller
1275
+ // that older history was still being backfilled for a folder whose
1276
+ // backfill had never started — advice that reads as "wait it out" when
1277
+ // the real answer is "run a sync".
1278
+ const neverSynced = state === null;
1279
+ const historyComplete = !neverSynced && state.resumePage === null;
986
1280
  // A partially backfilled folder legitimately holds fewer messages than
987
1281
  // the server, so a count mismatch there proves nothing. Report both
988
1282
  // numbers and leave the verdict null rather than crying wolf for the
@@ -1001,69 +1295,19 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1001
1295
  ...(inSync === null
1002
1296
  ? { note: serverCount === null
1003
1297
  ? '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.' }
1298
+ : neverSynced
1299
+ ? 'This folder has never been synced, so the cache holds nothing to compare. Run ofw_sync_messages.'
1300
+ : 'Older history is still being backfilled, so a lower cachedCount is expected and does not indicate drift.' }
1005
1301
  : {}),
1006
1302
  });
1007
1303
  }
1008
1304
  }
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
- }
1305
+ // One folder-id resolve for the whole batch, and only when at least one id
1306
+ // is actually going to be probed — a call whose every id is refused for
1307
+ // mark-read reasons must cost nothing at all.
1308
+ const probed = await probeIds(client, cache, ids, { allowMarkRead });
1309
+ requestsUsed += probed.requests;
1310
+ const items = probed.items;
1067
1311
  const payload = {
1068
1312
  checkedAt: new Date().toISOString(),
1069
1313
  requestsUsed,
@@ -1075,6 +1319,156 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1075
1319
  }
1076
1320
  return jsonResponse(payload);
1077
1321
  });
1322
+ server.registerTool('ofw_status', {
1323
+ description: 'ONE live call that answers "where does everything stand?". This is the call that should back any status summary about drafts or specific messages — never session memory, and never a cached read alone. With no arguments it returns the FULL current draft inventory, verified against OurFamilyWizard. Pass ids and/or draftKeys to get each one\'s live lifecycle `state` ("draft" | "sent" | "received" | "deleted" | "unknown") with `sentAt` and `viewedAt`. A draftKey is the stable identity ofw_save_draft returns: editing a draft mints a new OFW id every time (create-then-delete), so the key is the only way to ask "what happened to the thing I was working on?" — it resolves to the chain\'s current id and keeps resolving after the draft is SENT (state:"sent" with sentMessageId). The top-level `complete` is true ONLY when every part of this snapshot was verified live; if it is false, do not state a draft count or a lifecycle claim from this payload.',
1324
+ annotations: { readOnlyHint: false },
1325
+ inputSchema: {
1326
+ ids: z.array(z.number()).describe(`Message/draft ids to resolve to a live state (combined with draftKeys, max ${MAX_FRESHNESS_IDS} probes per call).`).optional(),
1327
+ draftKeys: z.array(z.string()).describe('Stable draft keys (from ofw_save_draft / ofw_list_drafts) to resolve to their CURRENT id and state.').optional(),
1328
+ includeDraftInventory: z.boolean().describe('Return the full current draft list, verified against OurFamilyWizard first. Defaults to TRUE when neither ids nor draftKeys is given (so a bare ofw_status() is a complete status snapshot), otherwise false.').optional(),
1329
+ allowMarkRead: z.boolean().describe('Default false. An id whose cached state cannot rule out an unread INBOX message can only be probed by fetching its detail, which marks it READ on OurFamilyWizard — irreversible and co-parent-visible. Those are skipped unless this is true. Cached drafts, sent messages and already-read messages are always probed. Capped by OFW_ALLOW_MARK_READ.').optional(),
1330
+ },
1331
+ }, async (args) => {
1332
+ const cache = cacheProvider();
1333
+ const allowMarkRead = getAllowMarkRead() && (args.allowMarkRead ?? false);
1334
+ const requestedIds = args.ids ?? [];
1335
+ const requestedKeys = args.draftKeys ?? [];
1336
+ // A bare ofw_status() is meant to be the "here is where everything stands"
1337
+ // call, so it defaults to the inventory. Asking about specific ids does not
1338
+ // silently spend a drafts sync you did not ask for.
1339
+ const wantInventory = args.includeDraftInventory
1340
+ ?? (requestedIds.length === 0 && requestedKeys.length === 0);
1341
+ const allTargets = [
1342
+ ...requestedIds.map((id) => ({ kind: 'id', id })),
1343
+ ...requestedKeys.map((draftKey) => ({ kind: 'draftKey', draftKey })),
1344
+ ];
1345
+ const targets = allTargets.slice(0, MAX_FRESHNESS_IDS);
1346
+ const truncated = allTargets.length - targets.length;
1347
+ // Nothing asked for, nothing checked — so there is nothing to be complete
1348
+ // ABOUT. Returning `complete: true` here would be a reassuring-looking
1349
+ // payload that verified precisely nothing, which is the failure mode this
1350
+ // whole tool exists to remove.
1351
+ if (!wantInventory && targets.length === 0) {
1352
+ return jsonErrorResponse({
1353
+ result: 'NOTHING_REQUESTED',
1354
+ reason: 'ofw_status was called with includeDraftInventory:false and no ids or draftKeys, so nothing was checked.',
1355
+ remedy: 'Call ofw_status() with no arguments for the full draft inventory, or pass ids / draftKeys.',
1356
+ complete: false,
1357
+ });
1358
+ }
1359
+ let probeRequests = 0;
1360
+ const incomplete = [];
1361
+ // ── Draft inventory ────────────────────────────────────────────────────
1362
+ let drafts;
1363
+ let inventoryComplete = true;
1364
+ let inventoryFreshness;
1365
+ if (wantInventory) {
1366
+ const sync = await syncAll(client, {
1367
+ folders: ['drafts'],
1368
+ maxRequests: getSyncMaxRequests(),
1369
+ }, cache);
1370
+ // `refreshed` is the only honest signal that the walk actually diffed
1371
+ // against OFW. A budget-paused walk applies nothing and returns no count
1372
+ // — reporting its inventory as complete would be the same lie as
1373
+ // `drafts: 0` from a deferred sync.
1374
+ inventoryComplete = sync.refreshed.includes('drafts');
1375
+ const { freshness, cacheStatus, serverConfirmed } = await draftsFreshness(cache);
1376
+ inventoryFreshness = freshness;
1377
+ if (!serverConfirmed)
1378
+ inventoryComplete = false;
1379
+ const total = await cache.countDrafts();
1380
+ const rows = await cache.listDrafts({ page: 1, size: Math.max(total, 1) });
1381
+ const keyById = new Map((await cache.getDraftLineageByIds(rows.map((d) => d.id))).map((l) => [l.id, l.draftKey]));
1382
+ drafts = rows.map((d) => ({
1383
+ id: d.id,
1384
+ draftKey: keyById.get(d.id) ?? null,
1385
+ subject: d.subject,
1386
+ revision: draftRevision(d),
1387
+ modifiedAt: d.modifiedAt,
1388
+ recipients: d.recipients,
1389
+ replyToId: d.replyToId,
1390
+ cacheStatus,
1391
+ }));
1392
+ if (!inventoryComplete) {
1393
+ incomplete.push('the drafts folder was not fully verified against OurFamilyWizard on this call (the request budget paused the walk), so this inventory may be missing or misreporting drafts');
1394
+ }
1395
+ }
1396
+ // ── Per-id / per-key lifecycle ─────────────────────────────────────────
1397
+ const requested = [];
1398
+ if (targets.length > 0) {
1399
+ // Resolve keys to ids first so a key and a bare id naming the SAME
1400
+ // message are probed once, not twice.
1401
+ const resolved = new Map();
1402
+ for (const t of targets) {
1403
+ if (t.kind === 'draftKey' && !resolved.has(t.draftKey)) {
1404
+ resolved.set(t.draftKey, await resolveDraftKey(cache, t.draftKey));
1405
+ }
1406
+ }
1407
+ const toProbe = new Set();
1408
+ for (const t of targets) {
1409
+ if (t.kind === 'id')
1410
+ toProbe.add(t.id);
1411
+ else {
1412
+ const chain = resolved.get(t.draftKey);
1413
+ if (chain !== null && chain !== undefined)
1414
+ toProbe.add(chain.currentId);
1415
+ }
1416
+ }
1417
+ const probed = await probeIds(client, cache, [...toProbe], { allowMarkRead });
1418
+ probeRequests += probed.requests;
1419
+ const probes = new Map(probed.items.map((item) => [item.id, item]));
1420
+ for (const t of targets) {
1421
+ if (t.kind === 'id') {
1422
+ requested.push(decorate(probes.get(t.id)));
1423
+ continue;
1424
+ }
1425
+ const chain = resolved.get(t.draftKey);
1426
+ if (chain === null || chain === undefined) {
1427
+ requested.push({
1428
+ draftKey: t.draftKey,
1429
+ state: 'unknown',
1430
+ error: 'UNKNOWN_DRAFT_KEY',
1431
+ note: 'This draftKey has never been recorded in the local cache, so it cannot be resolved to a message id. Draft keys are minted by ofw_save_draft; a cache rebuilt or opened on another machine will not know an older key.',
1432
+ });
1433
+ continue;
1434
+ }
1435
+ requested.push({
1436
+ draftKey: t.draftKey,
1437
+ currentId: chain.currentId,
1438
+ previousIds: chain.ids.slice(0, -1),
1439
+ ...decorate(probes.get(chain.currentId)),
1440
+ });
1441
+ }
1442
+ for (const entry of requested) {
1443
+ if (entry.skipped === true || entry.error !== undefined || entry.state === 'unknown') {
1444
+ incomplete.push(`id/key ${String(entry.draftKey ?? entry.id)} could not be resolved to a confirmed live state`);
1445
+ }
1446
+ }
1447
+ }
1448
+ if (truncated > 0) {
1449
+ incomplete.push(`${truncated} of ${allTargets.length} requested ids/draftKeys were not probed (per-call cap of ${MAX_FRESHNESS_IDS})`);
1450
+ }
1451
+ const complete = incomplete.length === 0;
1452
+ return jsonResponse({
1453
+ checkedAt: new Date().toISOString(),
1454
+ probeRequests,
1455
+ ...(drafts !== undefined ? { drafts, draftCount: drafts.length, draftInventoryComplete: inventoryComplete } : {}),
1456
+ ...(requested.length > 0 ? { requested } : {}),
1457
+ complete,
1458
+ ...(complete
1459
+ ? {}
1460
+ : { incompleteReasons: incomplete, note: 'complete:false — this snapshot is NOT a verified statement of current state. Do not report a draft count or say whether something was sent from it; resolve the reasons above (usually by calling ofw_sync_messages, or re-calling with allowMarkRead:true) and ask again.' }),
1461
+ ...(inventoryFreshness !== undefined ? { freshness: inventoryFreshness } : {}),
1462
+ });
1463
+ });
1464
+ }
1465
+ /**
1466
+ * Add the derived fields a status entry wants on top of a raw lifecycle probe.
1467
+ * `sentMessageId` names the id the message ended up as once sent — the answer to
1468
+ * "the draft I was editing went where?" — and only exists for a `sent` verdict.
1469
+ */
1470
+ function decorate(item) {
1471
+ return item.state === 'sent' ? { ...item, sentMessageId: item.id } : { ...item };
1078
1472
  }
1079
1473
  // OFW's bulk-delete endpoint takes a multipart form with `messageIds`.
1080
1474
  // Used by both ofw_delete_draft and ofw_send_message (draft cleanup).