ofw-mcp 2.7.0 → 2.8.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.
@@ -3,10 +3,11 @@ import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage, getDraftsC
3
3
  import { buildFreshness } from './freshness.js';
4
4
  import { checkDraftFreshness, draftRevision, fetchServerDraft, staleDraftPayload, } from './draft-freshness.js';
5
5
  import { getFolderVerifiedAt } from '../sync.js';
6
- import { isHostRenderableImage, resolveDownloadMime } from './attachments.js';
7
- import { getAttachmentsDir, getDefaultInlineAttachments, getSyncMaxRequests, getWriteMode } from '../config.js';
6
+ import { buildInlineDelivery, tryExtract } from './delivery.js';
7
+ import { resolveDownloadMime } from './attachments.js';
8
+ import { getAllowMarkRead, getAttachmentsDir, getDefaultInlineAttachments, getFetchUnreadBodies, getSyncMaxRequests, getWriteMode, } from '../config.js';
8
9
  import { basename, join } from 'node:path';
9
- import { ApiRecipientSchema, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
10
+ import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
10
11
  import { parseLenient } from '@chrischall/mcp-utils';
11
12
  // Schemas for the load-bearing fields of each /pub/v3 response this file
12
13
  // reads (issue #83). Loose: unknown keys pass through into cached listData.
@@ -28,6 +29,8 @@ const SavedDraftDetailSchema = z.looseObject({
28
29
  date: DateSchema.optional(),
29
30
  replyToId: z.number().nullable().optional(),
30
31
  recipients: z.array(ApiRecipientSchema).optional(),
32
+ // Read to audit whether requested myFileIDs actually attached (Defect 3).
33
+ files: z.array(z.number()).optional(),
31
34
  });
32
35
  // ofw_get_message's uncached detail fetch — lenient: a mismatch warns to
33
36
  // stderr and the existing ?? fallbacks keep the tool serving.
@@ -123,6 +126,48 @@ async function draftsFreshness(cache) {
123
126
  : 'unverified';
124
127
  return { freshness, serverConfirmed: cacheStatus === 'fresh', cacheStatus };
125
128
  }
129
+ /**
130
+ * Decide whether fetching this message's body from OFW would stamp the record,
131
+ * and refuse when the caller (or the deployment) has opted out of that.
132
+ *
133
+ * Returns null to proceed, or a structured refusal to return as-is.
134
+ *
135
+ * Only ONE case actually stamps: fetching the body of an UNREAD INBOX message.
136
+ * Everything else is waved through, because refusing a read that changes
137
+ * nothing would be friction with no safety to show for it:
138
+ * - a SENT message — the "First Viewed" times on it belong to the recipient,
139
+ * and our own fetch never writes one;
140
+ * - an already-read inbox message — the stamp exists; re-reading cannot add
141
+ * a second one (`deriveRead` is monotonic, so this cannot flip back);
142
+ * - a cached body — this function is never reached, the cache served it.
143
+ *
144
+ * An id with NO cached row is refused: whether it would stamp is exactly what
145
+ * we cannot know without making the request that stamps it. Syncing first
146
+ * (which reads list pages, not bodies) resolves it.
147
+ */
148
+ export function markReadVerdict(cached, requested) {
149
+ const ceiling = getAllowMarkRead();
150
+ if (ceiling && (requested ?? true))
151
+ return null;
152
+ const wouldStamp = cached === null
153
+ || (cached.folder === 'inbox' && !deriveRead(cached));
154
+ if (!wouldStamp)
155
+ return null;
156
+ const because = ceiling
157
+ ? 'you passed allowMarkRead:false'
158
+ : 'this server runs with OFW_ALLOW_MARK_READ=false';
159
+ return jsonErrorResponse({
160
+ error: 'MARK_READ_BLOCKED',
161
+ messageId: cached?.id ?? null,
162
+ reason: cached === null
163
+ ? 'This id is not in the cache, so whether reading it would mark it read is unknowable without making the request that would.'
164
+ : 'This is an unread inbox message; fetching its body would mark it read on OurFamilyWizard.',
165
+ 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)'}.`,
166
+ ...(cached === null
167
+ ? { hint: 'Run ofw_sync_messages first: it walks list pages, not bodies, so it can tell you what this id is without stamping anything.' }
168
+ : { subject: cached.subject, fromUser: cached.fromUser, sentAt: cached.sentAt }),
169
+ });
170
+ }
126
171
  export function registerMessageTools(server, client, cacheProvider, attachmentIO) {
127
172
  // OFW_WRITE_MODE gate (see config.ts). Send lands on the court-visible
128
173
  // record, so it is 'all'-only; draft-level writes (save/delete drafts,
@@ -199,10 +244,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
199
244
  return jsonResponse(payload);
200
245
  });
