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.
@@ -1,10 +1,7 @@
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';
3
+ import { getAttachmentsDir, getDefaultInlineAttachments, getSyncMaxRequests, getWriteMode } from '../config.js';
4
+ import { basename, join } from 'node:path';
8
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
@@ -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,7 +157,7 @@ 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
162
  let row = cached;
187
163
  // Refresh view status for a sent message we still believe is unviewed:
@@ -203,13 +179,13 @@ export function registerMessageTools(server, client) {
203
179
  recipients,
204
180
  listData: { ...cached.listData, showNeverViewed: !hasRealView(recipients) },
205
181
  };
206
- upsertMessage(row);
182
+ await cache.upsertMessage(row);
207
183
  }
208
184
  catch {
209
185
  // Best-effort: fall back to the cached row on any fetch/parse error.
210
186
  }
211
187
  }
212
- let attachments = listAttachmentsForMessage(id);
188
+ let attachments = await cache.listAttachmentsForMessage(id);
213
189
  // Lazy attachment backfill. The list-endpoint payload (stored in
214
190
  // listData) hints at attachments via `files: <count>` but doesn't
215
191
  // expose the fileIds — those live only on /pub/v3/messages/{id}.
@@ -221,8 +197,8 @@ export function registerMessageTools(server, client) {
221
197
  try {
222
198
  const detail = parseLenient(DetailFilesSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (attachment backfill)' });
223
199
  if (Array.isArray(detail.files) && detail.files.length > 0) {
224
- await fetchAttachmentMetaForMessage(client, id, detail.files);
225
- attachments = listAttachmentsForMessage(id);
200
+ await fetchAttachmentMetaForMessage(client, id, detail.files, cache);
201
+ attachments = await cache.listAttachmentsForMessage(id);
226
202
  }
227
203
  }
228
204
  catch {
@@ -232,7 +208,20 @@ export function registerMessageTools(server, client) {
232
208
  return jsonResponse({ ...row, attachments });
233
209
  }
234
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)' });
235
- 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
+ }
236
225
  const row = {
237
226
  id: detail.id,
238
227
  folder,
@@ -246,11 +235,11 @@ export function registerMessageTools(server, client) {
246
235
  chainRootId: cached?.chainRootId ?? null,
247
236
  listData: cached?.listData ?? detail,
248
237
  };
249
- upsertMessage(row);
238
+ await cache.upsertMessage(row);
250
239
  if (Array.isArray(detail.files) && detail.files.length > 0) {
251
- await fetchAttachmentMetaForMessage(client, detail.id, detail.files);
240
+ await fetchAttachmentMetaForMessage(client, detail.id, detail.files, cache);
252
241
  }
253
- const attachments = listAttachmentsForMessage(detail.id);
242
+ const attachments = await cache.listAttachmentsForMessage(detail.id);
254
243
  return jsonResponse({ ...row, attachments });
255
244
  });
256
245
  if (allowSend)
@@ -271,6 +260,7 @@ export function registerMessageTools(server, client) {
271
260
  throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
272
261
  }
273
262
  const draftRef = args.messageId ?? args.draftId;
263
+ const cache = cacheProvider();
274
264
  // Best-effort draft lookup: when draftRef points at a cached draft, use
275
265
  // its stored fields (including replyToId) as defaults for anything the
276
266
  // caller didn't supply. The "missing draft" case only matters when we
