ofw-mcp 2.7.1 → 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.
@@ -0,0 +1,110 @@
1
+ // A minimal, dependency-free ZIP reader.
2
+ //
3
+ // OOXML attachments (.xlsx/.docx/.pptx) are ZIP containers of XML parts, so
4
+ // reading one is the first step of every office-document extractor. This is
5
+ // deliberately not a general ZIP library: it reads the central directory,
6
+ // slices an entry's bytes, and inflates DEFLATE members via the WHATWG
7
+ // `DecompressionStream` — which exists in BOTH Node ≥18 and workerd, so the
8
+ // same code runs on the stdio server and the hosted connector. Using
9
+ // `node:zlib` here would break the Worker build; adding a userland inflate
10
+ // dependency would bloat it. Neither is necessary.
11
+ import { inflateBounded, MAX_DECOMPRESSED_BYTES } from './inflate.js';
12
+ const EOCD_SIG = 0x06054b50;
13
+ const CENTRAL_SIG = 0x02014b50;
14
+ const LOCAL_SIG = 0x04034b50;
15
+ const ZIP64_SENTINEL = 0xffffffff;
16
+ /**
17
+ * Hard ceiling on a single decompressed member (32 MiB). An attachment is a
18
+ * co-parent-supplied file, so a zip bomb is a real (if unlikely) input, and the
19
+ * Worker's memory budget is what is being protected.
20
+ *
21
+ * The cap is enforced on the bytes as they arrive ({@link inflateBounded}), NOT
22
+ * on the size the archive declares for itself. The declared size is checked too
23
+ * — it rejects an HONEST oversized member without inflating anything — but it
24
+ * is an optimization, not the guarantee: a central directory is free to claim
25
+ * 1 KB in front of a member that expands to a gigabyte.
26
+ */
27
+ export const ZIP_MAX_UNCOMPRESSED_BYTES = MAX_DECOMPRESSED_BYTES;
28
+ /** Locate the end-of-central-directory record, scanning back past any comment. */
29
+ function findEocd(bytes) {
30
+ // The comment field is a uint16, so the record starts at most 22+65535 bytes
31
+ // from the end. Scan backwards for the signature.
32
+ const earliest = Math.max(0, bytes.length - (22 + 0xffff));
33
+ for (let i = bytes.length - 22; i >= earliest; i--) {
34
+ if (bytes.readUInt32LE(i) === EOCD_SIG)
35
+ return i;
36
+ }
37
+ throw new Error('not a ZIP archive (no end-of-central-directory record)');
38
+ }
39
+ export async function readZip(bytes, opts = {}) {
40
+ const limit = opts.maxUncompressedBytes ?? ZIP_MAX_UNCOMPRESSED_BYTES;
41
+ const eocd = findEocd(bytes);
42
+ const count = bytes.readUInt16LE(eocd + 10);
43
+ const cdOffset = bytes.readUInt32LE(eocd + 16);
44
+ if (cdOffset === ZIP64_SENTINEL || count === 0xffff) {
45
+ throw new Error('ZIP64 archives are not supported');
46
+ }
47
+ const entries = new Map();
48
+ let p = cdOffset;
49
+ for (let i = 0; i < count; i++) {
50
+ if (bytes.readUInt32LE(p) !== CENTRAL_SIG) {
51
+ throw new Error(`corrupt ZIP central directory at offset ${p}`);
52
+ }
53
+ const nameLen = bytes.readUInt16LE(p + 28);
54
+ const extraLen = bytes.readUInt16LE(p + 30);
55
+ const commentLen = bytes.readUInt16LE(p + 32);
56
+ const name = bytes.toString('utf8', p + 46, p + 46 + nameLen);
57
+ entries.set(name, {
58
+ name,
59
+ method: bytes.readUInt16LE(p + 10),
60
+ compressedSize: bytes.readUInt32LE(p + 20),
61
+ uncompressedSize: bytes.readUInt32LE(p + 24),
62
+ localOffset: bytes.readUInt32LE(p + 42),
63
+ });
64
+ p += 46 + nameLen + extraLen + commentLen;
65
+ }
66
+ const cache = new Map();
67
+ async function read(name) {
68
+ const cached = cache.get(name);
69
+ if (cached)
70
+ return cached;
71
+ const entry = entries.get(name);
72
+ if (!entry)
73
+ return null;
74
+ // Cheap pre-check for an honest oversized member. A lying header falls
75
+ // through to the streaming cap below, which is the real guarantee.
76
+ if (entry.uncompressedSize > limit) {
77
+ throw new Error(`ZIP member ${name} is too large to extract (${entry.uncompressedSize} bytes)`);
78
+ }
79
+ // The central directory records the local header's offset, but the local
80
+ // header carries its OWN name/extra lengths (they can differ from the
81
+ // central copy), so the data offset must be computed from it.
82
+ const lo = entry.localOffset;
83
+ if (bytes.readUInt32LE(lo) !== LOCAL_SIG) {
84
+ throw new Error(`corrupt ZIP local header for ${name}`);
85
+ }
86
+ const start = lo + 30 + bytes.readUInt16LE(lo + 26) + bytes.readUInt16LE(lo + 28);
87
+ const raw = bytes.subarray(start, start + entry.compressedSize);
88
+ let out;
89
+ if (entry.method === 0)
90
+ out = Buffer.from(raw);
91
+ else if (entry.method === 8)
92
+ out = await inflateBounded(raw, 'deflate-raw', limit, `ZIP member ${name}`);
93
+ else
94
+ throw new Error(`unsupported ZIP compression method ${entry.method} for ${name}`);
95
+ cache.set(name, out);
96
+ return out;
97
+ }
98
+ return {
99
+ names: () => [...entries.keys()],
100
+ has: (name) => entries.has(name),
101
+ read,
102
+ async readText(name) {
103
+ const buf = await read(name);
104
+ if (!buf)
105
+ return null;
106
+ const text = buf.toString('utf8');
107
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
108
+ },
109
+ };
110
+ }
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
35
35
  // always succeeds before any credential check runs.
