ofw-mcp 2.6.7 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -1
- package/dist/bundle.js +524 -161
- package/dist/config.js +25 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +117 -24
- package/dist/tools/attachments.js +61 -0
- package/dist/tools/freshness.js +147 -0
- package/dist/tools/messages.js +297 -36
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +16 -1
package/dist/tools/messages.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage, getDraftsCacheStatus } from '../sync.js';
|
|
3
|
+
import { buildFreshness } from './freshness.js';
|
|
3
4
|
import { checkDraftFreshness, draftRevision, fetchServerDraft, staleDraftPayload, } from './draft-freshness.js';
|
|
5
|
+
import { getFolderVerifiedAt } from '../sync.js';
|
|
6
|
+
import { isHostRenderableImage, resolveDownloadMime } from './attachments.js';
|
|
4
7
|
import { getAttachmentsDir, getDefaultInlineAttachments, getSyncMaxRequests, getWriteMode } from '../config.js';
|
|
5
8
|
import { basename, join } from 'node:path';
|
|
6
9
|
import { ApiRecipientSchema, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
|
|
@@ -43,6 +46,33 @@ const MessageDetailSchema = z.looseObject({
|
|
|
43
46
|
});
|
|
44
47
|
// Attachment-backfill detail fetch reads only `files`.
|
|
45
48
|
const DetailFilesSchema = z.looseObject({ files: z.array(z.number()).optional() });
|
|
49
|
+
// ofw_check_freshness' folder probe. `includeFolderCounts=true` returns a
|
|
50
|
+
// per-folder count, but the field name varies across OFW payload versions —
|
|
51
|
+
// accept the known spellings and degrade to a null serverCount rather than
|
|
52
|
+
// guessing, since a wrong count would manufacture a false out-of-sync verdict.
|
|
53
|
+
const FolderCountsSchema = z.looseObject({
|
|
54
|
+
systemFolders: z.array(z.looseObject({
|
|
55
|
+
id: z.string(),
|
|
56
|
+
folderType: z.string(),
|
|
57
|
+
totalCount: z.number().optional(),
|
|
58
|
+
messageCount: z.number().optional(),
|
|
59
|
+
count: z.number().optional(),
|
|
60
|
+
})).optional(),
|
|
61
|
+
});
|
|
62
|
+
const FOLDER_TYPE = {
|
|
63
|
+
inbox: 'INBOX',
|
|
64
|
+
sent: 'SENT_MESSAGES',
|
|
65
|
+
drafts: 'DRAFTS',
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Cap on per-id probes in one ofw_check_freshness call.
|
|
69
|
+
*
|
|
70
|
+
* Each id costs one OFW request, and on the hosted Worker every request counts
|
|
71
|
+
* against the subrequest cap (see OFW_SYNC_MAX_REQUESTS). The check has to stay
|
|
72
|
+
* cheap enough that a caller reaches for it freely — that is the entire point
|
|
73
|
+
* of it existing — so it truncates loudly rather than turning into a sync.
|
|
74
|
+
*/
|
|
75
|
+
const MAX_FRESHNESS_IDS = 25;
|
|
46
76
|
// Upload response — STRICT: fileId is the whole point of the call; caching
|
|
47
77
|
// or returning an undefined/mistyped fileId produces an unusable attachment.
|
|
48
78
|
const UploadedFileSchema = z.looseObject({
|
|
@@ -67,6 +97,32 @@ function listDataHintsAtFiles(listData) {
|
|
|
67
97
|
return ld.files.length > 0;
|
|
68
98
|
return false;
|
|
69
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Freshness for a drafts read, plus the per-draft `serverConfirmed` flag.
|
|
102
|
+
*
|
|
103
|
+
* `serverConfirmed` answers the question that triggered this whole mechanism:
|
|
104
|
+
* "is this draft actually still sitting unsent on OFW?" It is true ONLY when a
|
|
105
|
+
* completed drafts walk verified the cache against OFW within the freshness
|
|
106
|
+
* window (getFreshnessTtlSeconds, default 300s) — NOT a claim about this exact
|
|
107
|
+
* instant, which no cache can make. Anything less — a deferred walk, an aged
|
|
108
|
+
* stamp, a cache that was never checked — is false, meaning the draft's
|
|
109
|
+
* existence and unsent status are remembered, not known. On a false, a caller
|
|
110
|
+
* must not state either as present-tense fact without calling
|
|
111
|
+
* ofw_check_freshness first.
|
|
112
|
+
*/
|
|
113
|
+
async function draftsFreshness(cache) {
|
|
114
|
+
const freshness = await buildFreshness(cache, { source: 'cache', folders: ['drafts'] });
|
|
115
|
+
// Reconcile the two signals so a single response can never contradict
|
|
116
|
+
// itself (the same rule withReadState applies to read flags). The drafts
|
|
117
|
+
// meta key says whether the last walk COMPLETED; freshness additionally
|
|
118
|
+
// knows whether that walk has since aged out or been overtaken by a sync
|
|
119
|
+
// that skipped drafts. Downgrade only — this can turn 'fresh' off, never on.
|
|
120
|
+
const completed = await getDraftsCacheStatus(cache);
|
|
121
|
+
const cacheStatus = completed === 'fresh' && freshness.staleness === 'fresh'
|
|
122
|
+
? 'fresh'
|
|
123
|
+
: 'unverified';
|
|
124
|
+
return { freshness, serverConfirmed: cacheStatus === 'fresh', cacheStatus };
|
|
125
|
+
}
|
|
70
126
|
export function registerMessageTools(server, client, cacheProvider, attachmentIO) {
|
|
71
127
|
// OFW_WRITE_MODE gate (see config.ts). Send lands on the court-visible
|
|
72
128
|
// record, so it is 'all'-only; draft-level writes (save/delete drafts,
|
|
@@ -76,11 +132,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
76
132
|
const allowSend = writeMode === 'all';
|
|
77
133
|
const allowDrafts = writeMode !== 'none';
|
|
78
134
|
server.registerTool('ofw_list_message_folders', {
|
|
79
|
-
description: 'List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.',
|
|
135
|
+
description: 'List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Fetched LIVE from OFW, so the counts are current. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.',
|
|
80
136
|
annotations: { readOnlyHint: true },
|
|
81
137
|
}, async () => {
|
|
82
138
|
const data = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
|
|
83
|
-
|
|
139
|
+
const freshness = await buildFreshness(cacheProvider(), { source: 'live', folders: [] });
|
|
140
|
+
return jsonResponse({ folders: data, freshness });
|
|
84
141
|
});
|
|
85
142
|
server.registerTool('ofw_list_messages', {
|
|
86
143
|
description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages. Call ofw_sync_messages first if the cache is empty or stale.',
|
|
@@ -105,9 +162,16 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
105
162
|
else if (folderArg === 'both')
|
|
106
163
|
folder = undefined;
|
|
107
164
|
else {
|
|
165
|
+
// Still carries freshness: `messages: []` with no age label is exactly
|
|
166
|
+
// the shape this mechanism exists to eliminate, even when the emptiness
|
|
167
|
+
// is caused by a bad argument rather than an empty cache.
|
|
108
168
|
return jsonResponse({
|
|
109
169
|
messages: [],
|
|
110
|
-
|
|
170
|
+
freshness: await buildFreshness(cacheProvider(), {
|
|
171
|
+
source: 'cache',
|
|
172
|
+
folders: ['inbox', 'sent'],
|
|
173
|
+
}),
|
|
174
|
+
note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache. No lookup was performed — this empty result says nothing about what is in the cache.',
|
|
111
175
|
});
|
|
112
176
|
}
|
|
113
177
|
const cache = cacheProvider();
|
|
@@ -118,7 +182,14 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
118
182
|
// from the record's own `viewedAt`/`fetchedBodyAt` and `listData` is forced
|
|
119
183
|
// to agree — see withReadState.
|
|
120
184
|
const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
|
|
121
|
-
|
|
185
|
+
// Served from the local cache, so the result must say how old it is and
|
|
186
|
+
// whether anything vouches for it — a caller cannot state current state
|
|
187
|
+
// from this payload without either re-reading or surfacing the caveat.
|
|
188
|
+
const freshness = await buildFreshness(cache, {
|
|
189
|
+
source: 'cache',
|
|
190
|
+
folders: folder === undefined ? ['inbox', 'sent'] : [folder],
|
|
191
|
+
});
|
|
192
|
+
const payload = { messages, total, page, size, freshness };
|
|
122
193
|
if (total === 0) {
|
|
123
194
|
payload.note = 'No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.';
|
|
124
195
|
}
|
|
@@ -144,6 +215,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
144
215
|
// up — see syncDrafts, which also evicts these stale rows.
|
|
145
216
|
const draftRow = await cache.getDraft(id);
|
|
146
217
|
if (draftRow !== null) {
|
|
218
|
+
const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
|
|
147
219
|
return jsonResponse({
|
|
148
220
|
id: draftRow.id,
|
|
149
221
|
folder: 'drafts',
|
|
@@ -163,7 +235,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
163
235
|
// Concurrency token — pass as expectedRevision to ofw_save_draft /
|
|
164
236
|
// ofw_delete_draft to assert you are editing THIS version.
|
|
165
237
|
revision: draftRevision(draftRow),
|
|
166
|
-
cacheStatus
|
|
238
|
+
cacheStatus,
|
|
239
|
+
// False = this draft's existence and unsent status are remembered from
|
|
240
|
+
// a cache, not confirmed on OFW. Call ofw_check_freshness before
|
|
241
|
+
// stating either as current fact.
|
|
242
|
+
serverConfirmed,
|
|
243
|
+
freshness,
|
|
167
244
|
});
|
|
168
245
|
}
|
|
169
246
|
const cached = await cache.getMessage(id);
|
|
@@ -214,7 +291,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
214
291
|
// Backfill is best-effort. Fall through with whatever we have.
|
|
215
292
|
}
|
|
216
293
|
}
|
|
217
|
-
|
|
294
|
+
// Cache-served: the body is whatever the last sync stored. Even though
|
|
295
|
+
// this call may have re-hit detail for view status, the message content
|
|
296
|
+
// itself was not re-verified, so report the folder's cache freshness.
|
|
297
|
+
const freshness = await buildFreshness(cache, { source: 'cache', folders: [row.folder] });
|
|
298
|
+
return jsonResponse({ ...withReadState(row), attachments, freshness });
|
|
218
299
|
}
|
|
219
300
|
const detail = parseLenient(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (ofw_get_message)' });
|
|
220
301
|
// Derive the folder for a live-fetched message. A cached row (reached here
|
|
@@ -249,7 +330,9 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
249
330
|
await fetchAttachmentMetaForMessage(client, detail.id, detail.files, cache);
|
|
250
331
|
}
|
|
251
332
|
const attachments = await cache.listAttachmentsForMessage(detail.id);
|
|
252
|
-
|
|
333
|
+
// Fetched live from OFW in this call — current by construction.
|
|
334
|
+
const freshness = await buildFreshness(cache, { source: 'live', folders: [folder] });
|
|
335
|
+
return jsonResponse({ ...withReadState(row), attachments, freshness });
|
|
253
336
|
});
|
|
254
337
|
if (allowSend)
|
|
255
338
|
server.registerTool('ofw_send_message', {
|
|
@@ -450,17 +533,28 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
450
533
|
const page = args.page ?? 1;
|
|
451
534
|
const size = args.size ?? 50;
|
|
452
535
|
const cache = cacheProvider();
|
|
453
|
-
const cacheStatus = await
|
|
536
|
+
const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
|
|
454
537
|
const rows = await cache.listDrafts({ page, size });
|
|
455
538
|
// Every draft carries the concurrency token to echo back on a write, plus
|
|
456
|
-
// whether the last sync actually compared this cache against OFW
|
|
457
|
-
|
|
539
|
+
// whether the last sync actually compared this cache against OFW and
|
|
540
|
+
// whether its presence-on-server is confirmed or merely remembered.
|
|
541
|
+
const drafts = rows.map((d) => ({
|
|
542
|
+
...d,
|
|
543
|
+
revision: draftRevision(d),
|
|
544
|
+
cacheStatus,
|
|
545
|
+
serverConfirmed,
|
|
546
|
+
asOf: freshness.asOf,
|
|
547
|
+
}));
|
|
458
548
|
if (drafts.length === 0) {
|
|
459
|
-
return jsonResponse({
|
|
549
|
+
return jsonResponse({
|
|
550
|
+
drafts: [],
|
|
551
|
+
freshness,
|
|
552
|
+
note: 'No drafts in the local cache. That is NOT proof there are no drafts on OurFamilyWizard — call ofw_sync_messages to populate, or ofw_check_freshness to confirm.',
|
|
553
|
+
});
|
|
460
554
|
}
|
|
461
|
-
const payload = { drafts };
|
|
462
|
-
if (
|
|
463
|
-
payload.note = '
|
|
555
|
+
const payload = { drafts, freshness };
|
|
556
|
+
if (!serverConfirmed) {
|
|
557
|
+
payload.note = 'serverConfirmed:false — these drafts are remembered from the local cache, NOT confirmed to still exist unsent on OurFamilyWizard right now, and their bodies may be behind the server. Do not state that a draft "is still sitting unsent" on this basis; drafts edited or deleted in the OFW web app bump no timestamp, so the cache cannot detect it on its own. Call ofw_check_freshness (cheap, live) or ofw_sync_messages first. Writes are guarded regardless — ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
|
|
464
558
|
}
|
|
465
559
|
return jsonResponse(payload);
|
|
466
560
|
});
|
|
@@ -554,8 +648,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
554
648
|
}
|
|
555
649
|
}
|
|
556
650
|
}
|
|
651
|
+
// The draft was just re-fetched from OFW by postMessageAndRefetch, so this
|
|
652
|
+
// one row IS server-confirmed regardless of the drafts folder's overall
|
|
653
|
+
// cache freshness.
|
|
557
654
|
const responseObj = persisted !== null
|
|
558
|
-
? { ...persisted, revision: newRevision, cacheStatus: 'fresh' }
|
|
655
|
+
? { ...persisted, revision: newRevision, cacheStatus: 'fresh', serverConfirmed: true }
|
|
559
656
|
: raw;
|
|
560
657
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
|
|
561
658
|
const notes = [forceNote, rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join('\n\n');
|
|
@@ -596,9 +693,18 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
596
693
|
}, async (args) => {
|
|
597
694
|
const page = args.page ?? 1;
|
|
598
695
|
const size = args.size ?? 50;
|
|
599
|
-
const
|
|
696
|
+
const cache = cacheProvider();
|
|
697
|
+
const sent = await cache.listMessages({ folder: 'sent', page, size });
|
|
698
|
+
// "Nobody has read it yet" is a present-tense claim drawn entirely from
|
|
699
|
+
// cached view timestamps, which only move when a sync refreshes them —
|
|
700
|
+
// so it needs the same age label as any other cached read.
|
|
701
|
+
const freshness = await buildFreshness(cache, { source: 'cache', folders: ['sent'] });
|
|
600
702
|
if (sent.length === 0) {
|
|
601
|
-
return jsonResponse({
|
|
703
|
+
return jsonResponse({
|
|
704
|
+
unread: [],
|
|
705
|
+
freshness,
|
|
706
|
+
note: 'Sent cache is empty. Call ofw_sync_messages to populate. An empty cache is NOT evidence that no sent messages exist.',
|
|
707
|
+
});
|
|
602
708
|
}
|
|
603
709
|
const unread = [];
|
|
604
710
|
for (const msg of sent) {
|
|
@@ -608,9 +714,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
608
714
|
}
|
|
609
715
|
}
|
|
610
716
|
if (unread.length === 0) {
|
|
611
|
-
return jsonResponse({
|
|
717
|
+
return jsonResponse({
|
|
718
|
+
unread: [],
|
|
719
|
+
freshness,
|
|
720
|
+
message: 'All scanned sent messages had been read as of the timestamp in `freshness.asOf`. A recipient may have read a message since without the cache hearing about it.',
|
|
721
|
+
});
|
|
612
722
|
}
|
|
613
|
-
return jsonResponse(unread);
|
|
723
|
+
return jsonResponse({ unread, freshness });
|
|
614
724
|
});
|
|
615
725
|
if (allowDrafts)
|
|
616
726
|
server.registerTool('ofw_upload_attachment', {
|
|
@@ -657,18 +767,24 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
657
767
|
});
|
|
658
768
|
});
|
|
659
769
|
server.registerTool('ofw_download_attachment', {
|
|
660
|
-
description: 'Download an OFW message attachment by fileId. By default, bytes are saved to disk (~/Downloads/ofw-mcp/) and the response carries the absolute path, mime type, and size for the caller to read back. Pass inline:true to skip disk entirely and return the bytes as MCP content blocks — images come back as ImageContent (the model sees them directly); other
|
|
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).',
|
|
661
771
|
annotations: { readOnlyHint: false },
|
|
662
772
|
inputSchema: {
|
|
663
773
|
fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
|
|
664
|
-
inline: z.boolean().describe('If true, return bytes inline as MCP content (
|
|
665
|
-
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
|
|
774
|
+
inline: z.boolean().describe('If true, return bytes inline as MCP content (ImageContent for host-renderable images, embedded resource blob otherwise) and skip the disk write. If false, write to disk and return the path — except on a hosted deployment with no filesystem, where inline is forced (forcedInline:true) so the bytes are still returned. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).').optional(),
|
|
775
|
+
saveTo: z.string().describe('Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/Downloads/ofw-mcp/<fileId>-<filename>. Ignored when inline is in effect.').optional(),
|
|
666
776
|
force: z.boolean().describe('Re-download even if already on disk. Default false. Ignored when inline:true (inline always fetches fresh bytes, or reuses an on-disk copy if present).').optional(),
|
|
667
777
|
},
|
|
668
778
|
}, async (args) => {
|
|
669
779
|
const fileId = args.fileId;
|
|
670
780
|
const cache = cacheProvider();
|
|
671
|
-
const
|
|
781
|
+
const requestedInline = args.inline ?? getDefaultInlineAttachments();
|
|
782
|
+
// When the deployment has no filesystem (hosted connector), inline is the
|
|
783
|
+
// ONLY path to the bytes — force it rather than erroring on a disk write.
|
|
784
|
+
// `forcedInline` records that we overrode an explicit `inline:false` so the
|
|
785
|
+
// response is honest about it instead of silently ignoring the argument.
|
|
786
|
+
const inline = requestedInline || !attachmentIO.supportsDisk;
|
|
787
|
+
const forcedInline = inline && !requestedInline;
|
|
672
788
|
let cached = await cache.getAttachment(fileId);
|
|
673
789
|
if (!cached) {
|
|
674
790
|
// Not in cache. Fetch metadata and store under the messageId=0
|
|
@@ -682,7 +798,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
682
798
|
if (inline) {
|
|
683
799
|
// Reuse on-disk bytes if we already have them; otherwise fetch fresh.
|
|
684
800
|
let bytes = null;
|
|
685
|
-
let
|
|
801
|
+
let headerMime = cached.mimeType;
|
|
686
802
|
let fileName = cached.fileName;
|
|
687
803
|
if (cached.downloadedPath) {
|
|
688
804
|
bytes = attachmentIO.readDownloaded(cached.downloadedPath);
|
|
@@ -690,14 +806,25 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
690
806
|
if (bytes === null) {
|
|
691
807
|
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
692
808
|
bytes = response.body;
|
|
693
|
-
|
|
809
|
+
headerMime = response.contentType ?? cached.mimeType;
|
|
694
810
|
fileName = response.suggestedFileName ?? cached.fileName;
|
|
695
811
|
}
|
|
812
|
+
// Normalize to a bare media type: sniff the bytes first (OFW tacks a bogus
|
|
813
|
+
// charset onto binaries), then fall back to the stripped header, then the
|
|
814
|
+
// extension. A parameter suffix would make the host reject an image.
|
|
815
|
+
const mimeType = resolveDownloadMime(bytes, headerMime, fileName);
|
|
696
816
|
const base64 = bytes.toString('base64');
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
if (
|
|
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)) {
|
|
701
828
|
return { content: [metaBlock, { type: 'image', data: base64, mimeType }] };
|
|
702
829
|
}
|
|
703
830
|
return { content: [metaBlock, { type: 'resource', resource: {
|
|
@@ -723,38 +850,172 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
723
850
|
}
|
|
724
851
|
if (!args.force && cached.downloadedPath === dest) {
|
|
725
852
|
return jsonResponse({
|
|
726
|
-
|
|
727
|
-
|
|
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',
|
|
728
858
|
});
|
|
729
859
|
}
|
|
730
860
|
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
731
861
|
attachmentIO.writeDownload(dest, response.body);
|
|
732
862
|
await cache.markAttachmentDownloaded(fileId, dest);
|
|
863
|
+
const fileName = response.suggestedFileName ?? cached.fileName;
|
|
733
864
|
return jsonResponse({
|
|
734
865
|
fileId,
|
|
735
866
|
path: dest,
|
|
736
|
-
mimeType: response.contentType ?? cached.mimeType,
|
|
867
|
+
mimeType: resolveDownloadMime(response.body, response.contentType ?? cached.mimeType, fileName),
|
|
737
868
|
sizeBytes: response.body.length,
|
|
738
|
-
fileName
|
|
869
|
+
fileName,
|
|
739
870
|
});
|
|
740
871
|
});
|
|
741
872
|
server.registerTool('ofw_sync_messages', {
|
|
742
873
|
description: 'Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. EVERY call re-checks the newest page first, so new messages are picked up promptly even while an old-history backfill is still running; only then does it spend what is left of its budget advancing that backfill. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps). Sync is BOUNDED and RESUMABLE: on hosted deployments a per-call OFW-request budget (env OFW_SYNC_MAX_REQUESTS, or the maxRequests argument) caps how far one call walks; when the budget is hit the response reports done:false with a note — call again with the SAME arguments to resume. done:false means older history is still being backfilled; it does NOT mean recent messages are missing. Local installs are unbounded by default (done is always true).',
|
|
743
874
|
annotations: { readOnlyHint: false },
|
|
744
875
|
inputSchema: {
|
|
745
|
-
folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).describe('Folders to sync (default: all three)').optional(),
|
|
876
|
+
folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).min(1).describe('Folders to sync (default: all three). Must be non-empty if given — an empty list would sync nothing while reporting success.').optional(),
|
|
746
877
|
fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.').optional(),
|
|
747
878
|
deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
|
|
748
879
|
maxRequests: z.number().int().min(1).describe('Maximum OFW requests this single call may make before pausing. When hit, the response reports done:false — call again with the same arguments to continue. Omit to use the server default (OFW_SYNC_MAX_REQUESTS, or unbounded on local installs).').optional(),
|
|
749
880
|
},
|
|
750
881
|
}, async (args) => {
|
|
882
|
+
const cache = cacheProvider();
|
|
751
883
|
const result = await syncAll(client, {
|
|
752
884
|
folders: args.folders,
|
|
753
885
|
fetchUnreadBodies: args.fetchUnreadBodies,
|
|
754
886
|
deep: args.deep,
|
|
755
887
|
maxRequests: args.maxRequests ?? getSyncMaxRequests(),
|
|
756
|
-
},
|
|
757
|
-
|
|
888
|
+
}, cache);
|
|
889
|
+
// Freshness of the cache AS OF this sync completing — so a paused call
|
|
890
|
+
// that skipped a folder says so here too, not just in `notRefreshed`.
|
|
891
|
+
const freshness = await buildFreshness(cache, {
|
|
892
|
+
source: 'cache',
|
|
893
|
+
folders: args.folders ?? ['inbox', 'sent', 'drafts'],
|
|
894
|
+
});
|
|
895
|
+
return jsonResponse({ ...result, freshness });
|
|
896
|
+
});
|
|
897
|
+
server.registerTool('ofw_check_freshness', {
|
|
898
|
+
description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state — especially "draft X is still sitting unsent" — when a read returned serverConfirmed:false or freshness.staleness other than "fresh". Costs one OFW request for the folder check plus one per messageId. For each folder it returns the live server count next to the cached count; for each id, whether it still exists on OFW and whether its content matches the cache (compared by content revision, because OFW draft timestamps do NOT change when a draft is edited in the web app). Does not fetch bodies into the cache, does not touch attachments, and does not depend on sync state.',
|
|
899
|
+
annotations: { readOnlyHint: true },
|
|
900
|
+
inputSchema: {
|
|
901
|
+
folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).min(1).describe('Folders to compare cached vs live counts for. Defaults to all three when messageIds is not given. Must be non-empty if given.').optional(),
|
|
902
|
+
messageIds: z.array(z.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}). By default only ids present in the drafts cache are probed — see allowMarkRead.`).optional(),
|
|
903
|
+
allowMarkRead: z.boolean().describe('Default false. Probing an id that is NOT a cached draft requires fetching its detail, which marks an unread inbox message as READ on OurFamilyWizard — an irreversible change to the record. Such ids are skipped unless you set this to true.').optional(),
|
|
904
|
+
},
|
|
905
|
+
}, async (args) => {
|
|
906
|
+
const cache = cacheProvider();
|
|
907
|
+
const allowMarkRead = args.allowMarkRead ?? false;
|
|
908
|
+
const requestedIds = args.messageIds ?? [];
|
|
909
|
+
const ids = requestedIds.slice(0, MAX_FRESHNESS_IDS);
|
|
910
|
+
// Folders default to "all three" only when the caller asked about nothing
|
|
911
|
+
// else; an ids-only call shouldn't silently spend a request on folders.
|
|
912
|
+
const wantFolders = args.folders
|
|
913
|
+
?? (requestedIds.length > 0 ? [] : ['inbox', 'sent', 'drafts']);
|
|
914
|
+
let requestsUsed = 0;
|
|
915
|
+
const folders = [];
|
|
916
|
+
if (wantFolders.length > 0) {
|
|
917
|
+
requestsUsed++;
|
|
918
|
+
const data = parseLenient(FolderCountsSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), { label: 'ofw-mcp', context: 'GET /pub/v1/messageFolders (ofw_check_freshness)' });
|
|
919
|
+
const sys = data.systemFolders ?? [];
|
|
920
|
+
for (const folder of wantFolders) {
|
|
921
|
+
const entry = sys.find((x) => x.folderType === FOLDER_TYPE[folder]);
|
|
922
|
+
const serverCount = entry?.totalCount ?? entry?.messageCount ?? entry?.count ?? null;
|
|
923
|
+
const cachedCount = folder === 'drafts'
|
|
924
|
+
? (await cache.listDraftIds()).length
|
|
925
|
+
: await cache.countMessages({ folder });
|
|
926
|
+
const state = await cache.getSyncState(folder);
|
|
927
|
+
const historyComplete = state !== null && state.resumePage === null;
|
|
928
|
+
// A partially backfilled folder legitimately holds fewer messages than
|
|
929
|
+
// the server, so a count mismatch there proves nothing. Report both
|
|
930
|
+
// numbers and leave the verdict null rather than crying wolf for the
|
|
931
|
+
// entire duration of a backfill.
|
|
932
|
+
const inSync = serverCount === null || !historyComplete
|
|
933
|
+
? null
|
|
934
|
+
: serverCount === cachedCount;
|
|
935
|
+
folders.push({
|
|
936
|
+
folder,
|
|
937
|
+
existsOnServer: entry !== undefined,
|
|
938
|
+
serverCount,
|
|
939
|
+
cachedCount,
|
|
940
|
+
historyComplete,
|
|
941
|
+
lastVerifiedAt: await getFolderVerifiedAt(cache, folder),
|
|
942
|
+
inSync,
|
|
943
|
+
...(inSync === null
|
|
944
|
+
? { note: serverCount === null
|
|
945
|
+
? 'OFW did not report a count for this folder, so cached-vs-server cannot be compared. Use the per-id check instead.'
|
|
946
|
+
: 'Older history is still being backfilled, so a lower cachedCount is expected and does not indicate drift.' }
|
|
947
|
+
: {}),
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const items = [];
|
|
952
|
+
for (const id of ids) {
|
|
953
|
+
const cachedDraft = await cache.getDraft(id);
|
|
954
|
+
// Drafts have no read state, so probing one is genuinely side-effect
|
|
955
|
+
// free. Any other id means GET /pub/v3/messages/{id}, which marks an
|
|
956
|
+
// unread inbox message read on OFW — a permanent change to a
|
|
957
|
+
// court-visible record. Refuse by default rather than quietly doing it.
|
|
958
|
+
if (cachedDraft === null && !allowMarkRead) {
|
|
959
|
+
items.push({
|
|
960
|
+
id,
|
|
961
|
+
skipped: true,
|
|
962
|
+
reason: 'NOT_A_CACHED_DRAFT',
|
|
963
|
+
note: 'Not in the drafts cache. Verifying it requires fetching its detail from OFW, which would mark an unread inbox message as READ on OurFamilyWizard. Pass allowMarkRead:true if that is acceptable.',
|
|
964
|
+
});
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
requestsUsed++;
|
|
968
|
+
try {
|
|
969
|
+
const server = await fetchServerDraft(client, id);
|
|
970
|
+
const cacheRevision = cachedDraft === null ? null : draftRevision(cachedDraft);
|
|
971
|
+
if (server === null) {
|
|
972
|
+
items.push({
|
|
973
|
+
id,
|
|
974
|
+
existsOnServer: false,
|
|
975
|
+
inSync: false,
|
|
976
|
+
cacheRevision,
|
|
977
|
+
serverRevision: null,
|
|
978
|
+
note: cachedDraft === null
|
|
979
|
+
? 'Not found on OurFamilyWizard.'
|
|
980
|
+
: 'This draft is in the local cache but NO LONGER EXISTS on OurFamilyWizard — it was sent or deleted elsewhere. Do not describe it as still unsent.',
|
|
981
|
+
});
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
const serverRevision = draftRevision(server);
|
|
985
|
+
items.push({
|
|
986
|
+
id,
|
|
987
|
+
existsOnServer: true,
|
|
988
|
+
cacheRevision,
|
|
989
|
+
serverRevision,
|
|
990
|
+
inSync: cacheRevision !== null && cacheRevision === serverRevision,
|
|
991
|
+
...(cacheRevision === null
|
|
992
|
+
? { note: 'Exists on OurFamilyWizard but is not in the local cache.' }
|
|
993
|
+
: cacheRevision !== serverRevision
|
|
994
|
+
? { note: 'Content differs from the cache — it was edited on OurFamilyWizard since the last sync. Run ofw_sync_messages before reading or writing it.' }
|
|
995
|
+
: {}),
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
catch (e) {
|
|
999
|
+
// A check that could not run must not read as "in sync".
|
|
1000
|
+
items.push({
|
|
1001
|
+
id,
|
|
1002
|
+
error: 'FRESHNESS_CHECK_FAILED',
|
|
1003
|
+
message: e.message,
|
|
1004
|
+
inSync: null,
|
|
1005
|
+
note: 'The freshness check itself failed, so nothing is confirmed either way.',
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
const payload = {
|
|
1010
|
+
checkedAt: new Date().toISOString(),
|
|
1011
|
+
requestsUsed,
|
|
1012
|
+
...(folders.length > 0 ? { folders } : {}),
|
|
1013
|
+
...(items.length > 0 ? { items } : {}),
|
|
1014
|
+
};
|
|
1015
|
+
if (requestedIds.length > ids.length) {
|
|
1016
|
+
payload.note = `Only the first ${MAX_FRESHNESS_IDS} of ${requestedIds.length} messageIds were checked (per-call cap). The remaining ${requestedIds.length - ids.length} were NOT verified — call again with the rest.`;
|
|
1017
|
+
}
|
|
1018
|
+
return jsonResponse(payload);
|
|
758
1019
|
});
|
|
759
1020
|
}
|
|
760
1021
|
// OFW's bulk-delete endpoint takes a multipart form with `messageIds`.
|
package/package.json
CHANGED
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.7.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.7.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
package/skills/ofw/SKILL.md
CHANGED
|
@@ -97,11 +97,12 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
|
|
|
97
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. |
|
|
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
|
-
| `ofw_list_drafts` | List saved drafts (cache-backed). |
|
|
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
104
|
| `ofw_download_attachment(fileId, inline?, saveTo?, force?)` | Download an attachment. `inline:true` returns bytes as MCP content; default writes to `~/Downloads/ofw-mcp/`. |
|
|
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). |
|
|
105
106
|
|
|
106
107
|
### Calendar
|
|
107
108
|
| Tool | Notes |
|
|
@@ -124,6 +125,19 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
|
|
|
124
125
|
| `ofw_list_journal_entries(start?, max?)` | 1-based offset; default max 10 |
|
|
125
126
|
| `ofw_create_journal_entry(title, body)` | Create a new entry |
|
|
126
127
|
|
|
128
|
+
## Freshness
|
|
129
|
+
|
|
130
|
+
Message and draft reads come from a local cache, so **a result can be stale without looking stale**. Every read tool returns a `freshness` block: `staleness` (`fresh`/`unverified`/`stale`), `asOf`, `ageSeconds`, and a `warning` whenever it is not `fresh`. Drafts additionally carry `serverConfirmed`.
|
|
131
|
+
|
|
132
|
+
Rules for using it:
|
|
133
|
+
|
|
134
|
+
- **Never state current state from memory.** If you saved a draft earlier in the session, that is not evidence it still exists unsent now — the user may have sent or deleted it in the web app since.
|
|
135
|
+
- **`serverConfirmed: false` means "remembered, not known."** Do not say a draft "is still sitting unsent" on that basis. Call `ofw_check_freshness(messageIds: [id])` first, or say plainly that you are reporting cached state and give its age.
|
|
136
|
+
- **If `freshness.staleness` is not `fresh`, either re-read or surface the caveat** in your answer. The `warning` string is written to be quotable.
|
|
137
|
+
- OFW does **not** bump a draft's timestamp when it is edited in the web app, which is why freshness is tracked separately and compared by content revision. "Nothing changed" and "we didn't look" are otherwise indistinguishable.
|
|
138
|
+
- A missing folder count in `ofw_sync_messages` output means that folder was **not checked** — it is never "no changes". Check `notRefreshed`.
|
|
139
|
+
|
|
140
|
+
|
|
127
141
|
## Workflows
|
|
128
142
|
|
|
129
143
|
**Check inbox:**
|
|
@@ -152,3 +166,4 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
|
|
|
152
166
|
- **Always confirm before sending messages or deleting anything** — OFW is a legal co-parenting record.
|
|
153
167
|
- `ofw_get_notifications` updates last-seen status — avoid calling silently in the background.
|
|
154
168
|
- `ofw_get_message` marks messages read — warn the user if they want to keep something unread.
|
|
169
|
+
- **Do not narrate cached state as present fact.** Check `freshness`/`serverConfirmed` before saying what "is" true on OFW right now, and prefer `ofw_check_freshness` over guessing — it is one cheap call.
|