@@ -284,7 +274,7 @@ export function registerMessageTools(server, client) {
284
274
  let draftFound = false;
285
275
  if (draftRef !== undefined) {
286
276
  draftLookupAttempted = true;
287
- const draft = getDraft(draftRef);
277
+ const draft = await cache.getDraft(draftRef);
288
278
  if (draft !== null) {
289
279
  draftFound = true;
290
280
  subject = subject ?? draft.subject;
@@ -312,11 +302,11 @@ export function registerMessageTools(server, client) {
312
302
  let chainRootId = null;
313
303
  let rewriteNote = null;
314
304
  if (requestedReplyTo !== null) {
315
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
305
+ resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
316
306
  if (resolvedReplyTo !== requestedReplyTo) {
317
307
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
318
308
  }
319
- const parent = getMessage(resolvedReplyTo);
309
+ const parent = await cache.getMessage(resolvedReplyTo);
320
310
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
321
311
  }
322
312
  const myFileIDs = args.myFileIDs ?? [];
@@ -346,13 +336,13 @@ export function registerMessageTools(server, client) {
346
336
  chainRootId,
347
337
  listData: detail,
348
338
  };
349
- upsertMessage(persisted);
339
+ await cache.upsertMessage(persisted);
350
340
  // Link attached files to the new message in the attachments cache.
351
341
  // We may not have full metadata if the upload happened in a prior
352
342
  // session — fall back to what we know.
353
343
  for (const fileId of myFileIDs) {
354
- const existing = getAttachment(fileId);
355
- upsertAttachmentForMessage({
344
+ const existing = await cache.getAttachment(fileId);
345
+ await cache.upsertAttachmentForMessage({
356
346
  fileId,
357
347
  fileName: existing?.fileName ?? `file-${fileId}`,
358
348
  label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
@@ -375,7 +365,7 @@ export function registerMessageTools(server, client) {
375
365
  }
376
366
  else if (draftRef !== undefined) {
377
367
  await deleteOFWMessages(client, [draftRef]);
378
- deleteDraft(draftRef);
368
+ await cache.deleteDraft(draftRef);
379
369
  }
380
370
  const responseObj = persisted ?? raw;
381
371
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
@@ -392,7 +382,7 @@ export function registerMessageTools(server, client) {
392
382
  }, async (args) => {
393
383
  const page = args.page ?? 1;
394
384
  const size = args.size ?? 50;
395
- const drafts = listDrafts({ page, size });
385
+ const drafts = await cacheProvider().listDrafts({ page, size });
396
386
  const payload = drafts.length === 0
397
387
  ? { drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' }
398
388
  : { drafts };
@@ -411,11 +401,12 @@ export function registerMessageTools(server, client) {
411
401
  myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
412
402
  },
413
403
  }, async (args) => {
404
+ const cache = cacheProvider();
414
405
  const requestedReplyTo = args.replyToId ?? null;
415
406
  let resolvedReplyTo = requestedReplyTo;
416
407
  let rewriteNote = null;
417
408
  if (requestedReplyTo !== null) {
418
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
409
+ resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
419
410
  if (resolvedReplyTo !== requestedReplyTo) {
420
411
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
421
412
  }
@@ -451,13 +442,13 @@ export function registerMessageTools(server, client) {
451
442
  modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
452
443
  listData: detail,
453
444
  };
454
- upsertDraft(persisted);
445
+ await cache.upsertDraft(persisted);
455
446
  // Replace-path: caller passed messageId, so they want the old draft
456
447
  // gone. Delete it after the new one is safely created+cached.
457
448
  if (args.messageId !== undefined && args.messageId !== newId) {
458
449
  try {
459
450
  await deleteOFWMessages(client, [args.messageId]);
460
- deleteDraft(args.messageId);
451
+ await cache.deleteDraft(args.messageId);
461
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.)`;
462
453
  }
463
454
  catch (e) {
@@ -479,7 +470,7 @@ export function registerMessageTools(server, client) {
479
470
  },
480
471
  }, async (args) => {
481
472
  const data = await deleteOFWMessages(client, [args.messageId]);
482
- deleteDraft(args.messageId);
473
+ await cacheProvider().deleteDraft(args.messageId);
483
474
  return data ? jsonResponse(data) : textResponse('Draft deleted.');
484
475
  });
485
476
  server.registerTool('ofw_get_unread_sent', {
@@ -492,7 +483,7 @@ export function registerMessageTools(server, client) {
492
483
  }, async (args) => {
493
484
  const page = args.page ?? 1;
494
485
  const size = args.size ?? 50;
495
- const sent = listMessages({ folder: 'sent', page, size });
486
+ const sent = await cacheProvider().listMessages({ folder: 'sent', page, size });
496
487
  if (sent.length === 0) {
497
488
  return jsonResponse({ note: 'Sent cache is empty. Call ofw_sync_messages to populate.' });
498
489
  }
@@ -519,16 +510,12 @@ export function registerMessageTools(server, client) {
519
510
  description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
520
511
  },
521
512
  }, 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);
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);
528
516
  // Build the multipart payload matching the OFW web UI's request shape.
529
517
  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);
518
+ form.append('file', blob, fileName);
532
519
  form.append('source', 'message');
533
520
  form.append('description', args.description ?? fileName);
534
521
  form.append('label', args.label ?? fileName);
@@ -538,12 +525,12 @@ export function registerMessageTools(server, client) {
538
525
  // Cache metadata so subsequent ofw_get_message calls can surface it and
539
526
  // ofw_download_attachment can short-circuit. messageId is 0 (the
540
527
  // not-yet-linked sentinel) until a message actually references this file.
541
- upsertAttachmentForMessage({
528
+ await cacheProvider().upsertAttachmentForMessage({
542
529
  fileId: meta.fileId,
543
530
  fileName: meta.fileName ?? fileName,
544
531
  label: meta.label ?? args.label ?? fileName,
545
532
  mimeType: meta.fileType ?? mime,
546
- sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : stat.size,
533
+ sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : sizeBytes,
547
534
  metadata: meta,
548
535
  messageId: 0,
549
536
  });
@@ -551,7 +538,7 @@ export function registerMessageTools(server, client) {
551
538
  fileId: meta.fileId,
552
539
  fileName: meta.fileName ?? fileName,
553
540
  mimeType: meta.fileType ?? mime,
554
- sizeBytes: meta.sizeInBytes ?? stat.size,
541
+ sizeBytes: meta.sizeInBytes ?? sizeBytes,
555
542
  shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
556
543
  note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
557
544
  });
@@ -567,13 +554,14 @@ export function registerMessageTools(server, client) {
567
554
  },
568
555
  }, async (args) => {
569
556
  const fileId = args.fileId;
557
+ const cache = cacheProvider();
570
558
  const inline = args.inline ?? getDefaultInlineAttachments();
571
- let cached = getAttachment(fileId);
559
+ let cached = await cache.getAttachment(fileId);
572
560
  if (!cached) {
573
561
  // Not in cache. Fetch metadata and store under the messageId=0
574
562
  // sentinel — gets re-linked if a message later references this file.
575
- await fetchAttachmentMeta(client, fileId, 0);
576
- cached = getAttachment(fileId);
563
+ await fetchAttachmentMeta(client, fileId, 0, cache);
564
+ cached = await cache.getAttachment(fileId);
577
565
  /* v8 ignore next -- fetchAttachmentMeta persists the row it just fetched; a still-null read here is an unreachable storage failure */
578
566
  if (!cached)
579
567
  throw new Error(`failed to fetch metadata for fileId ${fileId}`);
@@ -584,10 +572,7 @@ export function registerMessageTools(server, client) {
584
572
  let mimeType = cached.mimeType;
585
573
  let fileName = cached.fileName;
586
574
  if (cached.downloadedPath) {
587
- try {
588
- bytes = readFileSync(cached.downloadedPath);
589
- }
590
- catch { /* on-disk copy missing; fall through */ }
575
+ bytes = attachmentIO.readDownloaded(cached.downloadedPath);
591
576
  }
592
577
  if (bytes === null) {
593
578
  const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
@@ -630,9 +615,8 @@ export function registerMessageTools(server, client) {
630
615
  });
631
616
  }
632
617
  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);
618
+ attachmentIO.writeDownload(dest, response.body);
619
+ await cache.markAttachmentDownloaded(fileId, dest);
636
620
  return jsonResponse({
637
621
  fileId,
638
622
  path: dest,
@@ -642,19 +626,21 @@ export function registerMessageTools(server, client) {
642
626
  });
643
627
  });
644
628
  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).',
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).',
646
630
  annotations: { readOnlyHint: false },
647
631
  inputSchema: {
648
632
  folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).describe('Folders to sync (default: all three)').optional(),
649
633
  fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.').optional(),
650
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(),
651
636
  },
652
637
  }, async (args) => {
653
638
  const result = await syncAll(client, {
654
639
  folders: args.folders,
655
640
  fetchUnreadBodies: args.fetchUnreadBodies,
656
641
  deep: args.deep,
657
- });
642
+ maxRequests: args.maxRequests ?? getSyncMaxRequests(),
643
+ }, cacheProvider());
658
644
  return jsonResponse(result);
659
645
  });
660
646
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.5.0",
3
+ "version": "2.6.3",
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.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.5.0",
14
+ "version": "2.6.3",
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)