36
36
  await runMcp({
37
37
  name: 'ofw',
38
- version: '2.7.1', // x-release-please-version
38
+ version: '2.8.0', // x-release-please-version
39
39
  deps: client,
40
40
  tools: [
41
41
  registerUserTools,
package/dist/sync.js CHANGED
@@ -222,8 +222,13 @@ async function walkPages(client, folder, folderId, opts, store) {
222
222
  await fetchAttachmentMetaBudgeted(client, item.id, detailFileIds, store, budget);
223
223
  }
224
224
  }
225
- // Flush the page's rows in one transaction/RPC. Empty array is a no-op.
226
- await store.upsertMessages(toUpsert);
225
+ // Flush the page's rows in one transaction/RPC. Skipped entirely when the
226
+ // page held nothing new: on the Worker this call is a Durable-Object RPC,
227
+ // and a DO RPC counts against the same subrequest budget as an OFW fetch.
228
+ // A deep re-walk crosses page after page of already-cached messages, so an
229
+ // unconditional "no-op" write spends the caller's budget to store nothing.
230
+ if (toUpsert.length > 0)
231
+ await store.upsertMessages(toUpsert);
227
232
  if (pageBudgetHit) {
228
233
  // Paused mid-page. Resume at THIS page: the partial rows are cached, so
229
234
  // getMessages skips them next time and upserts are idempotent.
@@ -0,0 +1,99 @@
1
+ // The attachment delivery ladder.
2
+ //
3
+ // A successful fetch must always produce retrievable content. "The host cannot
4
+ // render this type" is a DISPLAY limit, and letting it become a DATA limit is
5
+ // the bug this module exists to close: `ofw_download_attachment` used to fetch
6
+ // a 10 KB custody-schedule spreadsheet, hand back an EmbeddedResource, and have
7
+ // the host reject it with "Resources of type '…spreadsheetml.sheet' are not
8
+ // currently supported" — leaving the caller holding nothing at all.
9
+ //
10
+ // Every inline delivery now walks the same rungs and returns the first that
11
+ // works:
12
+ //
13
+ // 1. host-renderable image → ImageContent (the model sees the picture)
14
+ // 2. extractable document → the FILE'S TEXT, as text (see src/extract)
15
+ // 3. raw bytes → base64 EmbeddedResource, as before
16
+ //
17
+ // Rung 3 never disappears, so nothing regresses; rung 2 is what makes a
18
+ // spreadsheet, PDF, Word or PowerPoint attachment readable at all. When a rung
19
+ // is skipped or fails, the response says so by name in `deliveryAttempts` —
20
+ // a caller must never be left guessing why it got bytes instead of content.
21
+ import { extractAttachment } from '../extract/index.js';
22
+ import { isHostRenderableImage } from './attachments.js';
23
+ /**
24
+ * Attempt extraction, converting every failure into a REASON rather than an
25
+ * exception: a format we cannot read must still be delivered as bytes, and the
26
+ * caller is owed the explanation either way.
27
+ */
28
+ export async function tryExtract(bytes, mimeType, fileName, opts) {
29
+ try {
30
+ const extracted = await extractAttachment(bytes, mimeType, fileName, {
31
+ maxChars: opts.maxChars,
32
+ parts: opts.parts,
33
+ });
34
+ if (!extracted) {
35
+ return { reason: `no text extractor for ${mimeType} (${fileName})` };
36
+ }
37
+ return { extracted, truncated: extracted.truncated ?? false };
38
+ }
39
+ catch (err) {
40
+ // A malformed .xlsx is still an .xlsx: report why it could not be read and
41
+ // fall through to the bytes, rather than failing the whole call.
42
+ return { reason: `extraction failed: ${err instanceof Error ? err.message : String(err)}` };
43
+ }
44
+ }
45
+ /**
46
+ * Build the content blocks for an inline download by walking the ladder.
47
+ * The first block is always a JSON meta block naming `deliveredVia`, so the
48
+ * caller can tell how the content arrived without inspecting block types.
49
+ */
50
+ export async function buildInlineDelivery(input) {
51
+ const { fileId, fileName, mimeType, bytes, forcedInline, options } = input;
52
+ const meta = {
53
+ fileId, fileName, mimeType, sizeBytes: bytes.length, mode: 'inline',
54
+ };
55
+ if (forcedInline)
56
+ meta.forcedInline = true;
57
+ const block = () => ({ type: 'text', text: JSON.stringify(meta, null, 2) });
58
+ // Rung 1 — the host renders these itself, and a picture beats a description.
59
+ if (isHostRenderableImage(mimeType)) {
60
+ meta.deliveredVia = 'image';
61
+ return { content: [block(), { type: 'image', data: bytes.toString('base64'), mimeType }] };
62
+ }
63
+ // Rung 2 — extraction. Skipped only when the caller explicitly opts out.
64
+ const attempts = [];
65
+ if (options.extract === false) {
66
+ attempts.push('extraction skipped (extract:false)');
67
+ }
68
+ else {
69
+ const outcome = await tryExtract(bytes, mimeType, fileName, options);
70
+ if (outcome.extracted) {
71
+ meta.deliveredVia = 'extracted';
72
+ meta.extracted = outcome.extracted;
73
+ meta.truncated = outcome.truncated;
74
+ // The bytes are deliberately NOT also attached: the extracted text is the
75
+ // readable form, and a duplicate base64 blob would be the very payload
76
+ // the host rejects — plus double the response size.
77
+ meta.note = 'Content extracted from the file. Pass extract:false to get the raw bytes instead.';
78
+ return { content: [block()] };
79
+ }
80
+ /* v8 ignore next -- tryExtract always sets `reason` when it returns no extraction */
81
+ attempts.push(outcome.reason ?? 'extraction produced no content');
82
+ }
83
+ // Rung 3 — the bytes themselves. Always available, so a fetch that succeeded
84
+ // never ends with the caller holding nothing.
85
+ meta.deliveredVia = 'blob';
86
+ meta.deliveryAttempts = attempts;
87
+ meta.note = 'Returned as raw bytes. Some hosts cannot render an embedded resource of this type; '
88
+ + 'if it came back unreadable, the file has no text extractor here (see deliveryAttempts).';
89
+ return {
90
+ content: [block(), {
91
+ type: 'resource',
92
+ resource: {
93
+ uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName)}`,
94
+ mimeType,
95
+ blob: bytes.toString('base64'),
96
+ },
97
+ }],
98
+ };
99
+ }
@@ -47,8 +47,13 @@ function isNotFound(e) {
47
47
  * Read a draft's AUTHORITATIVE state straight from OFW, bypassing the cache.
48
48
  *
49
49
  * Returns `null` when the draft no longer exists (404). Any other failure
50
- * throws `DraftFreshnessError`: a freshness check that could not run must
51
- * abort the write, never wave it through — see the callers in messages.ts.
50
+ * throws, and the callers in messages.ts abort on ALL of them: a freshness
51
+ * check that could not run must never wave the write through.
52
+ *
53
+ * Most failures throw `DraftFreshnessError` from this function, but not all —
54
+ * a strict `parseLenient` mismatch on the response throws `McpToolError`
55
+ * instead. Callers must not assume the narrower type (the catch blocks read
56
+ * only `.message`, which every Error carries).
52
57
  */
53
58
  export async function fetchServerDraft(client, id) {
54
59
  let raw;
@@ -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.
@@ -125,6 +126,48 @@ async function draftsFreshness(cache) {
125
126
  : 'unverified';
126
127
  return { freshness, serverConfirmed: cacheStatus === 'fresh', cacheStatus };
127
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
+ }
128
171
  export function registerMessageTools(server, client, cacheProvider, attachmentIO) {
129
172
  // OFW_WRITE_MODE gate (see config.ts). Send lands on the court-visible
130
173
  // record, so it is 'all'-only; draft-level writes (save/delete drafts,
@@ -201,10 +244,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
201
244
  return jsonResponse(payload);
202
245
  });
203
246
  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.',
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.',
205
248
  annotations: { readOnlyHint: false },
206
249
  inputSchema: {
207
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(),
208
252
  },
209
253
  }, async (args) => {
210
254
  const id = Number(args.messageId);
@@ -299,6 +343,14 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
299
343
  const freshness = await buildFreshness(cache, { source: 'cache', folders: [row.folder] });
300
344
  return jsonResponse({ ...withReadState(row), attachments, freshness });
301
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;
302
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)' });
303
355
  // Derive the folder for a live-fetched message. A cached row (reached here
304
356
  // only when its body was NULL) already knows its folder, so keep it.
@@ -480,10 +532,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
480
532
  server = await fetchServerDraft(client, draftId);
481
533
  }
482
534
  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.
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.)
487
542
  const reason = e.message;
488
543
  if (force) {
489
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.` };
@@ -659,7 +714,16 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
659
714
  // silent normalization becomes a visible warning rather than a surprise.
660
715
  if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
661
716
  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.`);
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.`);
663
727
  }
664
728
  // Only warn on recipients/attachments when the detail actually reported
665
729
  // them — an omitted array is "not echoed", not "dropped", and crying wolf
@@ -825,13 +889,16 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
825
889
  });
826
890
  });
