ofw-mcp 2.0.10 → 2.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
1
  import { z } from 'zod';
2
- import { syncAll, fetchAttachmentMetaForMessage } from '../sync.js';
2
+ import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
3
3
  import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, listAttachmentsForMessage, getAttachment, upsertAttachmentForMessage, markAttachmentDownloaded, } from '../cache.js';
4
- import { getAttachmentsDir } from '../config.js';
4
+ import { getAttachmentsDir, getDefaultInlineAttachments } from '../config.js';
5
5
  import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
- import { basename, dirname, extname, join, isAbsolute, resolve } from 'node:path';
6
+ import { basename, dirname, extname, join } from 'node:path';
7
+ import { expandPath, jsonResponse, mapRecipients, textResponse } from './_shared.js';
7
8
  // Lightweight mime sniff from extension. OFW re-derives mime from the filename
8
9
  // server-side anyway, so this is just a polite Content-Type for the Blob.
9
10
  const MIME_BY_EXT = {
@@ -31,13 +32,27 @@ const MIME_BY_EXT = {
31
32
  function mimeFromName(name) {
32
33
  return MIME_BY_EXT[extname(name).toLowerCase()] ?? 'application/octet-stream';
33
34
  }
35
+ // The list endpoint payload (cached as `listData`) reports attachments via
36
+ // `files: <count>` (a number) — the actual fileIds only appear on the detail
37
+ // endpoint as `files: [number, ...]`. Some intermediate shapes return an
38
+ // array on the list too. Treat any of those as "this message has files".
39
+ function listDataHintsAtFiles(listData) {
40
+ if (typeof listData !== 'object' || listData === null)
41
+ return false;
42
+ const ld = listData;
43
+ if (typeof ld.files === 'number')
44
+ return ld.files > 0;
45
+ if (Array.isArray(ld.files))
46
+ return ld.files.length > 0;
47
+ return false;
48
+ }
34
49
  export function registerMessageTools(server, client) {
35
50
  server.registerTool('ofw_list_message_folders', {
36
51
  description: 'List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.',
37
52
  annotations: { readOnlyHint: true },
38
53
  }, async () => {
39
54
  const data = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
40
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
55
+ return jsonResponse(data);
41
56
  });
42
57
  server.registerTool('ofw_list_messages', {
43
58
  description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages. Call ofw_sync_messages first if the cache is empty or stale.',
@@ -62,15 +77,10 @@ export function registerMessageTools(server, client) {
62
77
  else if (folderArg === 'both')
63
78
  folder = undefined;
64
79
  else {
65
- return {
66
- content: [{
67
- type: 'text',
68
- text: JSON.stringify({
69
- messages: [],
70
- note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.',
71
- }, null, 2),
72
- }],
73
- };
80
+ return jsonResponse({
81
+ messages: [],
82
+ note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.',
83
+ });
74
84
  }
75
85
  const filter = { folder, since: args.since, until: args.until, q: args.q };
76
86
  const total = countMessages(filter);
@@ -82,7 +92,7 @@ export function registerMessageTools(server, client) {
82
92
  else if (page * size < total) {
83
93
  payload.note = `Showing ${(page - 1) * size + 1}–${(page - 1) * size + messages.length} of ${total}. Increase 'page' to see more, or narrow with since/until/q.`;
84
94
  }
85
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
95
+ return jsonResponse(payload);
86
96
  });
87
97
  server.registerTool('ofw_get_message', {
88
98
  description: 'Get a single OurFamilyWizard message by ID. Reads from local cache when available; otherwise fetches from OFW (which will mark unread inbox messages as read on OFW).',
@@ -94,13 +104,29 @@ export function registerMessageTools(server, client) {
94
104
  const id = Number(args.messageId);
95
105
  const cached = getMessage(id);
96
106
  if (cached && cached.body !== null) {
97
- const attachments = listAttachmentsForMessage(id);
98
- return { content: [{ type: 'text', text: JSON.stringify({ ...cached, attachments }, null, 2) }] };
107
+ let attachments = listAttachmentsForMessage(id);
108
+ // Lazy attachment backfill. The list-endpoint payload (stored in
109
+ // listData) hints at attachments via `files: <count>` but doesn't
110
+ // expose the fileIds — those live only on /pub/v3/messages/{id}.
111
+ // For messages bodied before attachment caching existed, the
112
+ // attachments table is empty even though OFW has files. Re-hit
113
+ // detail to harvest fileIds (idempotent: body is already cached so
114
+ // OFW state isn't changing).
115
+ if (attachments.length === 0 && listDataHintsAtFiles(cached.listData)) {
116
+ try {
117
+ const detail = await client.request('GET', `/pub/v3/messages/${id}`);
118
+ if (Array.isArray(detail.files) && detail.files.length > 0) {
119
+ await fetchAttachmentMetaForMessage(client, id, detail.files);
120
+ attachments = listAttachmentsForMessage(id);
121
+ }
122
+ }
123
+ catch {
124
+ // Backfill is best-effort. Fall through with whatever we have.
125
+ }
126
+ }
127
+ return jsonResponse({ ...cached, attachments });
99
128
  }
100
129
  const detail = await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
101
- const recipients = (detail.recipients ?? []).map((r) => ({
102
- userId: r.user.id, name: r.user.name, viewedAt: r.viewed?.dateTime ?? null,
103
- }));
104
130
  const folder = cached?.folder ?? 'inbox';
105
131
  const row = {
106
132
  id: detail.id,
@@ -108,7 +134,7 @@ export function registerMessageTools(server, client) {
108
134
  subject: detail.subject,
109
135
  fromUser: detail.from?.name ?? '',
110
136
  sentAt: detail.date?.dateTime ?? new Date().toISOString(),
111
- recipients,
137
+ recipients: mapRecipients(detail.recipients),
112
138
  body: detail.body ?? '',
113
139
  fetchedBodyAt: new Date().toISOString(),
114
140
  replyToId: cached?.replyToId ?? null,
@@ -120,7 +146,7 @@ export function registerMessageTools(server, client) {
120
146
  await fetchAttachmentMetaForMessage(client, detail.id, detail.files);
121
147
  }
122
148
  const attachments = listAttachmentsForMessage(detail.id);
123
- return { content: [{ type: 'text', text: JSON.stringify({ ...row, attachments }, null, 2) }] };
149
+ return jsonResponse({ ...row, attachments });
124
150
  });
125
151
  server.registerTool('ofw_send_message', {
126
152
  description: 'Send a message via OurFamilyWizard. If sending from a draft, pass draftId to delete the draft after sending. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs.',
@@ -157,16 +183,13 @@ export function registerMessageTools(server, client) {
157
183
  replyToId: resolvedReplyTo,
158
184
  });
159
185
  if (data && typeof data.id === 'number') {
160
- const recipients = (data.recipients ?? []).map((r) => ({
161
- userId: r.user.id, name: r.user.name, viewedAt: r.viewed?.dateTime ?? null,
162
- }));
163
186
  const row = {
164
187
  id: data.id,
165
188
  folder: 'sent',
166
189
  subject: data.subject ?? args.subject,
167
190
  fromUser: data.from?.name ?? '',
168
191
  sentAt: data.date?.dateTime ?? new Date().toISOString(),
169
- recipients,
192
+ recipients: mapRecipients(data.recipients),
170
193
  body: data.body ?? args.body,
171
194
  fetchedBodyAt: new Date().toISOString(),
172
195
  replyToId: resolvedReplyTo,
@@ -191,14 +214,11 @@ export function registerMessageTools(server, client) {
191
214
  }
192
215
  }
193
216
  if (args.draftId !== undefined) {
194
- const form = new FormData();
195
- form.append('messageIds', String(args.draftId));
196
- await client.request('DELETE', '/pub/v1/messages', form);
217
+ await deleteOFWMessages(client, [args.draftId]);
197
218
  deleteDraft(args.draftId);
198
219
  }
199
220
  const text = data ? JSON.stringify(data, null, 2) : 'Message sent successfully.';
200
- const finalText = rewriteNote ? `${rewriteNote}\n\n${text}` : text;
201
- return { content: [{ type: 'text', text: finalText }] };
221
+ return textResponse(rewriteNote ? `${rewriteNote}\n\n${text}` : text);
202
222
  });
203
223
  server.registerTool('ofw_list_drafts', {
204
224
  description: 'List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.',
@@ -214,7 +234,7 @@ export function registerMessageTools(server, client) {
214
234
  const payload = drafts.length === 0
215
235
  ? { drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' }
216
236
  : { drafts };
217
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
237
+ return jsonResponse(payload);
218
238
  });
219
239
  server.registerTool('ofw_save_draft', {
220
240
  description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs.',
@@ -255,9 +275,7 @@ export function registerMessageTools(server, client) {
255
275
  id: data.id,
256
276
  subject: data.subject ?? args.subject,
257
277
  body: data.body ?? args.body,
258
- recipients: (data.recipients ?? []).map((r) => ({
259
- userId: r.user.id, name: r.user.name, viewedAt: r.viewed?.dateTime ?? null,
260
- })),
278
+ recipients: mapRecipients(data.recipients),
261
279
  replyToId: data.replyToId ?? resolvedReplyTo,
262
280
  modifiedAt: data.date?.dateTime ?? new Date().toISOString(),
263
281
  listData: data,
@@ -265,8 +283,7 @@ export function registerMessageTools(server, client) {
265
283
  upsertDraft(draft);
266
284
  }
267
285
  const text = data ? JSON.stringify(data, null, 2) : 'Draft saved.';
268
- const finalText = rewriteNote ? `${rewriteNote}\n\n${text}` : text;
269
- return { content: [{ type: 'text', text: finalText }] };
286
+ return textResponse(rewriteNote ? `${rewriteNote}\n\n${text}` : text);
270
287
  });
271
288
  server.registerTool('ofw_delete_draft', {
272
289
  description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.',
@@ -275,11 +292,9 @@ export function registerMessageTools(server, client) {
275
292
  messageId: z.number().describe('Draft message ID to delete'),
276
293
  },
277
294
  }, async (args) => {
278
- const form = new FormData();
279
- form.append('messageIds', String(args.messageId));
280
- const data = await client.request('DELETE', '/pub/v1/messages', form);
295
+ const data = await deleteOFWMessages(client, [args.messageId]);
281
296
  deleteDraft(args.messageId);
282
- return { content: [{ type: 'text', text: data ? JSON.stringify(data, null, 2) : 'Draft deleted.' }] };
297
+ return data ? jsonResponse(data) : textResponse('Draft deleted.');
283
298
  });
284
299
  server.registerTool('ofw_get_unread_sent', {
285
300
  description: 'List sent messages that have not been read by one or more recipients. Reads from local cache; call ofw_sync_messages first if cache is stale.',
@@ -293,9 +308,7 @@ export function registerMessageTools(server, client) {
293
308
  const size = args.size ?? 50;
294
309
  const sent = listMessages({ folder: 'sent', page, size });
295
310
  if (sent.length === 0) {
296
- return { content: [{ type: 'text', text: JSON.stringify({
297
- note: 'Sent cache is empty. Call ofw_sync_messages to populate.',
298
- }, null, 2) }] };
311
+ return jsonResponse({ note: 'Sent cache is empty. Call ofw_sync_messages to populate.' });
299
312
  }
300
313
  const unread = [];
301
314
  for (const msg of sent) {
@@ -305,11 +318,9 @@ export function registerMessageTools(server, client) {
305
318
  }
306
319
  }
307
320
  if (unread.length === 0) {
308
- return { content: [{ type: 'text', text: JSON.stringify({
309
- message: 'All scanned sent messages have been read.',
310
- }, null, 2) }] };
321
+ return jsonResponse({ message: 'All scanned sent messages have been read.' });
311
322
  }
312
- return { content: [{ type: 'text', text: JSON.stringify(unread, null, 2) }] };
323
+ return jsonResponse(unread);
313
324
  });
314
325
  server.registerTool('ofw_upload_attachment', {
315
326
  description: 'Upload a local file to OurFamilyWizard\'s "My Files" so it can be attached to a message. Returns the fileId — pass that to ofw_send_message or ofw_save_draft in myFileIDs to attach it. The file is uploaded as PRIVATE (visible only to you) by default; pass shareClass:"SHARED" to share with co-parents directly via the My Files area.',
@@ -321,18 +332,14 @@ export function registerMessageTools(server, client) {
321
332
  description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
322
333
  },
323
334
  }, async (args) => {
324
- // Resolve and read the local file
325
- const expanded = args.path.startsWith('~/')
326
- ? join(process.env.HOME ?? '', args.path.slice(2))
327
- : args.path;
328
- const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
335
+ const abs = expandPath(args.path);
329
336
  const stat = statSync(abs); // throws if missing
330
337
  if (!stat.isFile())
331
338
  throw new Error(`Not a file: ${abs}`);
332
339
  const buf = readFileSync(abs);
333
340
  const fileName = basename(abs);
334
341
  const mime = mimeFromName(fileName);
335
- // Build the multipart payload matching the OFW web UI's request shape
342
+ // Build the multipart payload matching the OFW web UI's request shape.
336
343
  const form = new FormData();
337
344
  form.append('file', new Blob([new Uint8Array(buf)], { type: mime }), fileName);
338
345
  form.append('source', 'message');
@@ -341,10 +348,9 @@ export function registerMessageTools(server, client) {
341
348
  form.append('fileName', fileName);
342
349
  form.append('shareClass', args.shareClass ?? 'PRIVATE');
343
350
  const meta = await client.request('POST', '/pub/v3/myfiles/multipart', form);
344
- // Cache the metadata so subsequent ofw_get_message calls can surface it
345
- // and ofw_download_attachment short-circuits if asked. messageId is 0
346
- // because no message references this yet — it'll be linked once a
347
- // message is sent with this fileId in its attachments.
351
+ // Cache metadata so subsequent ofw_get_message calls can surface it and
352
+ // ofw_download_attachment can short-circuit. messageId is 0 (the
353
+ // not-yet-linked sentinel) until a message actually references this file.
348
354
  upsertAttachmentForMessage({
349
355
  fileId: meta.fileId,
350
356
  fileName: meta.fileName ?? fileName,
@@ -354,77 +360,93 @@ export function registerMessageTools(server, client) {
354
360
  metadata: meta,
355
361
  messageId: 0,
356
362
  });
357
- return { content: [{ type: 'text', text: JSON.stringify({
358
- fileId: meta.fileId,
359
- fileName: meta.fileName ?? fileName,
360
- mimeType: meta.fileType ?? mime,
361
- sizeBytes: meta.sizeInBytes ?? buf.length,
362
- shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
363
- note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
364
- }, null, 2) }] };
363
+ return jsonResponse({
364
+ fileId: meta.fileId,
365
+ fileName: meta.fileName ?? fileName,
366
+ mimeType: meta.fileType ?? mime,
367
+ sizeBytes: meta.sizeInBytes ?? buf.length,
368
+ shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
369
+ note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
370
+ });
365
371
  });
366
372
  server.registerTool('ofw_download_attachment', {
367
- description: 'Download an OFW message attachment by fileId. Bytes are saved to disk; the tool returns the absolute path, mime type, and size so the caller can then read/analyze the file. fileId comes from the attachments array on ofw_get_message. Saves under ~/.cache/ofw-mcp/attachments/<hash>/ by default (override via OFW_ATTACHMENTS_DIR or the saveTo argument). Re-downloading is a no-op if the file is already on disk.',
373
+ description: 'Download an OFW message attachment by fileId. By default, bytes are saved to disk (~/Downloads/ofw-mcp/) and the response carries the absolute path, mime type, and size for the caller to read back. Pass inline:true to skip disk entirely and return the bytes as MCP content blocks — images come back as ImageContent (the model sees them directly); other files come back as an EmbeddedResource blob. Use inline for small files where you want the model to read content immediately and the host is sandboxed; use disk for large files or when you want a persistent local copy. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var (set to "true" to make inline the default). fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo. Re-downloading to the same path is a no-op (disk mode only).',
368
374
  annotations: { readOnlyHint: false },
369
375
  inputSchema: {
370
376
  fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
371
- saveTo: z.string().describe('Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/.cache/ofw-mcp/attachments/<hash>/<fileId>-<filename>').optional(),
372
- force: z.boolean().describe('Re-download even if already on disk. Default false.').optional(),
377
+ inline: z.boolean().describe('If true, return bytes inline as MCP content (image for image/*, embedded resource blob otherwise) and skip the disk write. If false, write to disk and return the path. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).').optional(),
378
+ saveTo: z.string().describe('Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/Downloads/ofw-mcp/<fileId>-<filename>. Ignored when inline:true.').optional(),
379
+ force: z.boolean().describe('Re-download even if already on disk. Default false. Ignored when inline:true (inline always fetches fresh bytes, or reuses an on-disk copy if present).').optional(),
373
380
  },
374
381
  }, async (args) => {
375
382
  const fileId = args.fileId;
383
+ const inline = args.inline ?? getDefaultInlineAttachments();
376
384
  let cached = getAttachment(fileId);
377
385
  if (!cached) {
378
- // Metadata not in cache fetch on the fly.
379
- const meta = await client.request('GET', `/pub/v1/myfiles/${fileId}`);
380
- // Store with a sentinel "metadata-only, no message link" — we don't know which message asked.
381
- // We'll re-link if a message later references it during sync.
382
- upsertAttachmentForMessage({
383
- fileId: meta.fileId ?? fileId,
384
- fileName: meta.fileName ?? `file-${fileId}`,
385
- label: meta.label ?? meta.fileName ?? `file-${fileId}`,
386
- mimeType: meta.fileType ?? 'application/octet-stream',
387
- sizeBytes: typeof meta.fileSize === 'number' ? meta.fileSize : null,
388
- metadata: meta,
389
- messageId: 0, // placeholder; will be cleaned up if a real message references it
390
- });
386
+ // Not in cache. Fetch metadata and store under the messageId=0
387
+ // sentinel gets re-linked if a message later references this file.
388
+ await fetchAttachmentMeta(client, fileId, 0);
391
389
  cached = getAttachment(fileId);
392
390
  if (!cached)
393
391
  throw new Error(`failed to fetch metadata for fileId ${fileId}`);
394
392
  }
395
- // Decide destination path
393
+ if (inline) {
394
+ // Reuse on-disk bytes if we already have them; otherwise fetch fresh.
395
+ let bytes = null;
396
+ let mimeType = cached.mimeType;
397
+ let fileName = cached.fileName;
398
+ if (cached.downloadedPath) {
399
+ try {
400
+ bytes = readFileSync(cached.downloadedPath);
401
+ }
402
+ catch { /* on-disk copy missing; fall through */ }
403
+ }
404
+ if (bytes === null) {
405
+ const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
406
+ bytes = response.body;
407
+ mimeType = response.contentType ?? cached.mimeType;
408
+ fileName = response.suggestedFileName ?? cached.fileName;
409
+ }
410
+ const base64 = bytes.toString('base64');
411
+ const metaBlock = { type: 'text', text: JSON.stringify({
412
+ fileId, fileName, mimeType, sizeBytes: bytes.length, mode: 'inline',
413
+ }, null, 2) };
414
+ if (mimeType.startsWith('image/')) {
415
+ return { content: [metaBlock, { type: 'image', data: base64, mimeType }] };
416
+ }
417
+ return { content: [metaBlock, { type: 'resource', resource: {
418
+ uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName)}`,
419
+ mimeType,
420
+ blob: base64,
421
+ } }] };
422
+ }
396
423
  let dest;
397
424
  if (args.saveTo) {
398
- const expanded = args.saveTo.startsWith('~/')
399
- ? join(process.env.HOME ?? '', args.saveTo.slice(2))
400
- : args.saveTo;
401
- const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
402
- // If it looks like a directory (ends with /) OR is an existing directory, treat as dir.
403
- const isDirArg = expanded.endsWith('/') || expanded.endsWith('\\');
425
+ // Treat saveTo as a directory if it ends with a separator; otherwise as a full path.
426
+ const isDirArg = args.saveTo.endsWith('/') || args.saveTo.endsWith('\\');
427
+ const abs = expandPath(args.saveTo);
404
428
  dest = isDirArg ? join(abs, `${fileId}-${cached.fileName}`) : abs;
405
429
  }
406
430
  else {
407
431
  dest = join(getAttachmentsDir(), `${fileId}-${cached.fileName}`);
408
432
  }
409
- // Short-circuit if already downloaded to this path
410
433
  if (!args.force && cached.downloadedPath === dest) {
411
- return { content: [{ type: 'text', text: JSON.stringify({
412
- fileId, path: dest, mimeType: cached.mimeType, sizeBytes: cached.sizeBytes,
413
- fileName: cached.fileName, note: 'already downloaded',
414
- }, null, 2) }] };
434
+ return jsonResponse({
435
+ fileId, path: dest, mimeType: cached.mimeType, sizeBytes: cached.sizeBytes,
436
+ fileName: cached.fileName, note: 'already downloaded',
437
+ });
415
438
  }
416
- // Fetch bytes
417
439
  const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
418
440
  mkdirSync(dirname(dest), { recursive: true });
419
441
  writeFileSync(dest, response.body);
420
442
  markAttachmentDownloaded(fileId, dest);
421
- return { content: [{ type: 'text', text: JSON.stringify({
422
- fileId,
423
- path: dest,
424
- mimeType: response.contentType ?? cached.mimeType,
425
- sizeBytes: response.body.length,
426
- fileName: response.suggestedFileName ?? cached.fileName,
427
- }, null, 2) }] };
443
+ return jsonResponse({
444
+ fileId,
445
+ path: dest,
446
+ mimeType: response.contentType ?? cached.mimeType,
447
+ sizeBytes: response.body.length,
448
+ fileName: response.suggestedFileName ?? cached.fileName,
449
+ });
428
450
  });
429
451
  server.registerTool('ofw_sync_messages', {
430
452
  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).',
@@ -440,6 +462,14 @@ export function registerMessageTools(server, client) {
440
462
  fetchUnreadBodies: args.fetchUnreadBodies,
441
463
  deep: args.deep,
442
464
  });
443
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
465
+ return jsonResponse(result);
444
466
  });
445
467
  }
468
+ // OFW's bulk-delete endpoint takes a multipart form with `messageIds`.
469
+ // Used by both ofw_delete_draft and ofw_send_message (draft cleanup).
470
+ async function deleteOFWMessages(client, ids) {
471
+ const form = new FormData();
472
+ for (const id of ids)
473
+ form.append('messageIds', String(id));
474
+ return client.request('DELETE', '/pub/v1/messages', form);
475
+ }
@@ -1,16 +1,17 @@
1
+ import { jsonResponse } from './_shared.js';
1
2
  export function registerUserTools(server, client) {
2
3
  server.registerTool('ofw_get_profile', {
3
4
  description: 'Get current user and co-parent profile information from OurFamilyWizard',
4
5
  annotations: { readOnlyHint: true },
5
6
  }, async () => {
6
7
  const data = await client.request('GET', '/pub/v2/profiles');
7
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
8
+ return jsonResponse(data);
8
9
  });
9
10
  server.registerTool('ofw_get_notifications', {
10
11
  description: 'Get OurFamilyWizard dashboard summary: unread message count, upcoming events, outstanding expenses. Note: updates your last-seen status.',
11
12
  annotations: { readOnlyHint: false },
12
13
  }, async () => {
13
14
  const data = await client.request('GET', '/pub/v1/users/useraccountstatus');
14
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
15
+ return jsonResponse(data);
15
16
  });
16
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
4
4
  "mcpName": "io.github.chrischall/ofw-mcp",
5
5
  "description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -32,10 +32,10 @@
32
32
  "zod": "^4.4.2"
33
33
  },
34
34
  "devDependencies": {
35
- "@types/node": "^25.5.2",
36
- "@vitest/coverage-v8": "^4.1.2",
35
+ "@types/node": "^25.8.0",
36
+ "@vitest/coverage-v8": "^4.1.6",
37
37
  "esbuild": "^0.28.0",
38
38
  "typescript": "^6.0.2",
39
- "vitest": "^4.1.2"
39
+ "vitest": "^4.1.6"
40
40
  }
41
41
  }
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.0.10",
9
+ "version": "2.0.12",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.0.10",
14
+ "version": "2.0.12",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },