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.
package/dist/index.js CHANGED
@@ -16,6 +16,17 @@ import { registerMessageTools } from './tools/messages.js';
16
16
  import { registerCalendarTools } from './tools/calendar.js';
17
17
  import { registerExpenseTools } from './tools/expenses.js';
18
18
  import { registerJournalTools } from './tools/journal.js';
19
+ import { OFWCache } from './cache/node.js';
20
+ import { getCacheDbPath } from './config.js';
21
+ import { NodeAttachmentIO } from './tools/attachments.js';
22
+ // The stdio server backs the message cache with a local `node:sqlite` file,
23
+ // opened lazily on first use (so the server still boots and answers the host's
24
+ // install-time tools/list probe when no cache path is configured). The hosted
25
+ // Cloudflare connector (a later task) injects a Durable-Object-backed
26
+ // CacheStore + a filesystem-free AttachmentIO into the same registrar instead.
27
+ let nodeCache;
28
+ const nodeCacheProvider = () => (nodeCache ??= OFWCache.open(getCacheDbPath()));
29
+ const nodeAttachmentIO = new NodeAttachmentIO();
19
30
  // runMcp builds the McpServer, applies the registrars (with `client` threaded
20
31
  // through as deps), prints the banner to stderr, wires SIGINT/SIGTERM graceful
21
32
  // shutdown, and connects the stdio transport. The deferred-config-error pattern
@@ -24,11 +35,11 @@ import { registerJournalTools } from './tools/journal.js';
24
35
  // always succeeds before any credential check runs.
