ofw-mcp 2.5.0 → 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.
@@ -0,0 +1,26 @@
1
+ import { loginWithPassword } from './auth-password.js';
2
+ /**
3
+ * `ConnectorAuth` for the OurFamilyWizard remote connector: the login page
4
+ * collects the user's own OFW email/username + password, verifies them via the
5
+ * same Spring Security form login the stdio server uses (`loginWithPassword` in
6
+ * `auth-password.js`), and stores `{ username, password }` as the OAuth props
7
+ * that `worker.ts`'s `buildClient` turns into a per-user `OFWClient` capable of
8
+ * re-authenticating when its 6h token expires.
9
+ */
10
+ export const ofwAuth = {
11
+ service: 'OurFamilyWizard',
12
+ accent: '#00A9A5',
13
+ privacyNote: 'Your OFW email and password are stored encrypted and used only to sign in to OurFamilyWizard on your behalf ' +
14
+ '(OFW sign-in tokens expire every few hours, so your password is needed to renew them).',
15
+ fields: [
16
+ { name: 'username', label: 'OFW email or username' },
17
+ { name: 'password', label: 'OFW password', type: 'password' },
18
+ ],
19
+ async login(fields) {
20
+ // Verify the credentials up front — a bad password throws here, which the
21
+ // connector surfaces back on the login page. We deliberately discard the
22
+ // returned token: the per-user client logs in again from the stored creds.
23
+ await loginWithPassword(fields.username, fields.password);
24
+ return { username: fields.username, password: fields.password };
25
+ },
26
+ };
package/dist/sync.js CHANGED
@@ -1,4 +1,3 @@
1
- import { setMeta, upsertMessage, getMessage, deleteMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
2
1
  import { z } from 'zod';
3
2
  import { ApiRecipientSchema, hasRealView, mapRecipients } from './tools/_shared.js';
4
3
  import { parseLenient } from '@chrischall/mcp-utils';
@@ -21,9 +20,9 @@ const FileMetaSchema = z.looseObject({
21
20
  // Throws on network/HTTP errors — callers in bulk-sync paths wrap this in the
22
21
  // best-effort helper below; callers that need the result (download tool) let
23
22
  // the throw propagate.
24
- export async function fetchAttachmentMeta(client, fileId, messageId) {
23
+ export async function fetchAttachmentMeta(client, fileId, messageId, store) {
25
24
  const meta = parseLenient(FileMetaSchema, await client.request('GET', `/pub/v1/myfiles/${fileId}`), { label: 'ofw-mcp', context: 'GET /pub/v1/myfiles/{fileId}' });
26
- upsertAttachmentForMessage({
25
+ await store.upsertAttachmentForMessage({
27
26
  fileId: meta.fileId ?? fileId,
28
27
  fileName: meta.fileName ?? `file-${fileId}`,
29
28
  label: meta.label ?? meta.fileName ?? `file-${fileId}`,
@@ -33,17 +32,50 @@ export async function fetchAttachmentMeta(client, fileId, messageId) {
33
32
  messageId,
34
33
  });
35
34
  }
36
- export async function fetchAttachmentMetaForMessage(client, messageId, fileIds) {
35
+ export async function fetchAttachmentMetaForMessage(client, messageId, fileIds, store) {
37
36
  // Fan out in parallel — each fetch is independent and the file id stays
38
37
  // in listData on failure (model can retry via ofw_download_attachment,
39
38
  // which surfaces the real error). Promise.allSettled so one bad
40
39
  // attachment doesn't break the surrounding sync.
41
- await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client, fid, messageId)));
40
+ await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client, fid, messageId, store)));
41
+ }
42
+ /**
43
+ * Build a {@link Budget} that allows `max` requests. `Number.POSITIVE_INFINITY`
44
+ * (the local-stdio default) never exhausts — `take()` always returns true — so
45
+ * bounded logic collapses to the original unbounded walk.
46
+ */
47
+ export function makeBudget(max) {
48
+ let remaining = max;
49
+ return {
50
+ take() {
51
+ if (remaining <= 0)
52
+ return false;
53
+ remaining -= 1;
54
+ return true;
55
+ },
56
+ };
57
+ }
58
+ // Budget-gated attachment-meta backfill. Spends one unit per file id it can
59
+ // afford (in order), skipping the rest, then fetches the affordable ones with
60
+ // the existing best-effort parallel helper. Attachment fetches are best-effort:
61
+ // a skipped file id stays in the message's listData and can be backfilled later
62
+ // by ofw_get_message. Under an infinite budget this fetches every file id — the
63
+ // unbounded behaviour.
64
+ async function fetchAttachmentMetaBudgeted(client, messageId, fileIds, store, budget) {
65
+ const affordable = [];
66
+ for (const fid of fileIds) {
67
+ if (!budget.take())
68
+ break;
69
+ affordable.push(fid);
70
+ }
71
+ if (affordable.length > 0) {
72
+ await fetchAttachmentMetaForMessage(client, messageId, affordable, store);
73
+ }
42
74
  }
43
75
  const FoldersSchema = z.looseObject({
44
76
  systemFolders: z.array(z.looseObject({ id: z.string(), folderType: z.string() })).optional(),
45
77
  });
46
- export async function resolveFolderIds(client) {
78
+ export async function resolveFolderIds(client, store) {
47
79
  const data = parseLenient(FoldersSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), { label: 'ofw-mcp', context: 'GET /pub/v1/messageFolders' });
48
80
  const sys = data.systemFolders ?? [];
49
81
  const find = (type) => {
@@ -57,7 +89,11 @@ export async function resolveFolderIds(client) {
57
89
  sent: find('SENT_MESSAGES'),
58
90
  drafts: find('DRAFTS'),
59
91
  };
60
- setMeta('drafts_folder_id', ids.drafts);
92
+ await store.setMeta('drafts_folder_id', ids.drafts);
93
+ // Persist the sent folder id too: ofw_get_message's live-fetch path uses it to
94
+ // label an uncached message sent-vs-inbox from the detail payload's own folder
95
+ // id, instead of hard-defaulting to inbox.
96
+ await store.setMeta('sent_folder_id', ids.sent);
61
97
  return ids;
62
98
  }
63
99
  // Required fields are the ones the sync loop reads unguarded (id keys the
@@ -79,22 +115,39 @@ const DetailResponseSchema = z.looseObject({
79
115
  // endpoint only has an epoch placeholder) — used by the view-status refresh.
80
116
  recipients: z.array(ApiRecipientSchema).optional(),
81
117
  });
82
- export async function syncMessageFolder(client, folder, folderId, opts) {
83
- let page = 1;
84
- let synced = 0;
118
+ const maxId = (a, b) => a === null ? b : b === null ? a : Math.max(a, b);
119
+ /**
120
+ * Walk one folder's list pages from `startPage` toward older messages, caching
121
+ * what isn't cached yet. Stops on an empty page, when `stopAtCachedPage` says
122
+ * we've reached cached history, or when the request budget runs out.
123
+ */
124
+ async function walkPages(client, folder, folderId, opts, store) {
125
+ const budget = opts.budget;
126
+ let page = opts.startPage;
85
127
  let newestId = null;
128
+ let synced = 0;
86
129
  const unread = [];
87
130
  while (true) {
131
+ // One unit per list-page fetch. Out of budget → pause and resume at `page`.
132
+ if (!budget.take()) {
133
+ return { synced, unread, newestId, done: false, nextPage: page };
134
+ }
88
135
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
89
136
  const list = parseLenient(ListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: `GET /pub/v3/messages?folders={${folder}}` });
90
137
  const items = list.data ?? [];
91
- if (items.length === 0)
92
- break;
138
+ if (items.length === 0) {
139
+ return { synced, unread, newestId, done: true, nextPage: null };
140
+ }
141
+ // One batch read of this page's ids (S1) instead of a per-item getMessage.
142
+ const existingById = new Map((await store.getMessages(items.map((it) => it.id))).map((row) => [row.id, row]));
143
+ // Rows created/updated this page, flushed in ONE batch upsert (S1).
144
+ const toUpsert = [];
93
145
  let pageHadNewItem = false;
146
+ let pageBudgetHit = false;
94
147
  for (const item of items) {
95
148
  if (newestId === null || item.id > newestId)
96
149
  newestId = item.id;
97
- const existing = getMessage(item.id);
150
+ const existing = existingById.get(item.id);
98
151
  if (existing) {
99
152
  // A sent message's read status changes AFTER it's first cached, when
100
153
  // the recipient opens it — so we can't just skip existing rows. The
@@ -104,8 +157,12 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
104
157
  // and we don't yet hold a real viewed time, re-fetch detail to capture
105
158
  // it (no body re-fetch — only the recipient view fields can change).
106
159
  if (folder === 'sent' && item.showNeverViewed === false && !hasRealView(existing.recipients)) {
160
+ if (!budget.take()) {
161
+ pageBudgetHit = true;
162
+ break;
163
+ }
107
164
  const detail = parseLenient(DetailResponseSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (view-status refresh)' });
108
- upsertMessage({ ...existing, recipients: mapRecipients(detail.recipients), listData: item });
165
+ toUpsert.push({ ...existing, recipients: mapRecipients(detail.recipients), listData: item });
109
166
  synced++;
110
167
  }
111
168
  continue;
@@ -116,10 +173,22 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
116
173
  let body = null;
117
174
  let fetchedBodyAt = null;
118
175
  let detailFileIds = [];
176
+ // Prefer the DETAIL endpoint's recipients when we fetch it: the list only
177
+ // ever carries an epoch placeholder for `viewed.dateTime` (even on a read
178
+ // message), while detail carries the real "First Viewed" time. Building
179
+ // the row from the list would cache viewedAt:null for a message that was
180
+ // already read by the time we first saw it — reporting "never viewed" for
181
+ // a message OFW shows as read, until a later sync's refresh healed it.
182
+ let detailRecipients;
119
183
  if (shouldFetchBody) {
184
+ if (!budget.take()) {
185
+ pageBudgetHit = true;
186
+ break;
187
+ }
120
188
  const detail = parseLenient(DetailResponseSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (sync)' });
121
189
  body = detail.body ?? '';
122
190
  fetchedBodyAt = new Date().toISOString();
191
+ detailRecipients = detail.recipients;
123
192
  if (Array.isArray(detail.files) && detail.files.length > 0) {
124
193
  detailFileIds = detail.files;
125
194
  }
@@ -138,33 +207,109 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
138
207
  subject: item.subject ?? '(no subject)',
139
208
  fromUser: item.from?.name ?? '',
140
209
  sentAt: item.date?.dateTime ?? new Date().toISOString(),
141
- recipients: mapRecipients(item.recipients),
210
+ recipients: mapRecipients(detailRecipients ?? item.recipients),
142
211
  body,
143
212
  fetchedBodyAt,
144
213
  replyToId: null,
145
214
  chainRootId: null,
146
215
  listData: item,
147
216
  };
148
- upsertMessage(row);
217
+ toUpsert.push(row);
149
218
  synced++;
150
219
  if (detailFileIds.length > 0) {
151
- await fetchAttachmentMetaForMessage(client, item.id, detailFileIds);
220
+ await fetchAttachmentMetaBudgeted(client, item.id, detailFileIds, store, budget);
152
221
  }
153
222
  }
154
- // Stop heuristic: a page with no new items means we've reached cached
155
- // history (OFW returns date-desc). A page with even ONE new item could
156
- // mean there are more new items on the next page that we haven't seen
157
- // yet — keep walking. With `deep: true`, walk every page until OFW
158
- // returns an empty page (used to backfill suspected gaps).
159
- if (!opts.deep && !pageHadNewItem)
160
- break;
223
+ // Flush the page's rows in one transaction/RPC. Empty array is a no-op.
224
+ await store.upsertMessages(toUpsert);
225
+ if (pageBudgetHit) {
226
+ // Paused mid-page. Resume at THIS page: the partial rows are cached, so
227
+ // getMessages skips them next time and upserts are idempotent.
228
+ return { synced, unread, newestId, done: false, nextPage: page };
229
+ }
230
+ // Reached cached history (see `stopAtCachedPage`). Report THIS page as the
231
+ // resume point rather than the next one: it costs one redundant (cheap,
232
+ // all-cached) fetch if a backfill later starts here, and it cannot skip a
233
+ // message the way an off-by-one `page + 1` could.
234
+ if (opts.stopAtCachedPage && !pageHadNewItem) {
235
+ return { synced, unread, newestId, done: true, nextPage: page };
236
+ }
161
237
  page++;
162
238
  }
163
- setSyncState(folder, {
239
+ }
240
+ /**
241
+ * Sync one message folder. Runs two independent passes so that a long backfill
242
+ * can never starve new messages:
243
+ *
244
+ * 1. FORWARD — always from page 1, every call, regardless of how deep a
245
+ * backfill is parked. This is what guarantees a message sent or received
246
+ * since the last sync is cached by the next ordinary call. Once caught up
247
+ * it costs a single request: page 1 holds nothing new, so it stops there.
248
+ * 2. BACKFILL — resumes the parked cursor (or, for `deep`, walks past where
249
+ * the forward pass stopped) with whatever budget the forward pass left,
250
+ * and re-parks the cursor if it pauses again.
251
+ *
252
+ * Both passes share one budget, and the forward pass draws first: the newest
253
+ * messages are the ones a caller is most likely to need, and history that has
254
+ * waited months can wait one more call.
255
+ */
256
+ export async function syncMessageFolder(client, folder, folderId, opts, store) {
257
+ // No budget → unbounded (local stdio): every take() succeeds, so an ordinary
258
+ // sync is byte-for-byte the original unbounded walk (forward pass only).
259
+ const budget = opts.budget ?? makeBudget(Number.POSITIVE_INFINITY);
260
+ const saved = await store.getSyncState(folder);
261
+ const savedResume = saved?.resumePage ?? null;
262
+ const fwd = await walkPages(client, folder, folderId, {
263
+ startPage: 1,
264
+ stopAtCachedPage: true,
265
+ fetchUnreadBodies: opts.fetchUnreadBodies,
266
+ budget,
267
+ }, store);
268
+ let synced = fwd.synced;
269
+ const unread = [...fwd.unread];
270
+ // Never regress the folder's newest id: a pass that only walked cached pages
271
+ // still saw page 1, but a paused one may not have.
272
+ let newestId = maxId(saved?.newestId ?? null, fwd.newestId);
273
+ let done;
274
+ let resumePage;
275
+ if (!fwd.done) {
276
+ // The forward pass itself ran out of budget, so it never reached cached
277
+ // history — everything from `fwd.nextPage` down is unverified. Park the
278
+ // backfill at whichever cursor is higher up the folder, so no page that
279
+ // still owes us messages ends up above the resume point.
280
+ done = false;
281
+ resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
282
+ }
283
+ else if (fwd.nextPage === null) {
284
+ // The forward pass walked clean off the end of the folder — by definition
285
+ // there is no older history left to backfill.
286
+ done = true;
287
+ resumePage = null;
288
+ }
289
+ else if (savedResume === null && !opts.deep) {
290
+ // Ordinary incremental sync with no backfill parked: caught up.
291
+ done = true;
292
+ resumePage = null;
293
+ }
294
+ else {
295
+ const bf = await walkPages(client, folder, folderId, {
296
+ startPage: savedResume ?? fwd.nextPage,
297
+ stopAtCachedPage: false,
298
+ fetchUnreadBodies: opts.fetchUnreadBodies,
299
+ budget,
300
+ }, store);
301
+ synced += bf.synced;
302
+ unread.push(...bf.unread);
303
+ newestId = maxId(newestId, bf.newestId);
304
+ done = bf.done;
305
+ resumePage = bf.done ? null : bf.nextPage;
306
+ }
307
+ await store.setSyncState(folder, {
164
308
  lastSyncAt: new Date().toISOString(),
165
309
  newestId,
310
+ resumePage,
166
311
  });
167
- return { synced, unread };
312
+ return { synced, unread, done };
168
313
  }
169
314
  const DraftListItemSchema = z.looseObject({
170
315
  id: z.number(),
@@ -178,13 +323,21 @@ const DraftDetailSchema = z.looseObject({
178
323
  body: z.string().optional(),
179
324
  subject: z.string().optional(),
180
325
  });
181
- export async function syncDrafts(client, draftsFolderId) {
182
- // Walk every page. The reconciliation loop at the bottom deletes any
183
- // cached draft that wasn't seen in the listing, so a partial walk would
184
- // wrongly evict real drafts beyond the first page.
326
+ export async function syncDrafts(client, draftsFolderId, store, budget) {
327
+ // No budget → unbounded (local stdio): identical to the original walk.
328
+ const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
329
+ // The reconciliation step below DELETES any cached draft not seen in the
330
+ // listing, so a partial walk must apply NOTHING. We therefore buffer the
331
+ // entire walk (all list pages + every detail) BEFORE touching the cache: if
332
+ // the budget can't fund the whole walk we discard the buffer and defer the
333
+ // drafts folder to a later call (done:false). The OFW requests already spent
334
+ // still count against the budget; drafts are few, so a discarded partial is
335
+ // cheap and — crucially — never evicts a real draft.
185
336
  const items = [];
186
337
  let page = 1;
187
338
  while (true) {
339
+ if (!b.take())
340
+ return { synced: 0, done: false };
188
341
  const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
189
342
  const list = parseLenient(DraftListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: 'GET /pub/v3/messages?folders={drafts}' });
190
343
  const pageItems = list.data ?? [];
@@ -193,32 +346,38 @@ export async function syncDrafts(client, draftsFolderId) {
193
346
  break;
194
347
  page++;
195
348
  }
196
- const seenIds = new Set();
197
- let synced = 0;
349
+ // Fetch every draft's detail up front, still buffered. OFW's list
350
+ // `date.dateTime` is NOT a reliable modification timestamp for drafts —
351
+ // direct UI edits don't bump it — so we can't skip the detail fetch.
352
+ const rows = [];
198
353
  for (const item of items) {
199
- seenIds.add(item.id);
200
- const modifiedAt = item.date?.dateTime ?? new Date().toISOString();
201
- // OFW's list endpoint's `date.dateTime` is NOT a reliable modification
202
- // timestamp for drafts — direct UI edits don't bump it — so we can't
203
- // use it to skip the detail fetch. Always re-fetch; drafts are few.
204
- const existing = getDraft(item.id);
354
+ if (!b.take())
355
+ return { synced: 0, done: false };
205
356
  const detail = parseLenient(DraftDetailSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (drafts sync)' });
206
- const row = {
357
+ rows.push({
207
358
  id: item.id,
208
359
  subject: detail.subject ?? item.subject ?? '(no subject)',
209
360
  body: detail.body ?? '',
210
361
  recipients: mapRecipients(item.recipients),
211
362
  replyToId: item.replyToId ?? null,
212
- modifiedAt,
363
+ modifiedAt: item.date?.dateTime ?? new Date().toISOString(),
213
364
  listData: item,
214
- };
215
- upsertDraft(row);
216
- // If a stale `messages` row exists for this id (cached by a prior
217
- // ofw_get_message call before the drafts table knew about this id),
218
- // evict it. The drafts table is the source of truth for drafts; we
219
- // don't want ofw_get_message returning a stale messages-table copy.
220
- if (getMessage(item.id))
221
- deleteMessage(item.id);
365
+ });
366
+ }
367
+ // Budget funded the whole walk — apply atomically. Batch reads (S1) snapshot
368
+ // pre-upsert state for the synced-count comparison and stale-row eviction.
369
+ const ids = items.map((it) => it.id);
370
+ const existingById = new Map((await store.getDrafts(ids)).map((d) => [d.id, d]));
371
+ await store.upsertDrafts(rows);
372
+ // If a stale `messages` row exists for a draft id (cached by a prior
373
+ // ofw_get_message call before the drafts table knew about this id), evict it.
374
+ // The drafts table is the source of truth for drafts.
375
+ for (const stale of await store.getMessages(ids)) {
376
+ await store.deleteMessage(stale.id);
377
+ }
378
+ let synced = 0;
379
+ for (const row of rows) {
380
+ const existing = existingById.get(row.id);
222
381
  if (!existing
223
382
  || existing.body !== row.body
224
383
  || existing.subject !== row.subject
@@ -226,40 +385,62 @@ export async function syncDrafts(client, draftsFolderId) {
226
385
  synced++;
227
386
  }
228
387
  }
229
- for (const id of listDraftIds()) {
388
+ const seenIds = new Set(ids);
389
+ for (const id of await store.listDraftIds()) {
230
390
  if (!seenIds.has(id))
231
- deleteDraft(id);
391
+ await store.deleteDraft(id);
232
392
  }
233
- return { synced };
393
+ return { synced, done: true };
234
394
  }
235
- export async function syncAll(client, opts) {
395
+ export async function syncAll(client, opts, store) {
236
396
  const folders = opts.folders ?? ['inbox', 'sent', 'drafts'];
237
- const ids = await resolveFolderIds(client);
397
+ // ONE budget shared across resolveFolderIds and every requested folder, in
398
+ // order — so the whole invocation stays under the hosting subrequest cap.
399
+ const budget = makeBudget(opts.maxRequests ?? Number.POSITIVE_INFINITY);
400
+ // resolveFolderIds always makes exactly one request; the tool guarantees
401
+ // maxRequests >= 1, so this unit is always available (result intentionally
402
+ // ignored — we account for it without a branch that can't be reached).
403
+ budget.take();
404
+ const ids = await resolveFolderIds(client, store);
238
405
  const synced = {};
239
406
  let unreadInbox = [];
407
+ let done = true;
240
408
  for (const folder of folders) {
241
409
  if (folder === 'inbox') {
242
410
  const r = await syncMessageFolder(client, 'inbox', ids.inbox, {
243
411
  fetchUnreadBodies: opts.fetchUnreadBodies ?? false,
244
412
  deep: opts.deep ?? false,
245
- });
413
+ budget,
414
+ }, store);
246
415
  synced.inbox = r.synced;
247
416
  unreadInbox = r.unread;
417
+ if (!r.done)
418
+ done = false;
248
419
  }
249
420
  else if (folder === 'sent') {
250
421
  const r = await syncMessageFolder(client, 'sent', ids.sent, {
251
422
  fetchUnreadBodies: false,
252
423
  deep: opts.deep ?? false,
253
- });
424
+ budget,
425
+ }, store);
254
426
  synced.sent = r.synced;
427
+ if (!r.done)
428
+ done = false;
255
429
  }
256
430
  else if (folder === 'drafts') {
257
- const r = await syncDrafts(client, ids.drafts);
431
+ const r = await syncDrafts(client, ids.drafts, store, budget);
258
432
  synced.drafts = r.synced;
433
+ if (!r.done)
434
+ done = false;
259
435
  }
260
436
  }
261
- const note = unreadInbox.length > 0
262
- ? `${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them — this will mark them as read on OFW.`
263
- : undefined;
264
- return { synced, unreadInbox, ...(note ? { note } : {}) };
437
+ const notes = [];
438
+ if (unreadInbox.length > 0) {
439
+ notes.push(`${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them — this will mark them as read on OFW.`);
440
+ }
441
+ if (!done) {
442
+ notes.push('Paused after the request budget to stay within the hosting limit; more pages remain — call ofw_sync_messages again with the same arguments to resume where it left off and continue the backfill.');
443
+ }
444
+ const note = notes.length > 0 ? notes.join('\n\n') : undefined;
445
+ return { synced, unreadInbox, done, ...(note ? { note } : {}) };
265
446
  }
@@ -0,0 +1,66 @@
1
+ // The attachment-I/O boundary for the message tools.
2
+ //
3
+ // `ofw_upload_attachment` reads a local file off disk; `ofw_download_attachment`
4
+ // writes downloaded bytes to disk (and reads them back for the inline-reuse
5
+ // path). Those are the ONLY node:fs touch points in the message tools — they
6
+ // live behind this {@link AttachmentIO} interface so the stdio server can use
7
+ // the disk-backed {@link NodeAttachmentIO} while the hosted Cloudflare
8
+ // connector (a later task) injects an inline, filesystem-free implementation.
9
+ // Keeping the interface here means src/tools/messages.ts imports nothing from
10
+ // node:fs.
11
+ import { readFileSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
12
+ import { basename, dirname, extname } from 'node:path';
13
+ import { fileBlob, expandPath } from '@chrischall/mcp-utils';
14
+ // Lightweight mime sniff from extension. OFW re-derives mime from the filename
15
+ // server-side anyway, so this is just a polite Content-Type for the Blob.
16
+ const MIME_BY_EXT = {
17
+ '.pdf': 'application/pdf',
18
+ '.png': 'image/png',
19
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
20
+ '.gif': 'image/gif',
21
+ '.webp': 'image/webp',
22
+ '.heic': 'image/heic',
23
+ '.txt': 'text/plain',
24
+ '.md': 'text/markdown',
25
+ '.csv': 'text/csv',
26
+ '.html': 'text/html', '.htm': 'text/html',
27
+ '.json': 'application/json',
28
+ '.xml': 'application/xml',
29
+ '.doc': 'application/msword',
30
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
31
+ '.xls': 'application/vnd.ms-excel',
32
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
33
+ '.ppt': 'application/vnd.ms-powerpoint',
34
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
35
+ '.zip': 'application/zip',
36
+ '.ics': 'text/calendar',
37
+ };
38
+ export function mimeFromName(name) {
39
+ return MIME_BY_EXT[extname(name).toLowerCase()] ?? 'application/octet-stream';
40
+ }
41
+ /** Disk-backed attachment I/O for the stdio/desktop server. */
42
+ export class NodeAttachmentIO {
43
+ async resolveUpload(path) {
44
+ const abs = expandPath(path);
45
+ const stat = statSync(abs); // throws if missing
46
+ if (!stat.isFile())
47
+ throw new Error(`Not a file: ${abs}`);
48
+ const fileName = basename(abs);
49
+ const mimeType = mimeFromName(fileName);
50
+ // fileBlob streams the file off disk (a file-backed Blob) instead of buffering it.
51
+ const blob = await fileBlob(abs, { type: mimeType });
52
+ return { blob, fileName, mimeType, sizeBytes: stat.size };
53
+ }
54
+ readDownloaded(path) {
55
+ try {
56
+ return readFileSync(path);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ writeDownload(dest, bytes) {
63
+ mkdirSync(dirname(dest), { recursive: true });
64
+ writeFileSync(dest, bytes);
65
+ }
66
+ }