827
891
  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).',
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).',
829
893
  annotations: { readOnlyHint: false },
830
894
  inputSchema: {
831
895
  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(),
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(),
833
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(),
834
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(),
835
902
  },
836
903
  }, async (args) => {
837
904
  const fileId = args.fileId;
@@ -843,6 +910,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
843
910
  // response is honest about it instead of silently ignoring the argument.
844
911
  const inline = requestedInline || !attachmentIO.supportsDisk;
845
912
  const forcedInline = inline && !requestedInline;
913
+ const deliveryOptions = { extract: args.extract, maxChars: args.maxChars, parts: args.parts };
846
914
  let cached = await cache.getAttachment(fileId);
847
915
  if (!cached) {
848
916
  // Not in cache. Fetch metadata and store under the messageId=0
@@ -871,25 +939,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
871
939
  // charset onto binaries), then fall back to the stripped header, then the
872
940
  // extension. A parameter suffix would make the host reject an image.
873
941
  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
- } }] };
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
+ });
893
948
  }
894
949
  let dest;
895
950
  // The filename comes from OFW file metadata — i.e. it is controlled by the
@@ -906,25 +961,38 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
906
961
  else {
907
962
  dest = join(getAttachmentsDir(), `${fileId}-${safeName}`);
908
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;
909
967
  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
- });
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
+ }
917
983
  }