25
36
  await runMcp({
26
37
  name: 'ofw',
27
- version: '2.4.4', // x-release-please-version
38
+ version: '2.6.3', // x-release-please-version
28
39
  deps: client,
29
40
  tools: [
30
41
  registerUserTools,
31
- registerMessageTools,
42
+ (server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO),
32
43
  registerCalendarTools,
33
44
  registerExpenseTools,
34
45
  registerJournalTools,
@@ -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,6 +1,5 @@
1
- import { setMeta, upsertMessage, getMessage, deleteMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
2
1
  import { z } from 'zod';
3
- import { ApiRecipientSchema, mapRecipients } from './tools/_shared.js';
2
+ import { ApiRecipientSchema, hasRealView, mapRecipients } from './tools/_shared.js';
4
3
  import { parseLenient } from '@chrischall/mcp-utils';
5
4
  // Each OFW message detail returns `files: [fileId, ...]`. We fetch the metadata
6
5
  // for each file id (cheap JSON call) so the model can see filenames/mime types
@@ -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
@@ -75,35 +111,84 @@ const ListResponseSchema = z.looseObject({ data: z.array(ListItemSchema).optiona
75
111
  const DetailResponseSchema = z.looseObject({
76
112
  body: z.string().optional(),
77
113
  files: z.array(z.number()).optional(),
114
+ // The detail endpoint carries the REAL recipient view timestamps (the list
115
+ // endpoint only has an epoch placeholder) — used by the view-status refresh.
116
+ recipients: z.array(ApiRecipientSchema).optional(),
78
117
  });
79
- export async function syncMessageFolder(client, folder, folderId, opts) {
80
- let page = 1;
81
- 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;
82
127
  let newestId = null;
128
+ let synced = 0;
83
129
  const unread = [];
84
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
+ }
85
135
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
86
136
  const list = parseLenient(ListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: `GET /pub/v3/messages?folders={${folder}}` });
87
137
  const items = list.data ?? [];
88
- if (items.length === 0)
89
- 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 = [];
90
145
  let pageHadNewItem = false;
146
+ let pageBudgetHit = false;
91
147
  for (const item of items) {
92
148
  if (newestId === null || item.id > newestId)
93
149
  newestId = item.id;
94
- const existing = getMessage(item.id);
95
- if (existing)
150
+ const existing = existingById.get(item.id);
151
+ if (existing) {
152
+ // A sent message's read status changes AFTER it's first cached, when
153
+ // the recipient opens it — so we can't just skip existing rows. The
154
+ // list item carries the reliable `showNeverViewed` boolean but only an
155
+ // epoch placeholder for the timestamp; the real "First Viewed" time is
156
+ // on the detail endpoint. So when a sent message has flipped to read
157
+ // and we don't yet hold a real viewed time, re-fetch detail to capture
158
+ // it (no body re-fetch — only the recipient view fields can change).
159
+ if (folder === 'sent' && item.showNeverViewed === false && !hasRealView(existing.recipients)) {
160
+ if (!budget.take()) {
161
+ pageBudgetHit = true;
162
+ break;
163
+ }
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)' });
165
+ toUpsert.push({ ...existing, recipients: mapRecipients(detail.recipients), listData: item });
166
+ synced++;
167
+ }
96
168
  continue;
169
+ }
97
170
  pageHadNewItem = true;
98
171
  const isInboxUnread = folder === 'inbox' && item.showNeverViewed === true;
99
172
  const shouldFetchBody = !isInboxUnread || opts.fetchUnreadBodies;
100
173
  let body = null;
101
174
  let fetchedBodyAt = null;
102
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;
103
183
  if (shouldFetchBody) {
184
+ if (!budget.take()) {
185
+ pageBudgetHit = true;
186
+ break;
187
+ }
104
188
  const detail = parseLenient(DetailResponseSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (sync)' });
105
189
  body = detail.body ?? '';
106
190
  fetchedBodyAt = new Date().toISOString();
191
+ detailRecipients = detail.recipients;
107
192
  if (Array.isArray(detail.files) && detail.files.length > 0) {
108
193
  detailFileIds = detail.files;
109
194
  }
@@ -122,33 +207,109 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
122
207
  subject: item.subject ?? '(no subject)',
123
208
  fromUser: item.from?.name ?? '',
124
209
  sentAt: item.date?.dateTime ?? new Date().toISOString(),
125
- recipients: mapRecipients(item.recipients),
210
+ recipients: mapRecipients(detailRecipients ?? item.recipients),
126
211
  body,
127
212
  fetchedBodyAt,
128
213
  replyToId: null,
129
214
  chainRootId: null,
130
215
  listData: item,
131
216
  };
132
- upsertMessage(row);
217
+ toUpsert.push(row);
133
218
  synced++;
134
219
  if (detailFileIds.length > 0) {
135
- await fetchAttachmentMetaForMessage(client, item.id, detailFileIds);
220
+ await fetchAttachmentMetaBudgeted(client, item.id, detailFileIds, store, budget);
136
221
  }
137
222
  }
138
- // Stop heuristic: a page with no new items means we've reached cached
139
- // history (OFW returns date-desc). A page with even ONE new item could
140
- // mean there are more new items on the next page that we haven't seen
141
- // yet keep walking. With `deep: true`, walk every page until OFW
142
- // returns an empty page (used to backfill suspected gaps).
143
- if (!opts.deep && !pageHadNewItem)
144
- 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
+ }
145
237
  page++;
146
238
  }
147
- 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, {
148
308
  lastSyncAt: new Date().toISOString(),
149
309
  newestId,
310
+ resumePage,
150
311
  });
151
- return { synced, unread };
312
+ return { synced, unread, done };
152
313
  }
153
314
  const DraftListItemSchema = z.looseObject({
154
315
  id: z.number(),
@@ -162,13 +323,21 @@ const DraftDetailSchema = z.looseObject({
162
323
  body: z.string().optional(),
163
324
  subject: z.string().optional(),
164
325
  });
165
- export async function syncDrafts(client, draftsFolderId) {
166
- // Walk every page. The reconciliation loop at the bottom deletes any
167
- // cached draft that wasn't seen in the listing, so a partial walk would
168
- // 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.
169
336
  const items = [];
170
337
  let page = 1;
171
338
  while (true) {
339
+ if (!b.take())
340
+ return { synced: 0, done: false };
172
341
  const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
173
342
  const list = parseLenient(DraftListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: 'GET /pub/v3/messages?folders={drafts}' });
174
343
  const pageItems = list.data ?? [];
@@ -177,32 +346,38 @@ export async function syncDrafts(client, draftsFolderId) {
177
346
  break;
178
347
  page++;
179
348
  }
180
- const seenIds = new Set();
181
- 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 = [];
182
353
  for (const item of items) {
183
- seenIds.add(item.id);
184
- const modifiedAt = item.date?.dateTime ?? new Date().toISOString();
185
- // OFW's list endpoint's `date.dateTime` is NOT a reliable modification
186
- // timestamp for drafts — direct UI edits don't bump it — so we can't
187
- // use it to skip the detail fetch. Always re-fetch; drafts are few.
188
- const existing = getDraft(item.id);
354
+ if (!b.take())
355
+ return { synced: 0, done: false };
189
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)' });
190
- const row = {
357
+ rows.push({
191
358
  id: item.id,
192
359
  subject: detail.subject ?? item.subject ?? '(no subject)',
193
360
  body: detail.body ?? '',
194
361
  recipients: mapRecipients(item.recipients),
195
362
  replyToId: item.replyToId ?? null,
196
- modifiedAt,
363
+ modifiedAt: item.date?.dateTime ?? new Date().toISOString(),
197
364
  listData: item,
198
- };
199
- upsertDraft(row);
200
- // If a stale `messages` row exists for this id (cached by a prior
201
- // ofw_get_message call before the drafts table knew about this id),
202
- // evict it. The drafts table is the source of truth for drafts; we
203
- // don't want ofw_get_message returning a stale messages-table copy.
204
- if (getMessage(item.id))
205
- 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);
206
381
  if (!existing
207
382
  || existing.body !== row.body
208
383
  || existing.subject !== row.subject
@@ -210,40 +385,62 @@ export async function syncDrafts(client, draftsFolderId) {
210
385
  synced++;
211
386
  }
212
387
  }
213
- for (const id of listDraftIds()) {
388
+ const seenIds = new Set(ids);
389
+ for (const id of await store.listDraftIds()) {
214
390
  if (!seenIds.has(id))
215
- deleteDraft(id);
391
+ await store.deleteDraft(id);
216
392
  }
217
- return { synced };
393
+ return { synced, done: true };
218
394
  }
219
- export async function syncAll(client, opts) {
395
+ export async function syncAll(client, opts, store) {
220
396
  const folders = opts.folders ?? ['inbox', 'sent', 'drafts'];
221
- 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);
222
405
  const synced = {};
223
406
  let unreadInbox = [];
407
+ let done = true;
224
408
  for (const folder of folders) {
225
409
  if (folder === 'inbox') {
226
410
  const r = await syncMessageFolder(client, 'inbox', ids.inbox, {
227
411
  fetchUnreadBodies: opts.fetchUnreadBodies ?? false,
228
412
  deep: opts.deep ?? false,
229
- });
413
+ budget,
414
+ }, store);
230
415
  synced.inbox = r.synced;
231
416
  unreadInbox = r.unread;
417
+ if (!r.done)
418
+ done = false;
232
419
  }
233
420
  else if (folder === 'sent') {
234
421
  const r = await syncMessageFolder(client, 'sent', ids.sent, {
235
422
  fetchUnreadBodies: false,
236
423
  deep: opts.deep ?? false,
237
- });
424
+ budget,
425
+ }, store);
238
426
  synced.sent = r.synced;
427
+ if (!r.done)
428
+ done = false;
239
429
  }
240
430
  else if (folder === 'drafts') {
241
- const r = await syncDrafts(client, ids.drafts);
431
+ const r = await syncDrafts(client, ids.drafts, store, budget);
242
432
  synced.drafts = r.synced;
433
+ if (!r.done)
434
+ done = false;
243
435
  }
244
436
  }
245
- const note = unreadInbox.length > 0
246
- ? `${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them — this will mark them as read on OFW.`
247
- : undefined;
248
- 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 } : {}) };
249
446
  }