201
246
  server.registerTool('ofw_get_message', {
202
- 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.',
247
+ 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.',
203
248
  annotations: { readOnlyHint: false },
204
249
  inputSchema: {
205
250
  messageId: z.string().describe('Message ID (also accepts draft IDs — drafts are routed via the drafts cache)'),
251
+ 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(),
206
252
  },
207
253
  }, async (args) => {
208
254
  const id = Number(args.messageId);
@@ -297,6 +343,14 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
297
343
  const freshness = await buildFreshness(cache, { source: 'cache', folders: [row.folder] });
298
344
  return jsonResponse({ ...withReadState(row), attachments, freshness });
299
345
  }
346
+ // Everything above this line was served without asking OFW for a body.
347
+ // This is the one path that fetches one — and fetching the body of an
348
+ // unread INBOX message marks it read on OFW, stamping a "First Viewed"
349
+ // time the co-parent can see. That is a court-visible, irreversible change
350
+ // made as a side effect of an ordinary read, so it gets an explicit gate.
351
+ const markReadCheck = markReadVerdict(cached, args.allowMarkRead);
352
+ if (markReadCheck !== null)
353
+ return markReadCheck;
300
354
  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)' });
301
355
  // Derive the folder for a live-fetched message. A cached row (reached here
302
356
  // only when its body was NULL) already knows its folder, so keep it.
@@ -478,10 +532,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
478
532
  server = await fetchServerDraft(client, draftId);
479
533
  }
480
534
  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.
535
+ // Anything landing here means the check could not RUN. Most failures
536
+ // arrive as DraftFreshnessError from fetchServerDraft, but not all of
537
+ // them: a strict parseLenient mismatch on the server draft throws
538
+ // McpToolError instead. Both are caught, and both abort — which is the
539
+ // point. A failed check is not permission to proceed: a transient 5xx
540
+ // must not degrade into a blind overwrite. (The cast below only reads
541
+ // `.message`, which every Error carries.)
485
542
  const reason = e.message;
486
543
  if (force) {
487
544
  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.` };
@@ -497,8 +554,15 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
497
554
  };
498
555
  }
499
556
  const verdict = checkDraftFreshness({ server, cached, expectedRevision });
500
- if (verdict.verdict === 'FRESH')
501
- return { ok: true, note: null };
557
+ if (verdict.verdict === 'FRESH') {
558
+ // A metadata-only "conflict" is the connector's own post-save replyToId
559
+ // normalization catching up — safe to proceed, but say so rather than
560
+ // pretending nothing moved.
561
+ const note = verdict.metadataOnly
562
+ ? `NOTE: draft ${draftId} was treated as current for this ${action}. Since you read it, OurFamilyWizard normalized connector-authored metadata (${verdict.changedFields.join(', ')}); the subject, body and recipients are unchanged, so this is not a conflict.`
563
+ : null;
564
+ return { ok: true, note };
565
+ }
502
566
  if (force) {
503
567
  // Loud, and the overwritten content rides along in the response so it is
504
568
  // recoverable from the tool result itself.
@@ -560,7 +624,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
560
624
  });
561
625
  if (allowDrafts)
562
626
  server.registerTool('ofw_save_draft', {
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.',
627
+ description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft — note that under the hood this creates a NEW draft and deletes the old one (OFW\'s update-in-place endpoint silently no-ops while echoing the posted body, so we don\'t use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response that also lists which fields (subject/body/recipients/replyToId/attachments) were carried over. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state, and the returned `revision` reflects that authoritative state (so it will match on your next edit). FIELD PRESERVATION: the response echoes the effective threading (replyToId/inReplyTo) and, whenever OFW did not carry over a requested replyToId, recipient or attachment, a `warnings[]` entry naming what was dropped — never a silent null. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody — merge your edit into it and retry with expectedRevision.',
564
628
  annotations: { readOnlyHint: false },
565
629
  inputSchema: {
566
630
  subject: z.string().describe('Message subject'),
@@ -619,26 +683,72 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
619
683
  let replaceNote = null;
620
684
  let verifyNote = null;
621
685
  let newRevision = null;
686
+ // Fields accepted on the write that the saved draft must carry — or their
687
+ // loss must be reported. Never a silent drop (Defect 3).
688
+ const warnings = [];
622
689
  if (newId !== null) {
623
690
  verifyNote = verifyWriteLanded('draft', { subject: args.subject, body: args.body }, detail);
691
+ // Trust the re-fetched server detail as the source of truth for the stored
692
+ // replyToId — NOT `resolvedReplyTo` (what we intended to post). OFW
693
+ // normalizes/drops threading after a save, and masking that with our own
694
+ // intent (the old `detail.replyToId ?? resolvedReplyTo`) both returned a
695
+ // revision that was stale on arrival (Defect 1) and hid a dropped reply
696
+ // link (Defect 3). `?? null` keeps a genuinely-echoed value and reflects a
697
+ // null/absent one honestly.
698
+ const effectiveReplyTo = detail.replyToId ?? null;
699
+ const storedRecipients = mapRecipients(detail.recipients);
624
700
  persisted = {
625
701
  id: newId,
626
702
  subject: detail.subject ?? args.subject,
627
703
  body: detail.body ?? '',
628
- recipients: mapRecipients(detail.recipients),
629
- replyToId: detail.replyToId ?? resolvedReplyTo,
704
+ recipients: storedRecipients,
705
+ replyToId: effectiveReplyTo,
630
706
  modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
631
707
  listData: detail,
632
708
  };
633
709
  await cache.upsertDraft(persisted);
710
+ // The revision is now computed from the server-authoritative detail, so it
711
+ // is the value a subsequent read/verify will observe (Defect 1).
634
712
  newRevision = draftRevision(persisted);
713
+ // Audit every field the caller supplied against what actually landed, so a
714
+ // silent normalization becomes a visible warning rather than a surprise.
715
+ if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
716
+ const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : '';
717
+ // Two different outcomes reach this branch, and they need different
718
+ // warnings. OFW either DROPPED the link (null) or RE-TARGETED it to
719
+ // another message in the thread. Describing both as "did not thread
720
+ // this draft (its inReplyTo/showContext will be empty)" contradicted
721
+ // the non-null inReplyTo the same response echoes — and a warning the
722
+ // caller can see is false is a warning it learns to skip.
723
+ const outcome = effectiveReplyTo === null
724
+ ? 'OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped.'
725
+ : `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.`;
726
+ 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.`);
727
+ }
728
+ // Only warn on recipients/attachments when the detail actually reported
729
+ // them — an omitted array is "not echoed", not "dropped", and crying wolf
730
+ // there would desensitize the caller to the real drops.
731
+ if (args.recipientIds !== undefined && Array.isArray(detail.recipients)) {
732
+ const requested = [...new Set(args.recipientIds)].sort((a, b) => a - b);
733
+ const stored = [...new Set(storedRecipients.map((r) => r.userId))].sort((a, b) => a - b);
734
+ if (requested.join(',') !== stored.join(',')) {
735
+ warnings.push(`recipientIds were requested as [${requested.join(', ')}] but the saved draft has [${stored.join(', ')}]. Verify the recipients on ourfamilywizard.com.`);
736
+ }
737
+ }
738
+ if (myFileIDs.length > 0 && Array.isArray(detail.files)) {
739
+ const storedFiles = new Set(detail.files);
740
+ const missing = myFileIDs.filter((id) => !storedFiles.has(id));
741
+ if (missing.length > 0) {
742
+ warnings.push(`Attachment fileId(s) ${missing.join(', ')} were requested in myFileIDs but are not attached to the saved draft. Re-upload or re-attach if needed.`);
743
+ }
744
+ }
635
745
  // Replace-path: caller passed messageId, so they want the old draft