918
984
  const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
919
985
  attachmentIO.writeDownload(dest, response.body);
920
986
  await cache.markAttachmentDownloaded(fileId, dest);
921
987
  const fileName = response.suggestedFileName ?? cached.fileName;
988
+ const mimeType = resolveDownloadMime(response.body, response.contentType ?? cached.mimeType, fileName);
922
989
  return jsonResponse({
923
990
  fileId,
924
991
  path: dest,
925
- mimeType: resolveDownloadMime(response.body, response.contentType ?? cached.mimeType, fileName),
992
+ mimeType,
926
993
  sizeBytes: response.body.length,
927
994
  fileName,
995
+ ...(extractOnDisk ? await tryExtract(response.body, mimeType, fileName, deliveryOptions) : {}),
928
996
  });
929
997
  });
930
998
  server.registerTool('ofw_sync_messages', {
@@ -932,7 +1000,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
932
1000
  annotations: { readOnlyHint: false },
933
1001
  inputSchema: {
934
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(),
935
- 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(),
936
1004
  deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
937
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(),
938
1006
  },
@@ -940,7 +1008,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
940
1008
  const cache = cacheProvider();
941
1009
  const result = await syncAll(client, {
942
1010
  folders: args.folders,
943
- 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()),
944
1015
  deep: args.deep,
945
1016
  maxRequests: args.maxRequests ?? getSyncMaxRequests(),
946
1017
  }, cache);
@@ -962,7 +1033,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
962
1033
  },
963
1034
  }, async (args) => {
964
1035
  const cache = cacheProvider();
965
- 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);
966
1040
  const requestedIds = args.messageIds ?? [];
967
1041
  const ids = requestedIds.slice(0, MAX_FRESHNESS_IDS);
968
1042
  // Folders default to "all three" only when the caller asked about nothing
@@ -982,7 +1056,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
982
1056
  ? (await cache.listDraftIds()).length
983
1057
  : await cache.countMessages({ folder });
984
1058
  const state = await cache.getSyncState(folder);
985
- 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;
986
1066
  // A partially backfilled folder legitimately holds fewer messages than
987
1067
  // the server, so a count mismatch there proves nothing. Report both
988
1068
  // numbers and leave the verdict null rather than crying wolf for the
@@ -1001,7 +1081,9 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1001
1081
  ...(inSync === null
1002
1082
  ? { note: serverCount === null
1003
1083
  ? '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.' }
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.' }
1005
1087
  : {}),
1006
1088
  });
1007
1089
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.7.1",
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)",
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.1",
9
+ "version": "2.8.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.7.1",
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