@@ -15,13 +15,30 @@ export const ApiRecipientSchema = z.looseObject({
15
15
  });
16
16
  // Translates OFW API recipient shape into the cache's normalized Recipient.
17
17
  // Used wherever we surface or persist recipients (sync, get_message, send,
18
- // save_draft) — all five call sites had near-identical inline mappings.
18
+ // save_draft).
19
+ //
20
+ // `viewedAt` is the recipient's true "First Viewed" time, or null if not yet
21
+ // viewed. Only the DETAIL endpoint (/pub/v3/messages/{id}) carries a real
22
+ // timestamp; the LIST endpoint returns an epoch-zero PLACEHOLDER
23
+ // ("1970-01-01T00:00:00") in the SAME field even for read messages (on the
24
+ // list, read status lives in `showNeverViewed`, not the timestamp). So treat
25
+ // the epoch placeholder as "no real view time" — otherwise a list-sourced row
26
+ // reports a bogus 1970 read time. A detail re-fetch is what fills in the truth.
19
27
  export function mapRecipients(items) {
20
- return (items ?? []).map((r) => ({
21
- userId: r.user?.id ?? 0,
22
- name: r.user?.name ?? '',
23
- viewedAt: r.viewed?.dateTime ?? null,
24
- }));
28
+ return (items ?? []).map((r) => {
29
+ const dt = r.viewed?.dateTime;
30
+ const viewedAt = typeof dt === 'string' && !dt.startsWith('1970-01-01') ? dt : null;
31
+ return { userId: r.user?.id ?? 0, name: r.user?.name ?? '', viewedAt };
32
+ });
33
+ }
34
+ // True if any recipient has a *real* "First Viewed" time — i.e. present and
35
+ // not the epoch-zero placeholder. After mapRecipients a fresh `viewedAt` is
36
+ // only ever a real timestamp or null, but a cache row written by older code
37
+ // (which trusted the list endpoint's `viewed`) may still hold the literal
38
+ // "1970-01-01T00:00:00". Treating that as "not viewed" lets sync/get_message
39
+ // re-fetch detail and self-heal the stale row to the real timestamp.
40
+ export function hasRealView(recipients) {
41
+ return recipients.some((r) => r.viewedAt !== null && !r.viewedAt.startsWith('1970-01-01'));
25
42
  }
26
43
  // Expand a user-provided path: ~ → home, relative → absolute. Re-exports
27
44
  // @chrischall/mcp-utils' `expandPath`.
@@ -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
+ }