636
746
  // gone. Delete it after the new one is safely created+cached.
637
747
  if (args.messageId !== undefined && args.messageId !== newId) {
638
748
  try {
639
749
  await deleteOFWMessages(client, [args.messageId]);
640
750
  await cache.deleteDraft(args.messageId);
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.)`;
751
+ replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.) Fields carried over to the new draft: subject, body, recipients (${persisted.recipients.length}), replyToId (${persisted.replyToId === null ? 'none' : persisted.replyToId}), attachments (${myFileIDs.length}).${warnings.length > 0 ? ' See warnings above for any field OurFamilyWizard did not carry over.' : ''}`;
642
752
  }
643
753
  catch (e) {
644
754
  // Partial-failure safety: the new draft is already created and
@@ -650,12 +760,24 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
650
760
  }
651
761
  // The draft was just re-fetched from OFW by postMessageAndRefetch, so this
652
762
  // one row IS server-confirmed regardless of the drafts folder's overall
653
- // cache freshness.
763
+ // cache freshness. `inReplyTo` echoes the effective threading alongside the
764
+ // volatile `id`, and `warnings` names any requested field that did not land.
654
765
  const responseObj = persisted !== null
655
- ? { ...persisted, revision: newRevision, cacheStatus: 'fresh', serverConfirmed: true }
766
+ ? {
767
+ ...persisted,
768
+ inReplyTo: persisted.replyToId,
769
+ revision: newRevision,
770
+ cacheStatus: 'fresh',
771
+ serverConfirmed: true,
772
+ ...(warnings.length > 0 ? { warnings } : {}),
773
+ }
656
774
  : raw;
657
775
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
658
- const notes = [forceNote, rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join('\n\n');
776
+ const warnNote = warnings.length > 0
777
+ ? `WARNING: ${warnings.join('\n\n')}`
778
+ : null;
779
+ const notes = [forceNote, rewriteNote, verifyNote, warnNote, replaceNote]
780
+ .filter((n) => n !== null).join('\n\n');
659
781
  return textResponse(notes ? `${notes}\n\n${text}` : text);
660
782
  });
661
783
  if (allowDrafts)
@@ -767,13 +889,16 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
767
889
  });
768
890
  });
769
891
  server.registerTool('ofw_download_attachment', {
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).',
892
+ 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).',
771
893
  annotations: { readOnlyHint: false },
772
894
  inputSchema: {
773
895
  fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
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(),
896
+ 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(),
775
897
  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(),
776
898
  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(),
899
+ 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(),
900
+ 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(),
901
+ 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(),
777
902
  },
