ofw-mcp 2.5.0 → 2.6.4

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,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, hasRealView, 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, withReadState } 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,14 @@ 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
+ // Reconcile each row's read state at read time: the cached list flags can be
116
+ // stale (a message read after it was first scraped), so `read` is derived
117
+ // from the record's own `viewedAt`/`fetchedBodyAt` and `listData` is forced
118
+ // to agree — see withReadState.
119
+ const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
141
120
  const payload = { messages, total, page, size };
142
121
  if (total === 0) {
143
122
  payload.note = 'No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.';
@@ -155,13 +134,14 @@ export function registerMessageTools(server, client) {
155
134
  },
156
135
  }, async (args) => {
157
136
  const id = Number(args.messageId);
137
+ const cache = cacheProvider();
158
138
  // Draft routing: if this id is in the drafts cache, return a
159
139
  // MessageRow-shaped synthesis built from the draft. The drafts table
160
140
  // is the source of truth for draft bodies (sync keeps it fresh);
161
141
  // the messages-table cache for the same id is stale by construction
162
142
  // when ofw_get_message was called on a draft id before sync caught
163
143
  // up — see syncDrafts, which also evicts these stale rows.
164
- const draftRow = getDraft(id);
144
+ const draftRow = await cache.getDraft(id);
165
145
  if (draftRow !== null) {
166
146
  return jsonResponse({
167
147
  id: draftRow.id,
@@ -181,7 +161,7 @@ export function registerMessageTools(server, client) {
181
161
  attachments: [],
182
162
  });
183
163
  }
184
- const cached = getMessage(id);
164
+ const cached = await cache.getMessage(id);
185
165
  if (cached && cached.body !== null) {
186
166
  let row = cached;
187
167
  // Refresh view status for a sent message we still believe is unviewed:
@@ -203,13 +183,13 @@ export function registerMessageTools(server, client) {
203
183
  recipients,
204
184
  listData: { ...cached.listData, showNeverViewed: !hasRealView(recipients) },
205
185
  };
206
- upsertMessage(row);
186
+ await cache.upsertMessage(row);
207
187
  }
208
188
  catch {
209
189
  // Best-effort: fall back to the cached row on any fetch/parse error.
210
190
  }
211
191
  }
212
- let attachments = listAttachmentsForMessage(id);
192
+ let attachments = await cache.listAttachmentsForMessage(id);
213
193
  // Lazy attachment backfill. The list-endpoint payload (stored in
214
194
  // listData) hints at attachments via `files: <count>` but doesn't
215
195
  // expose the fileIds — those live only on /pub/v3/messages/{id}.
@@ -221,18 +201,31 @@ export function registerMessageTools(server, client) {
221
201
  try {
222
202
  const detail = parseLenient(DetailFilesSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (attachment backfill)' });
223
203
  if (Array.isArray(detail.files) && detail.files.length > 0) {
224
- await fetchAttachmentMetaForMessage(client, id, detail.files);
225
- attachments = listAttachmentsForMessage(id);
204
+ await fetchAttachmentMetaForMessage(client, id, detail.files, cache);
205
+ attachments = await cache.listAttachmentsForMessage(id);
226
206
  }
227
207
  }
228
208
  catch {
229
209
  // Backfill is best-effort. Fall through with whatever we have.
230
210
  }
231
211
  }
232
- return jsonResponse({ ...row, attachments });
212
+ return jsonResponse({ ...withReadState(row), attachments });
233
213
  }
234
214
  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)' });
