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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -2
- package/dist/bundle.js +1081 -58
- package/dist/config.js +42 -0
- package/dist/extract/document.js +83 -0
- package/dist/extract/index.js +222 -0
- package/dist/extract/inflate.js +55 -0
- package/dist/extract/ooxml.js +58 -0
- package/dist/extract/pdf.js +278 -0
- package/dist/extract/presentation.js +54 -0
- package/dist/extract/spreadsheet.js +258 -0
- package/dist/extract/types.js +4 -0
- package/dist/extract/xml.js +61 -0
- package/dist/extract/zip.js +110 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +7 -2
- package/dist/tools/delivery.js +99 -0
- package/dist/tools/draft-freshness.js +56 -4
- package/dist/tools/messages.js +191 -51
- package/package.json +3 -3
- package/server.json +14 -2
- package/skills/ofw/SKILL.md +2 -2
package/dist/tools/messages.js
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
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
|
|
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
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
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
|
-
|
|
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
|
|
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:
|
|
629
|
-
replyToId:
|
|
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
|
-
? {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "2.8.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.
|
|
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
|
}
|
package/skills/ofw/SKILL.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|