778
903
  }, async (args) => {
779
904
  const fileId = args.fileId;
@@ -785,6 +910,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
785
910
  // response is honest about it instead of silently ignoring the argument.
786
911
  const inline = requestedInline || !attachmentIO.supportsDisk;
787
912
  const forcedInline = inline && !requestedInline;
913
+ const deliveryOptions = { extract: args.extract, maxChars: args.maxChars, parts: args.parts };
788
914
  let cached = await cache.getAttachment(fileId);
789
915
  if (!cached) {
790
916
  // Not in cache. Fetch metadata and store under the messageId=0
@@ -813,25 +939,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
813
939
  // charset onto binaries), then fall back to the stripped header, then the
814
940
  // extension. A parameter suffix would make the host reject an image.
815
941
  const mimeType = resolveDownloadMime(bytes, headerMime, fileName);
816
- const base64 = bytes.toString('base64');
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)) {
828
- return { content: [metaBlock, { type: 'image', data: base64, mimeType }] };
829
- }
830
- return { content: [metaBlock, { type: 'resource', resource: {
831
- uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName)}`,
832
- mimeType,
833
- blob: base64,
834
- } }] };
942
+ // The ladder decides between rendering, extracted content, and raw bytes
943
+ // — see src/tools/delivery.ts. Whatever the host can draw, the caller
944
+ // ends up holding something readable.
945
+ return await buildInlineDelivery({
946
+ fileId, fileName, mimeType, bytes, forcedInline, options: deliveryOptions,
947
+ });
835
948
  }
836
949
  let dest;
837
950
  // The filename comes from OFW file metadata — i.e. it is controlled by the
@@ -848,25 +961,38 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
848
961
  else {
849
962
  dest = join(getAttachmentsDir(), `${fileId}-${safeName}`);
850
963
  }
964
+ // Disk mode extracts only on request: the caller already has a real file to
965
+ // open, so extraction is an add-on here rather than the point.
966
+ const extractOnDisk = args.extract === true;
851
967
  if (!args.force && cached.downloadedPath === dest) {
852
- return jsonResponse({
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',
858
- });
968
+ // Nothing was re-fetched, so an extraction has to come off the copy on
969
+ // disk. If that copy has gone missing, fall through and download again
970
+ // rather than answering "already downloaded" with no content.
971
+ const onDisk = extractOnDisk ? attachmentIO.readDownloaded(dest) : null;
972
+ if (!extractOnDisk || onDisk) {
973
+ // No bytes on hand for the plain no-op case: normalize the
974
+ // cached/extension MIME (an empty buffer sniffs nothing) so a stored
975
+ // `image/png;charset=…` still reports bare.
976
+ const mimeType = resolveDownloadMime(onDisk ?? Buffer.alloc(0), cached.mimeType, cached.fileName);
977
+ return jsonResponse({
978
+ fileId, path: dest, mimeType,
979
+ sizeBytes: cached.sizeBytes, fileName: cached.fileName, note: 'already downloaded',
980
+ ...(onDisk ? await tryExtract(onDisk, mimeType, cached.fileName, deliveryOptions) : {}),
981
+ });
982
+ }
859
983
  }
860
984
  const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
861
985
  attachmentIO.writeDownload(dest, response.body);
862
986
  await cache.markAttachmentDownloaded(fileId, dest);
863
987
  const fileName = response.suggestedFileName ?? cached.fileName;
988
+ const mimeType = resolveDownloadMime(response.body, response.contentType ?? cached.mimeType, fileName);
864
989
  return jsonResponse({
865
990
  fileId,
866
991
  path: dest,
867
- mimeType: resolveDownloadMime(response.body, response.contentType ?? cached.mimeType, fileName),
992
+ mimeType,
868
993
  sizeBytes: response.body.length,
869
994
  fileName,
995
+ ...(extractOnDisk ? await tryExtract(response.body, mimeType, fileName, deliveryOptions) : {}),
870
996
  });
871
997
  });
872
998
  server.registerTool('ofw_sync_messages', {
@@ -874,7 +1000,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
874
1000
  annotations: { readOnlyHint: false },
875
1001
  inputSchema: {
876
1002
  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(),
877
- fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.').optional(),
1003
+ 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(),
878
1004
  deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
879
1005
  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(),
880
1006
  },
@@ -882,7 +1008,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
882
1008
  const cache = cacheProvider();
883
1009
  const result = await syncAll(client, {
884
1010
  folders: args.folders,
885
- fetchUnreadBodies: args.fetchUnreadBodies,
1011
+ // Default from OFW_FETCH_UNREAD_BODIES (false unless set), and capped by
1012
+ // the OFW_ALLOW_MARK_READ ceiling — fetching those bodies is exactly what
1013
+ // stamps a First Viewed time on every unread message it touches.
1014
+ fetchUnreadBodies: getAllowMarkRead() && (args.fetchUnreadBodies ?? getFetchUnreadBodies()),
886
1015
  deep: args.deep,
887
1016
  maxRequests: args.maxRequests ?? getSyncMaxRequests(),
888
1017
  }, cache);
@@ -904,7 +1033,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
904
1033
  },
905
1034
  }, async (args) => {
906
1035
  const cache = cacheProvider();
907
- const allowMarkRead = args.allowMarkRead ?? false;
1036
+ // The server-wide ceiling wins: a per-call allowMarkRead:true (or an
1037
+ // instruction injected into one) must not be able to stamp the record on a
1038
+ // deployment configured to never do so.
1039
+ const allowMarkRead = getAllowMarkRead() && (args.allowMarkRead ?? false);
908
1040
  const requestedIds = args.messageIds ?? [];
909
1041
  const ids = requestedIds.slice(0, MAX_FRESHNESS_IDS);
910
1042
  // Folders default to "all three" only when the caller asked about nothing
@@ -924,7 +1056,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
924
1056
  ? (await cache.listDraftIds()).length
925
1057
  : await cache.countMessages({ folder });
926
1058
  const state = await cache.getSyncState(folder);
927
- const historyComplete = state !== null && state.resumePage === null;
1059
+ // Never synced at all is a DIFFERENT state from "backfill in progress",
1060
+ // and both leave historyComplete false. Conflating them told the caller
1061
+ // that older history was still being backfilled for a folder whose
1062
+ // backfill had never started — advice that reads as "wait it out" when
1063
+ // the real answer is "run a sync".
1064
+ const neverSynced = state === null;
1065
+ const historyComplete = !neverSynced && state.resumePage === null;
928
1066
  // A partially backfilled folder legitimately holds fewer messages than
929
1067
  // the server, so a count mismatch there proves nothing. Report both
930
1068
  // numbers and leave the verdict null rather than crying wolf for the
@@ -943,7 +1081,9 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
943
1081
  ...(inSync === null
944
1082
  ? { note: serverCount === null
945
1083
  ? '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.' }
1084
+ : neverSynced
1085
+ ? 'This folder has never been synced, so the cache holds nothing to compare. Run ofw_sync_messages.'
1086
+ : 'Older history is still being backfilled, so a lower cachedCount is expected and does not indicate drift.' }
947
1087
  : {}),
948
1088
  });
949
1089
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "license": "MIT",
5
5
  "mcpName": "io.github.chrischall/ofw-mcp",
6
6
  "description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
@@ -42,13 +42,13 @@
42
42
  "zod": "^4.4.3"
43
43
  },
44
44
  "devDependencies": {
45
- "@chrischall/mcp-connector": "^1.0.0",
45
+ "@chrischall/mcp-connector": "^1.1.1",
46
46
  "@cloudflare/vitest-pool-workers": "^0.18.4",
47
47
  "@cloudflare/workers-oauth-provider": "^0.8.1",
48
48
  "@cloudflare/workers-types": "^5.20260708.1",
49
49
  "@types/node": "^26.0.0",
50
50
  "@vitest/coverage-v8": "^4.1.7",
51
- "agents": "^0.17.3",
51
+ "agents": "^0.19.0",
52
52
  "esbuild": "^0.28.0",
53
53
  "typescript": "^7.0.2",
54
54
  "vitest": "^4.1.7",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/ofw-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.7.0",
9
+ "version": "2.8.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.7.0",
14
+ "version": "2.8.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -40,6 +40,18 @@
40
40
  "description": "Set to \"true\" to register calendar write tools (create/update/delete event) in \"drafts\" write mode. Events have no draft stage but are reversible. Never overrides \"none\".",
41
41
  "isRequired": false,
42
42
  "format": "string"
43
+ },
44
+ {
45
+ "name": "OFW_ALLOW_MARK_READ",
46
+ "description": "Default \"true\". Set \"false\" to forbid any tool from marking a message read on OurFamilyWizard - reading a body for the first time stamps a co-parent-visible \"First Viewed\" time that cannot be undone. A ceiling: no per-call argument can raise it.",
47
+ "isRequired": false,
48
+ "format": "string"
49
+ },
50
+ {
51
+ "name": "OFW_FETCH_UNREAD_BODIES",
52
+ "description": "Default \"false\". Whether ofw_sync_messages fetches unread inbox bodies by default (each fetch stamps a First Viewed time). Capped by OFW_ALLOW_MARK_READ.",
53
+ "isRequired": false,
54
+ "format": "string"
43
55
  }
44
56
  ]
45
57
  }
@@ -94,14 +94,14 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
94
94
  | `ofw_sync_messages(folders?, deep?, fetchUnreadBodies?)` | Sync OFW → local cache. **Call first if the cache might be stale.** Returns unread inbox hints (bodies not fetched, to avoid mark-as-read). |
95
95
  | `ofw_list_message_folders` | List OFW folders with unread counts. Most reads use the cache; this is mainly for folder IDs and live unread counts. |
96
96
  | `ofw_list_messages(folderId?, since?, until?, q?, page?, size?)` | Cache-backed list. Supports folder ("inbox"/"sent"/"both"), date range, and substring search. |
97
- | `ofw_get_message(messageId)` | Read a message OR draft body. Cache-first. Ids in the drafts cache return `folder: "drafts"`. ⚠️ Falls through to OFW for unread inbox messages, which marks them as read. |
97
+ | `ofw_get_message(messageId, allowMarkRead?)` | Read a message OR draft body. Cache-first. Ids in the drafts cache return `folder: "drafts"`. ⚠️ Falls through to OFW for unread inbox messages, which marks them read AND stamps a "First Viewed" time the co-parent can see — irreversible. Pass `allowMarkRead:false` to refuse that fetch instead; cached, sent and already-read messages are unaffected. |
98
98
  | `ofw_send_message(subject, body, recipientIds[], replyToId?, draftId?, myFileIDs?)` | Send a message. Pass `replyToId` to thread original history. Pass `draftId` to auto-delete the draft after sending. Pass `myFileIDs` (from `ofw_upload_attachment`) to attach files. |
99
99
  | `ofw_get_unread_sent` | Sent messages your co-parent hasn't read yet (from cache). |
100
100
  | `ofw_list_drafts` | List saved drafts (cache-backed). Each draft carries `serverConfirmed` — see [Freshness](#freshness). |
101
101
  | `ofw_save_draft(subject, body, recipientIds?, messageId?, replyToId?, myFileIDs?)` | Create a new draft. Pass `messageId` to **replace** an existing draft: the tool creates a fresh draft and deletes the old one (OFW's update-in-place endpoint silently no-ops). The returned `id` is the NEW id; the response includes a `NOTE` documenting the swap. |
102
102
  | `ofw_delete_draft(messageId)` | Delete a draft. |
103
103
  | `ofw_upload_attachment(path, shareClass?, label?, description?)` | Upload a local file to My Files; returns a fileId to pass into `myFileIDs`. |
104
- | `ofw_download_attachment(fileId, inline?, saveTo?, force?)` | Download an attachment. `inline:true` returns bytes as MCP content; default writes to `~/Downloads/ofw-mcp/`. |
104
+ | `ofw_download_attachment(fileId, inline?, saveTo?, force?, extract?, maxChars?, parts?)` | Download an attachment. Inline delivery returns the first rung that works: image → `ImageContent`; .xlsx/.csv/.pdf/.docx/.pptx/text → **extracted content** under `extracted` (per-sheet CSV, per-page/slide text); anything else → raw bytes. Default writes to `~/Downloads/ofw-mcp/` (add `extract:true` for content too). Use `parts:"1-2"` / a sheet name and `maxChars` on large files. |
105
105
  | `ofw_check_freshness(folders?, messageIds?, allowMarkRead?)` | Cheap live check that the cache still matches OFW — one request for folder counts plus one per id, no bodies, no sync. Use before asserting current state. Only probes ids in the drafts cache unless `allowMarkRead:true` (probing others marks inbox messages read). |
106
106
 
107
107
  ### Calendar