235
- const folder = cached?.folder ?? 'inbox';
215
+ // Derive the folder for a live-fetched message. A cached row (reached here
216
+ // only when its body was NULL) already knows its folder, so keep it.
217
+ // Otherwise use the detail's own folder id, matched against the sent folder
218
+ // id persisted by the last resolveFolderIds — a sent message must not be
219
+ // mislabeled 'inbox' (which would also hide it from ofw_get_unread_sent and
220
+ // a sent-scoped ofw_list_messages). When that mapping isn't known yet (no
221
+ // sync has run in this cache), fall back to 'inbox' as before.
222
+ let folder = cached?.folder ?? 'inbox';
223
+ if (!cached) {
224
+ const sentFolderId = await cache.getMeta('sent_folder_id');
225
+ if (sentFolderId !== null && detail.folder?.id != null && String(detail.folder.id) === sentFolderId) {
226
+ folder = 'sent';
227
+ }
228
+ }
236
229
  const row = {
237
230
  id: detail.id,
238
231
  folder,
@@ -246,12 +239,12 @@ export function registerMessageTools(server, client) {
246
239
  chainRootId: cached?.chainRootId ?? null,
247
240
  listData: cached?.listData ?? detail,
248
241
  };
249
- upsertMessage(row);
242
+ await cache.upsertMessage(row);
250
243
  if (Array.isArray(detail.files) && detail.files.length > 0) {
251
- await fetchAttachmentMetaForMessage(client, detail.id, detail.files);
244
+ await fetchAttachmentMetaForMessage(client, detail.id, detail.files, cache);
252
245
  }
253
- const attachments = listAttachmentsForMessage(detail.id);
254
- return jsonResponse({ ...row, attachments });
246
+ const attachments = await cache.listAttachmentsForMessage(detail.id);
247
+ return jsonResponse({ ...withReadState(row), attachments });
255
248
  });
256
249
  if (allowSend)
