ofw-mcp 2.4.4 → 2.6.3

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.
@@ -1,9 +1,113 @@
1
1
  import { z } from 'zod';
2
2
  import { jsonResponse, textResponse } from './_shared.js';
3
- import { getWriteMode } from '../config.js';
3
+ import { getCalendarWritesAllowed } from '../config.js';
4
+ import { parseLenient } from '@chrischall/mcp-utils';
5
+ // OFW's real event-write API (reverse-engineered from the web app bundle):
6
+ // POST /pub/v3/events create — 201 with the full event
7
+ // GET /pub/v3/events/{eventRecurrenceId} detail
8
+ // PUT /pub/v3/events/{eventRecurrenceId} update — full payload, not a patch
9
+ // DELETE /pub/v3/events/{eventRecurrenceId}?includeFuture=<bool>
10
+ // The id in every URL is `eventRecurrenceId` — the same value calendar
11
+ // listings expose as `id`. (`eventId` in the response is a different,
12
+ // internal identifier; never put it in a URL.)
13
+ // Payload gotchas: dates are `YYYY-MM-DD` with separate `HH:mm` times;
14
+ // privacy is `publicFlag` (true = shared with co-parent); parent ids must be
15
+ // OMITTED when unset — the web form's "0" placeholders draw a 409
16
+ // "Must be a parent" from the API.
17
+ const ofwDate = z.looseObject({ dateTime: z.string() });
18
+ const userRef = z.looseObject({ userId: z.number() });
19
+ const eventDetailSchema = z.looseObject({
20
+ eventRecurrenceId: z.number(),
21
+ title: z.string(),
22
+ allDay: z.boolean(),
23
+ publicFlag: z.boolean(),
24
+ startDate: ofwDate,
25
+ endDate: ofwDate,
26
+ location: z.string().nullish(),
27
+ notes: z.string().nullish(),
28
+ reminderMinutes: z.number().nullish(),
29
+ children: z.array(userRef).nullish(),
30
+ eventParent: userRef.nullish(),
31
+ dropOffParent: userRef.nullish(),
32
+ pickUpParent: userRef.nullish(),
33
+ });
34
+ const eventWriteFields = {
35
+ startDate: z.string().describe('Start date YYYY-MM-DD'),
36
+ endDate: z.string().describe('End date YYYY-MM-DD (default: startDate)').optional(),
37
+ startTime: z.string().describe('Start time HH:mm, 24-hour (required unless allDay)').optional(),
38
+ endTime: z.string().describe('End time HH:mm, 24-hour (required unless allDay)').optional(),
39
+ allDay: z.boolean().optional(),
40
+ privateEvent: z.boolean().describe('true = visible only to you; default false = shared with co-parent').optional(),
41
+ location: z.string().optional(),
42
+ notes: z.string().optional(),
43
+ reminderMinutes: z.number().int().min(0).optional(),
44
+ children: z.array(z.number()).describe('Child userIds to tag (see ofw_get_profile)').optional(),
45
+ eventParentId: z.number().describe("userId of the parent the event is 'for'").optional(),
46
+ dropOffParentId: z.number().describe('userId of the drop-off parent').optional(),
47
+ pickUpParentId: z.number().describe('userId of the pick-up parent').optional(),
48
+ };
49
+ function buildEventPayload(a) {
50
+ const allDay = a.allDay ?? false;
51
+ if (!allDay && (!a.startTime || !a.endTime)) {
52
+ throw new Error('startTime and endTime (HH:mm) are required unless allDay is true');
53
+ }
54
+ const payload = {
55
+ title: a.title,
56
+ startDate: a.startDate,
57
+ endDate: a.endDate ?? a.startDate,
58
+ // The web form always sends times; for all-day events it uses 01:00/02:00
59
+ // placeholders that OFW ignores.
60
+ startTime: a.startTime ?? '01:00',
61
+ endTime: a.endTime ?? '02:00',
62
+ allDay,
63
+ publicFlag: !(a.privateEvent ?? false),
64
+ };
65
+ if (a.location)
66
+ payload.location = a.location;
67
+ if (a.notes)
68
+ payload.notes = a.notes;
69
+ if (a.reminderMinutes !== undefined)
70
+ payload.reminderMinutes = String(a.reminderMinutes);
71
+ // Omitted `children` PRESERVES existing tags on PUT; an explicit [] CLEARS
72
+ // them (verified live 2026-07-13) — so send the array whenever it's defined.
73
+ if (a.children !== undefined)
74
+ payload.children = a.children;
75
+ if (a.eventParentId !== undefined)
76
+ payload.eventParentId = String(a.eventParentId);
77
+ if (a.dropOffParentId !== undefined)
78
+ payload.dropOffParentId = String(a.dropOffParentId);
79
+ if (a.pickUpParentId !== undefined)
80
+ payload.pickUpParentId = String(a.pickUpParentId);
81
+ return payload;
82
+ }
83
+ // Map a detail response back onto write-args so ofw_update_event can send the
84
+ // full payload OFW's PUT expects while the caller only names the changes.
85
+ function detailToWriteArgs(d) {
86
+ const [startDate, startClock] = d.startDate.dateTime.split('T');
87
+ const [endDate, endClock] = d.endDate.dateTime.split('T');
88
+ return {
89
+ title: d.title,
90
+ startDate,
91
+ endDate,
92
+ startTime: (startClock ?? '01:00:00').slice(0, 5),
93
+ endTime: (endClock ?? '02:00:00').slice(0, 5),
94
+ allDay: d.allDay,
95
+ privateEvent: !d.publicFlag,
96
+ location: d.location ?? undefined,
97
+ notes: d.notes ?? undefined,
98
+ reminderMinutes: d.reminderMinutes ?? undefined,
99
+ // Untagged (nullish or empty) → undefined, so the merged PUT omits the
100
+ // field (omission preserves; only a CALLER-supplied [] should clear).
101
+ children: d.children?.length ? d.children.map((c) => c.userId) : undefined,
102
+ eventParentId: d.eventParent?.userId,
103
+ dropOffParentId: d.dropOffParent?.userId,
104
+ pickUpParentId: d.pickUpParent?.userId,
105
+ };
106
+ }
4
107
  export function registerCalendarTools(server, client) {
5
- // Calendar writes land on the court-visible record OFW_WRITE_MODE 'all' only.
6
- const allowWrites = getWriteMode() === 'all';
108
+ // Calendar writes land on the court-visible record with no draft stage, but
109
+ // events are reversible 'all' mode, or 'drafts' + OFW_CALENDAR_WRITES=true.
110
+ const allowWrites = getCalendarWritesAllowed();
7
111
  server.registerTool('ofw_list_events', {
8
112
  description: 'List OurFamilyWizard calendar events in a date range',
9
113
  annotations: { readOnlyHint: true },
@@ -19,53 +123,65 @@ export function registerCalendarTools(server, client) {
19
123
  });
20
124
  if (allowWrites)
21
125
  server.registerTool('ofw_create_event', {
22
- description: 'Create a calendar event in OurFamilyWizard',
126
+ description: 'Create a calendar event in OurFamilyWizard. Unless privateEvent is true, the event is immediately visible to the co-parent — there is no draft stage.',
23
127
  annotations: { destructiveHint: false },
24
128
  inputSchema: {
25
129
  title: z.string(),
26
- startDate: z.string().describe('ISO datetime string'),
27
- endDate: z.string().describe('ISO datetime string'),
28
- allDay: z.boolean().optional(),
29
- location: z.string().optional(),
30
- reminder: z.string().describe('Reminder setting (e.g. "1 hour before")').optional(),
31
- privateEvent: z.boolean().optional(),
32
- eventFor: z.string().describe('neither | parent1 | parent2').optional(),
33
- dropOffParent: z.string().optional(),
34
- pickUpParent: z.string().optional(),
35
- children: z.array(z.number()).describe('Array of child IDs').optional(),
130
+ ...eventWriteFields,
36
131
  },
37
132
  }, async (args) => {
38
- const data = await client.request('POST', '/pub/v1/calendar/events', args);
39
- return jsonResponse(data);
133
+ const raw = await client.request('POST', '/pub/v3/events', buildEventPayload(args));
134
+ const event = parseLenient(eventDetailSchema, raw, { label: 'ofw-mcp', context: 'POST /pub/v3/events', mode: 'strict' });
135
+ return jsonResponse({
136
+ note: `Event created. Use eventRecurrenceId ${event.eventRecurrenceId} as eventId for ofw_update_event/ofw_delete_event.`,
137
+ event,
138
+ });
40
139
  });
41
140
  if (allowWrites)
42
141
  server.registerTool('ofw_update_event', {
43
- description: 'Update an existing OurFamilyWizard calendar event',
142
+ description: 'Update an existing OurFamilyWizard calendar event. Fetches the event, applies the given changes, and writes the merged result back (OFW has no partial update).',
44
143
  annotations: { destructiveHint: true },
45
144
  inputSchema: {
46
- eventId: z.string(),
145
+ eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
47
146
  title: z.string().optional(),
48
- startDate: z.string().optional(),
49
- endDate: z.string().optional(),
50
- allDay: z.boolean().optional(),
51
- location: z.string().optional(),
52
- reminder: z.string().optional(),
53
- privateEvent: z.boolean().optional(),
147
+ startDate: eventWriteFields.startDate.optional(),
148
+ endDate: eventWriteFields.endDate,
149
+ startTime: eventWriteFields.startTime,
150
+ endTime: eventWriteFields.endTime,
151
+ allDay: eventWriteFields.allDay,
152
+ privateEvent: eventWriteFields.privateEvent,
153
+ location: eventWriteFields.location,
154
+ notes: eventWriteFields.notes,
155
+ reminderMinutes: eventWriteFields.reminderMinutes,
156
+ children: z.array(z.number()).describe('Child userIds to tag; pass [] to remove all child tags (omit to keep current tags)').optional(),
157
+ eventParentId: eventWriteFields.eventParentId,
158
+ dropOffParentId: eventWriteFields.dropOffParentId,
159
+ pickUpParentId: eventWriteFields.pickUpParentId,
54
160
  },
55
161
  }, async (args) => {
56
- const { eventId, ...updateData } = args;
57
- const data = await client.request('PUT', `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
58
- return jsonResponse(data);
162
+ const { eventId, ...changes } = args;
163
+ const id = encodeURIComponent(eventId);
164
+ const rawDetail = await client.request('GET', `/pub/v3/events/${id}`);
165
+ const current = parseLenient(eventDetailSchema, rawDetail, { label: 'ofw-mcp', context: `GET /pub/v3/events/${eventId}`, mode: 'strict' });
166
+ const defined = Object.fromEntries(Object.entries(changes).filter(([, v]) => v !== undefined));
167
+ const merged = { ...detailToWriteArgs(current), ...defined };
168
+ await client.request('PUT', `/pub/v3/events/${id}`, buildEventPayload(merged));
169
+ // PUT responses aren't documented — re-fetch the detail as authoritative state.
170
+ const rawAfter = await client.request('GET', `/pub/v3/events/${id}`);
171
+ const event = parseLenient(eventDetailSchema, rawAfter, { label: 'ofw-mcp', context: `GET /pub/v3/events/${eventId} (post-update)`, mode: 'strict' });
172
+ return jsonResponse({ note: 'Event updated; returning re-fetched event state.', event });
59
173
  });
60
174
  if (allowWrites)
61
175
  server.registerTool('ofw_delete_event', {
62
176
  description: 'Delete an OurFamilyWizard calendar event',
63
177
  annotations: { destructiveHint: true },
64
178
  inputSchema: {
65
- eventId: z.string().describe('Event ID to delete'),
179
+ eventId: z.string().describe('Event id the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
180
+ includeFuture: z.boolean().describe('For repeating events: also delete future occurrences (default false)').optional(),
66
181
  },
67
182
  }, async (args) => {
68
- await client.request('DELETE', `/pub/v1/calendar/events/${encodeURIComponent(args.eventId)}`);
183
+ const includeFuture = args.includeFuture ?? false;
184
+ await client.request('DELETE', `/pub/v3/events/${encodeURIComponent(args.eventId)}?includeFuture=${includeFuture}`);
69
185
  return textResponse(`Event ${args.eventId} deleted`);
70
186
  });
71
187
  }
@@ -1,11 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
3
- import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, listAttachmentsForMessage, getAttachment, upsertAttachmentForMessage, markAttachmentDownloaded, getDraft, } from '../cache.js';
4
- import { getAttachmentsDir, getDefaultInlineAttachments, getWriteMode } from '../config.js';
5
- import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
- import { basename, dirname, extname, join } from 'node:path';
7
- import { fileBlob } from '@chrischall/mcp-utils';
8
- import { ApiRecipientSchema, expandPath, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
3
+ import { getAttachmentsDir, getDefaultInlineAttachments, getSyncMaxRequests, getWriteMode } from '../config.js';
4
+ import { basename, join } from 'node:path';
5
+ import { ApiRecipientSchema, expandPath, hasRealView, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
9
6
  import { parseLenient } from '@chrischall/mcp-utils';
10
7
  // Schemas for the load-bearing fields of each /pub/v3 response this file
11
8
  // reads (issue #83). Loose: unknown keys pass through into cached listData.
@@ -38,6 +35,10 @@ const MessageDetailSchema = z.looseObject({
38
35
  from: z.looseObject({ name: z.string().optional() }).optional(),
39
36
  files: z.array(z.number()).optional(),
40
37
  recipients: z.array(ApiRecipientSchema).optional(),
38
+ // The detail payload carries its own owning folder ({id, name}). We read the
39
+ // id to label a live-fetched message sent-vs-inbox instead of blindly
40
+ // defaulting to inbox — see the folder derivation in ofw_get_message.
41
+ folder: z.looseObject({ id: z.number() }).optional(),
41
42
  });
42
43
  // Attachment-backfill detail fetch reads only `files`.
43
44
  const DetailFilesSchema = z.looseObject({ files: z.array(z.number()).optional() });
@@ -51,33 +52,6 @@ const UploadedFileSchema = z.looseObject({
51
52
  sizeInBytes: z.number().optional(),
52
53
  shareClass: z.string().optional(),
53
54
  });
54
- // Lightweight mime sniff from extension. OFW re-derives mime from the filename
55
- // server-side anyway, so this is just a polite Content-Type for the Blob.
56
- const MIME_BY_EXT = {
57
- '.pdf': 'application/pdf',
58
- '.png': 'image/png',
59
- '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
60
- '.gif': 'image/gif',
61
- '.webp': 'image/webp',
62
- '.heic': 'image/heic',
63
- '.txt': 'text/plain',
64
- '.md': 'text/markdown',
65
- '.csv': 'text/csv',
66
- '.html': 'text/html', '.htm': 'text/html',
67
- '.json': 'application/json',
68
- '.xml': 'application/xml',
69
- '.doc': 'application/msword',
70
- '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
71
- '.xls': 'application/vnd.ms-excel',
72
- '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
73
- '.ppt': 'application/vnd.ms-powerpoint',
74
- '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
75
- '.zip': 'application/zip',
76
- '.ics': 'text/calendar',
77
- };
78
- function mimeFromName(name) {
79
- return MIME_BY_EXT[extname(name).toLowerCase()] ?? 'application/octet-stream';
80
- }
81
55
  // The list endpoint payload (cached as `listData`) reports attachments via
82
56
  // `files: <count>` (a number) — the actual fileIds only appear on the detail
83
57
  // endpoint as `files: [number, ...]`. Some intermediate shapes return an
@@ -92,7 +66,7 @@ function listDataHintsAtFiles(listData) {
92
66
  return ld.files.length > 0;
93
67
  return false;
94
68
  }
95
- export function registerMessageTools(server, client) {
69
+ export function registerMessageTools(server, client, cacheProvider, attachmentIO) {
96
70
  // OFW_WRITE_MODE gate (see config.ts). Send lands on the court-visible
97
71
  // record, so it is 'all'-only; draft-level writes (save/delete drafts,
98
72
  // upload attachments) also register under 'drafts'. Read/sync/download
@@ -135,9 +109,10 @@ export function registerMessageTools(server, client) {
135
109
  note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.',
136
110
  });
137
111
  }
112
+ const cache = cacheProvider();
138
113
  const filter = { folder, since: args.since, until: args.until, q: args.q };
139
- const total = countMessages(filter);
140
- const messages = listMessages({ ...filter, page, size });
114
+ const total = await cache.countMessages(filter);
115
+ const messages = await cache.listMessages({ ...filter, page, size });
141
116
  const payload = { messages, total, page, size };
142
117
  if (total === 0) {
143
118
  payload.note = 'No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.';
@@ -155,13 +130,14 @@ export function registerMessageTools(server, client) {
155
130
  },
156
131
  }, async (args) => {
157
132
  const id = Number(args.messageId);
133
+ const cache = cacheProvider();
158
134
  // Draft routing: if this id is in the drafts cache, return a
159
135
  // MessageRow-shaped synthesis built from the draft. The drafts table
160
136
  // is the source of truth for draft bodies (sync keeps it fresh);
161
137
  // the messages-table cache for the same id is stale by construction
162
138
  // when ofw_get_message was called on a draft id before sync caught
163
139
  // up — see syncDrafts, which also evicts these stale rows.
164
- const draftRow = getDraft(id);
140
+ const draftRow = await cache.getDraft(id);
165
141
  if (draftRow !== null) {
166
142
  return jsonResponse({
167
143
  id: draftRow.id,
@@ -181,9 +157,35 @@ export function registerMessageTools(server, client) {
181
157
  attachments: [],
182
158
  });
183
159
  }
184
- const cached = getMessage(id);
160
+ const cached = await cache.getMessage(id);
185
161
  if (cached && cached.body !== null) {
186
- let attachments = listAttachmentsForMessage(id);
162
+ let row = cached;
163
+ // Refresh view status for a sent message we still believe is unviewed:
164
+ // the recipient may have opened it since the last sync, and the detail
165
+ // endpoint carries the real "First Viewed" timestamp (a list-synced row
166
+ // only knows the showNeverViewed boolean / epoch placeholder). Best-
167
+ // effort and one-way — once a real viewed time is cached we stop re-
168
+ // fetching. Sent-only: re-hitting an unread INBOX detail would mark it
169
+ // read on OFW.
170
+ if (cached.folder === 'sent' && !hasRealView(cached.recipients)) {
171
+ try {
172
+ const detail = parseLenient(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (view-status refresh)' });
173
+ const recipients = mapRecipients(detail.recipients);
174
+ // Keep the raw listData read-flag in step with the refreshed
175
+ // recipients so `showNeverViewed` can't contradict `viewedAt`.
176
+ // (Spreading a null/absent listData is a no-op, so no guard needed.)
177
+ row = {
178
+ ...cached,
179
+ recipients,
180
+ listData: { ...cached.listData, showNeverViewed: !hasRealView(recipients) },
181
+ };
182
+ await cache.upsertMessage(row);
183
+ }
184
+ catch {
185
+ // Best-effort: fall back to the cached row on any fetch/parse error.
186
+ }
187
+ }
188
+ let attachments = await cache.listAttachmentsForMessage(id);
187
189
  // Lazy attachment backfill. The list-endpoint payload (stored in
188
190
  // listData) hints at attachments via `files: <count>` but doesn't
189
191
  // expose the fileIds — those live only on /pub/v3/messages/{id}.
@@ -191,22 +193,35 @@ export function registerMessageTools(server, client) {
191
193
  // attachments table is empty even though OFW has files. Re-hit
192
194
  // detail to harvest fileIds (idempotent: body is already cached so
193
195
  // OFW state isn't changing).
194
- if (attachments.length === 0 && listDataHintsAtFiles(cached.listData)) {
196
+ if (attachments.length === 0 && listDataHintsAtFiles(row.listData)) {
195
197
  try {
196
198
  const detail = parseLenient(DetailFilesSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (attachment backfill)' });
197
199
  if (Array.isArray(detail.files) && detail.files.length > 0) {
198
- await fetchAttachmentMetaForMessage(client, id, detail.files);
199
- attachments = listAttachmentsForMessage(id);
200
+ await fetchAttachmentMetaForMessage(client, id, detail.files, cache);
201
+ attachments = await cache.listAttachmentsForMessage(id);
200
202
  }
201
203
  }
202
204
  catch {
203
205
  // Backfill is best-effort. Fall through with whatever we have.
204
206
  }
205
207
  }
206
- return jsonResponse({ ...cached, attachments });
208
+ return jsonResponse({ ...row, attachments });
207
209
  }
208
210
  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)' });
209
- const folder = cached?.folder ?? 'inbox';
211
+ // Derive the folder for a live-fetched message. A cached row (reached here
212
+ // only when its body was NULL) already knows its folder, so keep it.
213
+ // Otherwise use the detail's own folder id, matched against the sent folder
214
+ // id persisted by the last resolveFolderIds — a sent message must not be
215
+ // mislabeled 'inbox' (which would also hide it from ofw_get_unread_sent and
216
+ // a sent-scoped ofw_list_messages). When that mapping isn't known yet (no
217
+ // sync has run in this cache), fall back to 'inbox' as before.
218
+ let folder = cached?.folder ?? 'inbox';
219
+ if (!cached) {
220
+ const sentFolderId = await cache.getMeta('sent_folder_id');
221
+ if (sentFolderId !== null && detail.folder?.id != null && String(detail.folder.id) === sentFolderId) {
222
+ folder = 'sent';
223
+ }
224
+ }
210
225
  const row = {
211
226
  id: detail.id,
212
227
  folder,
@@ -220,11 +235,11 @@ export function registerMessageTools(server, client) {
220
235
  chainRootId: cached?.chainRootId ?? null,
221
236
  listData: cached?.listData ?? detail,
222
237
  };
223
- upsertMessage(row);
238
+ await cache.upsertMessage(row);
224
239
  if (Array.isArray(detail.files) && detail.files.length > 0) {
225
- await fetchAttachmentMetaForMessage(client, detail.id, detail.files);
240
+ await fetchAttachmentMetaForMessage(client, detail.id, detail.files, cache);
226
241
  }
227
- const attachments = listAttachmentsForMessage(detail.id);
242
+ const attachments = await cache.listAttachmentsForMessage(detail.id);
228
243
  return jsonResponse({ ...row, attachments });
229
244
  });
230
245
  if (allowSend)
@@ -245,6 +260,7 @@ export function registerMessageTools(server, client) {
245
260
  throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
246
261
  }
247
262
  const draftRef = args.messageId ?? args.draftId;
263
+ const cache = cacheProvider();
248
264
  // Best-effort draft lookup: when draftRef points at a cached draft, use
249
265
  // its stored fields (including replyToId) as defaults for anything the
250
266
  // caller didn't supply. The "missing draft" case only matters when we
@@ -258,7 +274,7 @@ export function registerMessageTools(server, client) {
258
274
  let draftFound = false;
259
275
  if (draftRef !== undefined) {
260
276
  draftLookupAttempted = true;
261
- const draft = getDraft(draftRef);
277
+ const draft = await cache.getDraft(draftRef);
262
278
  if (draft !== null) {
263
279
  draftFound = true;
264
280
  subject = subject ?? draft.subject;
@@ -286,11 +302,11 @@ export function registerMessageTools(server, client) {
286
302
  let chainRootId = null;
287
303
  let rewriteNote = null;
288
304
  if (requestedReplyTo !== null) {
289
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
305
+ resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
290
306
  if (resolvedReplyTo !== requestedReplyTo) {
291
307
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
292
308
  }
293
- const parent = getMessage(resolvedReplyTo);
309
+ const parent = await cache.getMessage(resolvedReplyTo);
294
310
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
295
311
  }
296
312
  const myFileIDs = args.myFileIDs ?? [];
@@ -320,13 +336,13 @@ export function registerMessageTools(server, client) {
320
336
  chainRootId,
321
337
  listData: detail,
322
338
  };
323
- upsertMessage(persisted);
339
+ await cache.upsertMessage(persisted);
324
340
  // Link attached files to the new message in the attachments cache.
325
341
  // We may not have full metadata if the upload happened in a prior
326
342
  // session — fall back to what we know.
327
343
  for (const fileId of myFileIDs) {
328
- const existing = getAttachment(fileId);
329
- upsertAttachmentForMessage({
344
+ const existing = await cache.getAttachment(fileId);
345
+ await cache.upsertAttachmentForMessage({
330
346
  fileId,
331
347
  fileName: existing?.fileName ?? `file-${fileId}`,
332
348
  label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
@@ -349,7 +365,7 @@ export function registerMessageTools(server, client) {
349
365
  }
350
366
  else if (draftRef !== undefined) {
351
367
  await deleteOFWMessages(client, [draftRef]);
352
- deleteDraft(draftRef);
368
+ await cache.deleteDraft(draftRef);
353
369
  }
354
370
  const responseObj = persisted ?? raw;
355
371
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
@@ -366,7 +382,7 @@ export function registerMessageTools(server, client) {
366
382
  }, async (args) => {
367
383
  const page = args.page ?? 1;
368
384
  const size = args.size ?? 50;
369
- const drafts = listDrafts({ page, size });
385
+ const drafts = await cacheProvider().listDrafts({ page, size });
370
386
  const payload = drafts.length === 0
371
387
  ? { drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' }
372
388
  : { drafts };
@@ -385,11 +401,12 @@ export function registerMessageTools(server, client) {
385
401
  myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
386
402
  },
387
403
  }, async (args) => {
404
+ const cache = cacheProvider();
388
405
  const requestedReplyTo = args.replyToId ?? null;
389
406
  let resolvedReplyTo = requestedReplyTo;
390
407
  let rewriteNote = null;
391
408
  if (requestedReplyTo !== null) {
392
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
409
+ resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
393
410
  if (resolvedReplyTo !== requestedReplyTo) {
394
411
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
395
412
  }
@@ -425,13 +442,13 @@ export function registerMessageTools(server, client) {
425
442
  modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
426
443
  listData: detail,
427
444
  };
428
- upsertDraft(persisted);
445
+ await cache.upsertDraft(persisted);
429
446
  // Replace-path: caller passed messageId, so they want the old draft
430
447
  // gone. Delete it after the new one is safely created+cached.
431
448
  if (args.messageId !== undefined && args.messageId !== newId) {
432
449
  try {
433
450
  await deleteOFWMessages(client, [args.messageId]);
434
- deleteDraft(args.messageId);
451
+ await cache.deleteDraft(args.messageId);
435
452
  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.)`;
436
453
  }
437
454
  catch (e) {
@@ -453,7 +470,7 @@ export function registerMessageTools(server, client) {
453
470
  },
454
471
  }, async (args) => {
455
472
  const data = await deleteOFWMessages(client, [args.messageId]);
456
- deleteDraft(args.messageId);
473
+ await cacheProvider().deleteDraft(args.messageId);
457
474
  return data ? jsonResponse(data) : textResponse('Draft deleted.');
458
475
  });
459
476
  server.registerTool('ofw_get_unread_sent', {
@@ -466,7 +483,7 @@ export function registerMessageTools(server, client) {
466
483
  }, async (args) => {
467
484
  const page = args.page ?? 1;
468
485
  const size = args.size ?? 50;
469
- const sent = listMessages({ folder: 'sent', page, size });
486
+ const sent = await cacheProvider().listMessages({ folder: 'sent', page, size });
470
487
  if (sent.length === 0) {
471
488
  return jsonResponse({ note: 'Sent cache is empty. Call ofw_sync_messages to populate.' });
472
489
  }
@@ -493,16 +510,12 @@ export function registerMessageTools(server, client) {
493
510
  description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
494
511
  },
495
512
  }, async (args) => {
496
- const abs = expandPath(args.path);
497
- const stat = statSync(abs); // throws if missing
498
- if (!stat.isFile())
499
- throw new Error(`Not a file: ${abs}`);
500
- const fileName = basename(abs);
501
- const mime = mimeFromName(fileName);
513
+ // Resolve the upload source through the injected attachment-I/O boundary
514
+ // (disk read on node; an in-memory source on the hosted connector).
515
+ const { blob, fileName, mimeType: mime, sizeBytes } = await attachmentIO.resolveUpload(args.path);
502
516
  // Build the multipart payload matching the OFW web UI's request shape.
503
517
  const form = new FormData();
504
- // fileBlob streams the file off disk (a file-backed Blob) instead of buffering it.
505
- form.append('file', await fileBlob(abs, { type: mime }), fileName);
518
+ form.append('file', blob, fileName);
506
519
  form.append('source', 'message');
507
520
  form.append('description', args.description ?? fileName);
508
521
  form.append('label', args.label ?? fileName);
@@ -512,12 +525,12 @@ export function registerMessageTools(server, client) {
512
525
  // Cache metadata so subsequent ofw_get_message calls can surface it and
513
526
  // ofw_download_attachment can short-circuit. messageId is 0 (the
514
527
  // not-yet-linked sentinel) until a message actually references this file.
515
- upsertAttachmentForMessage({
528
+ await cacheProvider().upsertAttachmentForMessage({
516
529
  fileId: meta.fileId,
517
530
  fileName: meta.fileName ?? fileName,
518
531
  label: meta.label ?? args.label ?? fileName,
519
532
  mimeType: meta.fileType ?? mime,
520
- sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : stat.size,
533
+ sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : sizeBytes,
521
534
  metadata: meta,
522
535
  messageId: 0,
523
536
  });
@@ -525,7 +538,7 @@ export function registerMessageTools(server, client) {
525
538
  fileId: meta.fileId,
526
539
  fileName: meta.fileName ?? fileName,
527
540
  mimeType: meta.fileType ?? mime,
528
- sizeBytes: meta.sizeInBytes ?? stat.size,
541
+ sizeBytes: meta.sizeInBytes ?? sizeBytes,
529
542
  shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
530
543
  note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
531
544
  });
@@ -541,13 +554,14 @@ export function registerMessageTools(server, client) {
541
554
  },
542
555
  }, async (args) => {
543
556
  const fileId = args.fileId;
557
+ const cache = cacheProvider();
544
558
  const inline = args.inline ?? getDefaultInlineAttachments();
545
- let cached = getAttachment(fileId);
559
+ let cached = await cache.getAttachment(fileId);
546
560
  if (!cached) {
547
561
  // Not in cache. Fetch metadata and store under the messageId=0
548
562
  // sentinel — gets re-linked if a message later references this file.
549
- await fetchAttachmentMeta(client, fileId, 0);
550
- cached = getAttachment(fileId);
563
+ await fetchAttachmentMeta(client, fileId, 0, cache);
564
+ cached = await cache.getAttachment(fileId);
551
565
  /* v8 ignore next -- fetchAttachmentMeta persists the row it just fetched; a still-null read here is an unreachable storage failure */
552
566
  if (!cached)
553
567
  throw new Error(`failed to fetch metadata for fileId ${fileId}`);
@@ -558,10 +572,7 @@ export function registerMessageTools(server, client) {
558
572
  let mimeType = cached.mimeType;
559
573
  let fileName = cached.fileName;
560
574
  if (cached.downloadedPath) {
561
- try {
562
- bytes = readFileSync(cached.downloadedPath);
563
- }
564
- catch { /* on-disk copy missing; fall through */ }
575
+ bytes = attachmentIO.readDownloaded(cached.downloadedPath);
565
576
  }
566
577
  if (bytes === null) {
567
578
  const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
@@ -604,9 +615,8 @@ export function registerMessageTools(server, client) {
604
615
  });
605
616
  }
606
617
  const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
607
- mkdirSync(dirname(dest), { recursive: true });
608
- writeFileSync(dest, response.body);
609
- markAttachmentDownloaded(fileId, dest);
618
+ attachmentIO.writeDownload(dest, response.body);
619
+ await cache.markAttachmentDownloaded(fileId, dest);
610
620
  return jsonResponse({
611
621
  fileId,
612
622
  path: dest,
@@ -616,19 +626,21 @@ export function registerMessageTools(server, client) {
616
626
  });
617
627
  });
618
628
  server.registerTool('ofw_sync_messages', {
619
- 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. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps).',
629
+ 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).',
620
630
  annotations: { readOnlyHint: false },
621
631
  inputSchema: {
622
632
  folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).describe('Folders to sync (default: all three)').optional(),
623
633
  fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.').optional(),
624
634
  deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
635
+ 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(),
625
636
  },
626
637
  }, async (args) => {
627
638
  const result = await syncAll(client, {
628
639
  folders: args.folders,
629
640
  fetchUnreadBodies: args.fetchUnreadBodies,
630
641
  deep: args.deep,
631
- });
642
+ maxRequests: args.maxRequests ?? getSyncMaxRequests(),
643
+ }, cacheProvider());
632
644
  return jsonResponse(result);
633
645
  });
634
646
  }