257
250
  server.registerTool('ofw_send_message', {
@@ -271,6 +264,7 @@ export function registerMessageTools(server, client) {
271
264
  throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
272
265
  }
273
266
  const draftRef = args.messageId ?? args.draftId;
267
+ const cache = cacheProvider();
274
268
  // Best-effort draft lookup: when draftRef points at a cached draft, use
275
269
  // its stored fields (including replyToId) as defaults for anything the
276
270
  // caller didn't supply. The "missing draft" case only matters when we
@@ -284,7 +278,7 @@ export function registerMessageTools(server, client) {
284
278
  let draftFound = false;
285
279
  if (draftRef !== undefined) {
286
280
  draftLookupAttempted = true;
287
- const draft = getDraft(draftRef);
281
+ const draft = await cache.getDraft(draftRef);
288
282
  if (draft !== null) {
289
283
  draftFound = true;
290
284
  subject = subject ?? draft.subject;
@@ -312,11 +306,11 @@ export function registerMessageTools(server, client) {
312
306
  let chainRootId = null;
313
307
  let rewriteNote = null;
314
308
  if (requestedReplyTo !== null) {
315
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
309
+ resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
316
310
  if (resolvedReplyTo !== requestedReplyTo) {
317
311
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
318
312
  }
319
- const parent = getMessage(resolvedReplyTo);
313
+ const parent = await cache.getMessage(resolvedReplyTo);
320
314
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
321
315
  }
322
316
  const myFileIDs = args.myFileIDs ?? [];
@@ -346,13 +340,13 @@ export function registerMessageTools(server, client) {
346
340
  chainRootId,
347
341
  listData: detail,
348
342
  };
349
- upsertMessage(persisted);
343
+ await cache.upsertMessage(persisted);
350
344
  // Link attached files to the new message in the attachments cache.
351
345
  // We may not have full metadata if the upload happened in a prior
352
346
  // session — fall back to what we know.
353
347
  for (const fileId of myFileIDs) {
354
- const existing = getAttachment(fileId);
355
- upsertAttachmentForMessage({
348
+ const existing = await cache.getAttachment(fileId);
349
+ await cache.upsertAttachmentForMessage({
356
350
  fileId,
357
351
  fileName: existing?.fileName ?? `file-${fileId}`,
358
352
  label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
@@ -375,7 +369,7 @@ export function registerMessageTools(server, client) {
375
369
  }
376
370
  else if (draftRef !== undefined) {
377
371
  await deleteOFWMessages(client, [draftRef]);
378
- deleteDraft(draftRef);
372
+ await cache.deleteDraft(draftRef);
379
373
  }
380
374
  const responseObj = persisted ?? raw;
381
375
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
@@ -392,7 +386,7 @@ export function registerMessageTools(server, client) {
392
386
  }, async (args) => {
393
387
  const page = args.page ?? 1;
394
388
  const size = args.size ?? 50;
395
- const drafts = listDrafts({ page, size });
389
+ const drafts = await cacheProvider().listDrafts({ page, size });
396
390
  const payload = drafts.length === 0
397
391
  ? { drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' }
398
392
  : { drafts };
@@ -411,11 +405,12 @@ export function registerMessageTools(server, client) {
411
405
  myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
412
406
  },
413
407
  }, async (args) => {
408
+ const cache = cacheProvider();
414
409
  const requestedReplyTo = args.replyToId ?? null;
415
410
  let resolvedReplyTo = requestedReplyTo;
416
411
  let rewriteNote = null;
417
412
  if (requestedReplyTo !== null) {
418
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
413
+ resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
419
414
  if (resolvedReplyTo !== requestedReplyTo) {
420
415
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
421
416
  }
@@ -451,13 +446,13 @@ export function registerMessageTools(server, client) {
451
446
  modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
452
447
  listData: detail,
453
448
  };
454
- upsertDraft(persisted);
449
+ await cache.upsertDraft(persisted);
455
450
  // Replace-path: caller passed messageId, so they want the old draft
456
451
  // gone. Delete it after the new one is safely created+cached.
457
452
  if (args.messageId !== undefined && args.messageId !== newId) {
458
453
  try {
459
454
  await deleteOFWMessages(client, [args.messageId]);
460
- deleteDraft(args.messageId);
455
+ await cache.deleteDraft(args.messageId);
461
456
  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.)`;
462
457
  }
463
458
  catch (e) {
@@ -479,7 +474,7 @@ export function registerMessageTools(server, client) {
479
474
  },
480
475
  }, async (args) => {
481
476
  const data = await deleteOFWMessages(client, [args.messageId]);
482
- deleteDraft(args.messageId);
477
+ await cacheProvider().deleteDraft(args.messageId);
483
478
  return data ? jsonResponse(data) : textResponse('Draft deleted.');
484
479
  });
485
480
  server.registerTool('ofw_get_unread_sent', {
@@ -492,7 +487,7 @@ export function registerMessageTools(server, client) {
492
487
  }, async (args) => {
493
488
  const page = args.page ?? 1;
494
489
  const size = args.size ?? 50;
495
- const sent = listMessages({ folder: 'sent', page, size });
490
+ const sent = await cacheProvider().listMessages({ folder: 'sent', page, size });
496
491
  if (sent.length === 0) {
497
492
  return jsonResponse({ note: 'Sent cache is empty. Call ofw_sync_messages to populate.' });
498
493
  }
@@ -519,16 +514,12 @@ export function registerMessageTools(server, client) {
519
514
  description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
520
515
  },
521
516
  }, async (args) => {
522
- const abs = expandPath(args.path);
523
- const stat = statSync(abs); // throws if missing
524
- if (!stat.isFile())
525
- throw new Error(`Not a file: ${abs}`);
526
- const fileName = basename(abs);
527
- const mime = mimeFromName(fileName);
517
+ // Resolve the upload source through the injected attachment-I/O boundary
518
+ // (disk read on node; an in-memory source on the hosted connector).
519
+ const { blob, fileName, mimeType: mime, sizeBytes } = await attachmentIO.resolveUpload(args.path);
528
520
  // Build the multipart payload matching the OFW web UI's request shape.
529
521
  const form = new FormData();
530
- // fileBlob streams the file off disk (a file-backed Blob) instead of buffering it.
531
- form.append('file', await fileBlob(abs, { type: mime }), fileName);
522
+ form.append('file', blob, fileName);
532
523
  form.append('source', 'message');
533
524
  form.append('description', args.description ?? fileName);
534
525
  form.append('label', args.label ?? fileName);
@@ -538,12 +529,12 @@ export function registerMessageTools(server, client) {
538
529
  // Cache metadata so subsequent ofw_get_message calls can surface it and
539
530
  // ofw_download_attachment can short-circuit. messageId is 0 (the
540
531
  // not-yet-linked sentinel) until a message actually references this file.
541
- upsertAttachmentForMessage({
532
+ await cacheProvider().upsertAttachmentForMessage({
542
533
  fileId: meta.fileId,
543
534
  fileName: meta.fileName ?? fileName,
544
535
  label: meta.label ?? args.label ?? fileName,
545
536
  mimeType: meta.fileType ?? mime,
546
- sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : stat.size,
537
+ sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : sizeBytes,
547
538
  metadata: meta,
548
539
  messageId: 0,
549
540
  });
@@ -551,7 +542,7 @@ export function registerMessageTools(server, client) {
551
542
  fileId: meta.fileId,
552
543
  fileName: meta.fileName ?? fileName,
553
544
  mimeType: meta.fileType ?? mime,
554
- sizeBytes: meta.sizeInBytes ?? stat.size,
545
+ sizeBytes: meta.sizeInBytes ?? sizeBytes,
555
546
  shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
556
547
  note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
557
548
  });
@@ -567,13 +558,14 @@ export function registerMessageTools(server, client) {
567
558
  },
568
559
  }, async (args) => {
569
560
  const fileId = args.fileId;
561
+ const cache = cacheProvider();
570
562
  const inline = args.inline ?? getDefaultInlineAttachments();
571
- let cached = getAttachment(fileId);
563
+ let cached = await cache.getAttachment(fileId);
572
564
  if (!cached) {
573
565
  // Not in cache. Fetch metadata and store under the messageId=0
574
566
  // sentinel — gets re-linked if a message later references this file.
575
- await fetchAttachmentMeta(client, fileId, 0);
576
- cached = getAttachment(fileId);
567
+ await fetchAttachmentMeta(client, fileId, 0, cache);
568
+ cached = await cache.getAttachment(fileId);
577
569
  /* v8 ignore next -- fetchAttachmentMeta persists the row it just fetched; a still-null read here is an unreachable storage failure */
578
570
  if (!cached)
579
571
  throw new Error(`failed to fetch metadata for fileId ${fileId}`);
@@ -584,10 +576,7 @@ export function registerMessageTools(server, client) {
584
576
  let mimeType = cached.mimeType;
585
577
  let fileName = cached.fileName;
586
578
  if (cached.downloadedPath) {
587
- try {
588
- bytes = readFileSync(cached.downloadedPath);
589
- }
590
- catch { /* on-disk copy missing; fall through */ }
579
+ bytes = attachmentIO.readDownloaded(cached.downloadedPath);
591
580
  }
592
581
  if (bytes === null) {
593
582
  const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
@@ -630,9 +619,8 @@ export function registerMessageTools(server, client) {
630
619
  });
631
620
  }
632
621
  const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
633
- mkdirSync(dirname(dest), { recursive: true });
634
- writeFileSync(dest, response.body);
635
- markAttachmentDownloaded(fileId, dest);
622
+ attachmentIO.writeDownload(dest, response.body);
623
+ await cache.markAttachmentDownloaded(fileId, dest);
636
624
  return jsonResponse({
637
625
  fileId,
638
626
  path: dest,
@@ -642,19 +630,21 @@ export function registerMessageTools(server, client) {
642
630
  });
643
631
  });
644
632
  server.registerTool('ofw_sync_messages', {
645
- 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).',
633
+ 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).',
646
634
  annotations: { readOnlyHint: false },
647
635
  inputSchema: {
648
636
  folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).describe('Folders to sync (default: all three)').optional(),
649
637
  fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.').optional(),
650
638
  deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
639
+ 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(),
651
640
  },
652
641
  }, async (args) => {
653
642
  const result = await syncAll(client, {
654
643
  folders: args.folders,
655
644
  fetchUnreadBodies: args.fetchUnreadBodies,
656
645
  deep: args.deep,
657
- });
646
+ maxRequests: args.maxRequests ?? getSyncMaxRequests(),
647
+ }, cacheProvider());
658
648
  return jsonResponse(result);
659
649
  });
660
650
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.5.0",
3
+ "version": "2.6.4",
4
4
  "license": "MIT",
5
5
  "mcpName": "io.github.chrischall/ofw-mcp",
6
6
  "description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
@@ -29,7 +29,10 @@
29
29
  "dev": "node --env-file=.env dist/index.js",
30
30
  "test": "vitest run",
31
31
  "test:coverage": "vitest run --coverage",
32
- "test:watch": "vitest"
32
+ "test:watch": "vitest",
33
+ "worker:dev": "wrangler dev",
34
+ "worker:deploy": "wrangler deploy",
35
+ "worker:test": "vitest run --config vitest.workers.config.ts"
33
36
  },
34
37
  "dependencies": {
35
38
  "@chrischall/mcp-utils": "^0.13.0",
@@ -39,10 +42,16 @@
39
42
  "zod": "^4.4.3"
40
43
  },
41
44
  "devDependencies": {
45
+ "@chrischall/mcp-connector": "^0.1.0",
46
+ "@cloudflare/vitest-pool-workers": "^0.18.4",
47
+ "@cloudflare/workers-oauth-provider": "^0.0.11",
48
+ "@cloudflare/workers-types": "^5.20260708.1",
42
49
  "@types/node": "^26.0.0",
43
50
  "@vitest/coverage-v8": "^4.1.7",
51
+ "agents": "^0.17.3",
44
52
  "esbuild": "^0.28.0",
45
53
  "typescript": "^7.0.2",
46
- "vitest": "^4.1.7"
54
+ "vitest": "^4.1.7",
55
+ "wrangler": "^4.110.0"
47
56
  }
48
57
  }
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/ofw-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.5.0",
9
+ "version": "2.6.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.5.0",
14
+ "version": "2.6.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -10,6 +10,8 @@ MCP server for OurFamilyWizard — provides read/write access to messages, calen
10
10
  - **npm:** [npmjs.com/package/ofw-mcp](https://www.npmjs.com/package/ofw-mcp)
11
11
  - **Source:** [github.com/chrischall/ofw-mcp](https://github.com/chrischall/ofw-mcp)
12
12
 
13
+ > These tools are also available via the hosted [claude.ai](https://claude.ai) remote connector (a Cloudflare Worker) — the tool set and behaviour are identical to the local stdio install. See the repo's `docs/DEPLOY-CONNECTOR.md`.
14
+
13
15
  ## Setup
14
16
 
15
17
  ### Option A — Claude Code (direct MCP, no mcporter)
@@ -55,7 +55,7 @@ read on OFW):
55
55
 
56
56
  ```sh
57
57
  curl -s "https://ofw.ourfamilywizard.com/pub/v3/messages/${ID}" "${AUTH_HEADERS[@]}" \
58
- | jq '{id, subject, body, sentAt: .date.dateTime, from: .from.name, files, recipients: [.recipients[] | {id: .user.id, name: .user.name, viewedAt: .viewed.dateTime}]}'
58
+ | jq '{id, subject, body, sentAt: .date.dateTime, from: .from.name, files, recipients: [.recipients[] | {id: .user.userId, name: .user.name, viewedAt: .viewed.dateTime}]}'
59
59
  ```
60
60
 
61
61
  ## 3. Send a message / save a draft (write — confirm-by-re-GET)