ofw-mcp 2.3.2 → 2.4.1

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,56 @@
1
1
  import { z } from 'zod';
2
2
  import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
3
3
  import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, listAttachmentsForMessage, getAttachment, upsertAttachmentForMessage, markAttachmentDownloaded, getDraft, } from '../cache.js';
4
- import { getAttachmentsDir, getDefaultInlineAttachments } from '../config.js';
4
+ import { getAttachmentsDir, getDefaultInlineAttachments, getWriteMode } from '../config.js';
5
5
  import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
6
  import { basename, dirname, extname, join } from 'node:path';
7
7
  import { fileBlob } from '@chrischall/mcp-utils';
8
- import { expandPath, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse } from './_shared.js';
8
+ import { ApiRecipientSchema, expandPath, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
9
+ import { parseOFW } from '../validate.js';
10
+ // Schemas for the load-bearing fields of each /pub/v3 response this file
11
+ // reads (issue #83). Loose: unknown keys pass through into cached listData.
12
+ const DateSchema = z.looseObject({ dateTime: z.string() });
13
+ // Detail GET after a send/save POST — validated STRICT inside
14
+ // postMessageAndRefetch (write-verification boundary). All fields optional:
15
+ // absence is handled by verifyWriteLanded's WARNING; a present-but-mistyped
16
+ // field throws.
17
+ const SentDetailSchema = z.looseObject({
18
+ subject: z.string().optional(),
19
+ body: z.string().optional(),
20
+ date: DateSchema.optional(),
21
+ from: z.looseObject({ name: z.string().optional() }).optional(),
22
+ recipients: z.array(ApiRecipientSchema).optional(),
23
+ });
24
+ const SavedDraftDetailSchema = z.looseObject({
25
+ subject: z.string().optional(),
26
+ body: z.string().optional(),
27
+ date: DateSchema.optional(),
28
+ replyToId: z.number().nullable().optional(),
29
+ recipients: z.array(ApiRecipientSchema).optional(),
30
+ });
31
+ // ofw_get_message's uncached detail fetch — lenient: a mismatch warns to
32
+ // stderr and the existing ?? fallbacks keep the tool serving.
33
+ const MessageDetailSchema = z.looseObject({
34
+ id: z.number(),
35
+ subject: z.string(),
36
+ body: z.string().optional(),
37
+ date: DateSchema,
38
+ from: z.looseObject({ name: z.string().optional() }).optional(),
39
+ files: z.array(z.number()).optional(),
40
+ recipients: z.array(ApiRecipientSchema).optional(),
41
+ });
42
+ // Attachment-backfill detail fetch reads only `files`.
43
+ const DetailFilesSchema = z.looseObject({ files: z.array(z.number()).optional() });
44
+ // Upload response — STRICT: fileId is the whole point of the call; caching
45
+ // or returning an undefined/mistyped fileId produces an unusable attachment.
46
+ const UploadedFileSchema = z.looseObject({
47
+ fileId: z.number(),
48
+ fileName: z.string().optional(),
49
+ label: z.string().optional(),
50
+ fileType: z.string().optional(),
51
+ sizeInBytes: z.number().optional(),
52
+ shareClass: z.string().optional(),
53
+ });
9
54
  // Lightweight mime sniff from extension. OFW re-derives mime from the filename
10
55
  // server-side anyway, so this is just a polite Content-Type for the Blob.
11
56
  const MIME_BY_EXT = {
@@ -48,6 +93,13 @@ function listDataHintsAtFiles(listData) {
48
93
  return false;
49
94
  }
50
95
  export function registerMessageTools(server, client) {
96
+ // OFW_WRITE_MODE gate (see config.ts). Send lands on the court-visible
97
+ // record, so it is 'all'-only; draft-level writes (save/delete drafts,
98
+ // upload attachments) also register under 'drafts'. Read/sync/download
99
+ // tools always register.
100
+ const writeMode = getWriteMode();
101
+ const allowSend = writeMode === 'all';
102
+ const allowDrafts = writeMode !== 'none';
51
103
  server.registerTool('ofw_list_message_folders', {
52
104
  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.',
53
105
  annotations: { readOnlyHint: true },
@@ -60,8 +112,8 @@ export function registerMessageTools(server, client) {
60
112
  annotations: { readOnlyHint: true },
61
113
  inputSchema: {
62
114
  folderId: z.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
63
- page: z.number().describe('Page number (default 1)').optional(),
64
- size: z.number().describe('Messages per page (default 50)').optional(),
115
+ page: z.number().int().min(1).describe('Page number (default 1)').optional(),
116
+ size: z.number().int().min(1).describe('Messages per page (default 50)').optional(),
65
117
  since: z.string().describe('ISO date or datetime — only messages with sent_at >= since (inclusive)').optional(),
66
118
  until: z.string().describe('ISO date or datetime — only messages with sent_at < until (exclusive)').optional(),
67
119
  q: z.string().describe('Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.').optional(),
@@ -141,7 +193,7 @@ export function registerMessageTools(server, client) {
141
193
  // OFW state isn't changing).
142
194
  if (attachments.length === 0 && listDataHintsAtFiles(cached.listData)) {
143
195
  try {
144
- const detail = await client.request('GET', `/pub/v3/messages/${id}`);
196
+ const detail = parseOFW(DetailFilesSchema, await client.request('GET', `/pub/v3/messages/${id}`), 'GET /pub/v3/messages/{id} (attachment backfill)');
145
197
  if (Array.isArray(detail.files) && detail.files.length > 0) {
146
198
  await fetchAttachmentMetaForMessage(client, id, detail.files);
147
199
  attachments = listAttachmentsForMessage(id);
@@ -153,7 +205,7 @@ export function registerMessageTools(server, client) {
153
205
  }
154
206
  return jsonResponse({ ...cached, attachments });
155
207
  }
156
- const detail = await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
208
+ const detail = parseOFW(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`), 'GET /pub/v3/messages/{id} (ofw_get_message)');
157
209
  const folder = cached?.folder ?? 'inbox';
158
210
  const row = {
159
211
  id: detail.id,
@@ -175,127 +227,141 @@ export function registerMessageTools(server, client) {
175
227
  const attachments = listAttachmentsForMessage(detail.id);
176
228
  return jsonResponse({ ...row, attachments });
177
229
  });
178
- server.registerTool('ofw_send_message', {
179
- description: 'Send a message via OurFamilyWizard. To send an existing draft, pass messageId — subject/body/recipientIds become optional overrides (missing fields default to the draft\'s cached values) and the draft is deleted after sending. To send a fresh message, supply subject/body/recipientIds directly. draftId is the legacy spelling of messageId and works the same way. 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. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.',
180
- annotations: { destructiveHint: true },
181
- inputSchema: {
182
- subject: z.string().describe('Message subject. Required unless messageId/draftId references a cached draft.').optional(),
183
- body: z.string().describe('Message body text. Required unless messageId/draftId references a cached draft.').optional(),
184
- recipientIds: z.array(z.number()).describe('Array of recipient user IDs (get from ofw_get_profile). Required unless messageId/draftId references a cached draft.').optional(),
185
- replyToId: z.number().describe('ID of the message being replied to').optional(),
186
- messageId: z.number().describe('ID of an existing draft to send. When set, missing subject/body/recipientIds default to the draft\'s cached values, and the draft is deleted after sending.').optional(),
187
- draftId: z.number().describe('Legacy synonym for messageId. If both are passed they must be equal.').optional(),
188
- myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment) to attach to the message').optional(),
189
- },
190
- }, async (args) => {
191
- if (args.messageId !== undefined && args.draftId !== undefined && args.messageId !== args.draftId) {
192
- throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
193
- }
194
- const draftRef = args.messageId ?? args.draftId;
195
- // Best-effort draft lookup: when draftRef points at a cached draft, use
196
- // its stored fields (including replyToId) as defaults for anything the
197
- // caller didn't supply. The "missing draft" case only matters when we
198
- // actually NEED the defaults — a caller passing all fields explicitly
199
- // can use draftId as a pure delete-target even on an empty cache.
200
- let subject = args.subject;
201
- let body = args.body;
202
- let recipientIds = args.recipientIds;
203
- let draftReplyToId = null;
204
- let draftLookupAttempted = false;
205
- let draftFound = false;
206
- if (draftRef !== undefined) {
207
- draftLookupAttempted = true;
208
- const draft = getDraft(draftRef);
209
- if (draft !== null) {
210
- draftFound = true;
211
- subject = subject ?? draft.subject;
212
- body = body ?? draft.body;
213
- recipientIds = recipientIds ?? draft.recipients.map((r) => r.userId);
214
- draftReplyToId = draft.replyToId;
230
+ if (allowSend)
231
+ server.registerTool('ofw_send_message', {
232
+ description: 'Send a message via OurFamilyWizard. To send an existing draft, pass messageId — subject/body/recipientIds become optional overrides (missing fields default to the draft\'s cached values) and the draft is deleted after sending. To send a fresh message, supply subject/body/recipientIds directly. draftId is the legacy spelling of messageId and works the same way. 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. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.',
233
+ annotations: { destructiveHint: true },
234
+ inputSchema: {
235
+ subject: z.string().describe('Message subject. Required unless messageId/draftId references a cached draft.').optional(),
236
+ body: z.string().describe('Message body text. Required unless messageId/draftId references a cached draft.').optional(),
237
+ recipientIds: z.array(z.number()).describe('Array of recipient user IDs (get from ofw_get_profile). Required unless messageId/draftId references a cached draft.').optional(),
238
+ replyToId: z.number().describe('ID of the message being replied to').optional(),
239
+ messageId: z.number().describe('ID of an existing draft to send. When set, missing subject/body/recipientIds default to the draft\'s cached values, and the draft is deleted after sending.').optional(),
240
+ draftId: z.number().describe('Legacy synonym for messageId. If both are passed they must be equal.').optional(),
241
+ myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment) to attach to the message').optional(),
242
+ },
243
+ }, async (args) => {
244
+ if (args.messageId !== undefined && args.draftId !== undefined && args.messageId !== args.draftId) {
245
+ throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
215
246
  }
216
- }
217
- if (subject === undefined || body === undefined || recipientIds === undefined) {
218
- if (draftLookupAttempted && !draftFound) {
219
- throw new Error(`draft ${draftRef} not found in local cache. Call ofw_sync_messages first, or supply subject/body/recipientIds explicitly.`);
247
+ const draftRef = args.messageId ?? args.draftId;
248
+ // Best-effort draft lookup: when draftRef points at a cached draft, use
249
+ // its stored fields (including replyToId) as defaults for anything the
250
+ // caller didn't supply. The "missing draft" case only matters when we
251
+ // actually NEED the defaults — a caller passing all fields explicitly
252
+ // can use draftId as a pure delete-target even on an empty cache.
253
+ let subject = args.subject;
254
+ let body = args.body;
255
+ let recipientIds = args.recipientIds;
256
+ let draftReplyToId = null;
257
+ let draftLookupAttempted = false;
258
+ let draftFound = false;
259
+ if (draftRef !== undefined) {
260
+ draftLookupAttempted = true;
261
+ const draft = getDraft(draftRef);
262
+ if (draft !== null) {
263
+ draftFound = true;
264
+ subject = subject ?? draft.subject;
265
+ body = body ?? draft.body;
266
+ recipientIds = recipientIds ?? draft.recipients.map((r) => r.userId);
267
+ draftReplyToId = draft.replyToId;
268
+ }
220
269
  }
221
- const missing = [
222
- subject === undefined ? 'subject' : null,
223
- body === undefined ? 'body' : null,
224
- recipientIds === undefined ? 'recipientIds' : null,
225
- ].filter((n) => n !== null).join(', ');
226
- throw new Error(`ofw_send_message requires ${missing}. Pass it directly, or pass messageId to default missing fields from a cached draft.`);
227
- }
228
- // Inherit the draft's replyToId when the caller didn't supply one. A
229
- // reply-draft saved with replyToId would otherwise be sent as a
230
- // top-level message silently losing the thread.
231
- const requestedReplyTo = args.replyToId ?? draftReplyToId ?? null;
232
- let resolvedReplyTo = requestedReplyTo;
233
- let chainRootId = null;
234
- let rewriteNote = null;
235
- if (requestedReplyTo !== null) {
236
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
237
- if (resolvedReplyTo !== requestedReplyTo) {
238
- rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
270
+ if (subject === undefined || body === undefined || recipientIds === undefined) {
271
+ if (draftLookupAttempted && !draftFound) {
272
+ throw new Error(`draft ${draftRef} not found in local cache. Call ofw_sync_messages first, or supply subject/body/recipientIds explicitly.`);
273
+ }
274
+ const missing = [
275
+ subject === undefined ? 'subject' : null,
276
+ body === undefined ? 'body' : null,
277
+ recipientIds === undefined ? 'recipientIds' : null,
278
+ ].filter((n) => n !== null).join(', ');
279
+ throw new Error(`ofw_send_message requires ${missing}. Pass it directly, or pass messageId to default missing fields from a cached draft.`);
239
280
  }
240
- const parent = getMessage(resolvedReplyTo);
241
- chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
242
- }
243
- const myFileIDs = args.myFileIDs ?? [];
244
- const { id: newId, detail, raw } = await postMessageAndRefetch(client, {
245
- subject,
246
- body,
247
- recipientIds,
248
- attachments: { myFileIDs },
249
- draft: false,
250
- includeOriginal: resolvedReplyTo !== null,
251
- replyToId: resolvedReplyTo,
252
- });
253
- let persisted = null;
254
- if (newId !== null) {
255
- persisted = {
256
- id: newId,
257
- folder: 'sent',
258
- subject: detail.subject ?? subject,
259
- fromUser: detail.from?.name ?? '',
260
- sentAt: detail.date?.dateTime ?? new Date().toISOString(),
261
- recipients: mapRecipients(detail.recipients),
262
- body: detail.body ?? body,
263
- fetchedBodyAt: new Date().toISOString(),
281
+ // Inherit the draft's replyToId when the caller didn't supply one. A
282
+ // reply-draft saved with replyToId would otherwise be sent as a
283
+ // top-level message — silently losing the thread.
284
+ const requestedReplyTo = args.replyToId ?? draftReplyToId ?? null;
285
+ let resolvedReplyTo = requestedReplyTo;
286
+ let chainRootId = null;
287
+ let rewriteNote = null;
288
+ if (requestedReplyTo !== null) {
289
+ resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
290
+ if (resolvedReplyTo !== requestedReplyTo) {
291
+ rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
292
+ }
293
+ const parent = getMessage(resolvedReplyTo);
294
+ chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
295
+ }
296
+ const myFileIDs = args.myFileIDs ?? [];
297
+ const { id: newId, detail, raw } = await postMessageAndRefetch(client, {
298
+ subject,
299
+ body,
300
+ recipientIds,
301
+ attachments: { myFileIDs },
302
+ draft: false,
303
+ includeOriginal: resolvedReplyTo !== null,
264
304
  replyToId: resolvedReplyTo,
265
- chainRootId,
266
- listData: detail,
267
- };
268
- upsertMessage(persisted);
269
- // Link attached files to the new message in the attachments cache.
270
- // We may not have full metadata if the upload happened in a prior
271
- // session — fall back to what we know.
272
- for (const fileId of myFileIDs) {
273
- const existing = getAttachment(fileId);
274
- upsertAttachmentForMessage({
275
- fileId,
276
- fileName: existing?.fileName ?? `file-${fileId}`,
277
- label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
278
- mimeType: existing?.mimeType ?? 'application/octet-stream',
279
- sizeBytes: existing?.sizeBytes ?? null,
280
- metadata: existing?.metadata ?? {},
281
- messageId: newId,
282
- });
305
+ }, SentDetailSchema, 'ofw_send_message');
306
+ let persisted = null;
307
+ let verifyNote = null;
308
+ if (newId !== null) {
309
+ verifyNote = verifyWriteLanded('message', { subject, body }, detail);
310
+ persisted = {
311
+ id: newId,
312
+ folder: 'sent',
313
+ subject: detail.subject ?? subject,
314
+ fromUser: detail.from?.name ?? '',
315
+ sentAt: detail.date?.dateTime ?? new Date().toISOString(),
316
+ recipients: mapRecipients(detail.recipients),
317
+ body: detail.body ?? body,
318
+ fetchedBodyAt: new Date().toISOString(),
319
+ replyToId: resolvedReplyTo,
320
+ chainRootId,
321
+ listData: detail,
322
+ };
323
+ upsertMessage(persisted);
324
+ // Link attached files to the new message in the attachments cache.
325
+ // We may not have full metadata if the upload happened in a prior
326
+ // session — fall back to what we know.
327
+ for (const fileId of myFileIDs) {
328
+ const existing = getAttachment(fileId);
329
+ upsertAttachmentForMessage({
330
+ fileId,
331
+ fileName: existing?.fileName ?? `file-${fileId}`,
332
+ label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
333
+ mimeType: existing?.mimeType ?? 'application/octet-stream',
334
+ sizeBytes: existing?.sizeBytes ?? null,
335
+ metadata: existing?.metadata ?? {},
336
+ messageId: newId,
337
+ });
338
+ }
283
339
  }
284
- }
285
- if (draftRef !== undefined) {
286
- await deleteOFWMessages(client, [draftRef]);
287
- deleteDraft(draftRef);
288
- }
289
- const responseObj = persisted ?? raw;
290
- const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
291
- return textResponse(rewriteNote ? `${rewriteNote}\n\n${text}` : text);
292
- });
340
+ // Only clean up the draft once the send is confirmed (the POST response
341
+ // carried an id). On the unconfirmed path the draft is the user's only
342
+ // copy of the message — keep it.
343
+ let unconfirmedNote = null;
344
+ if (newId === null) {
345
+ const draftClause = draftRef !== undefined
346
+ ? `Draft ${draftRef} was NOT deleted check`
347
+ : 'Check';
348
+ unconfirmedNote = `WARNING: OFW's send response did not include a message id, so the send could not be confirmed. ${draftClause} ourfamilywizard.com to see whether the message went out before retrying.`;
349
+ }
350
+ else if (draftRef !== undefined) {
351
+ await deleteOFWMessages(client, [draftRef]);
352
+ deleteDraft(draftRef);
353
+ }
354
+ const responseObj = persisted ?? raw;
355
+ const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
356
+ const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join('\n\n');
357
+ return textResponse(notes ? `${notes}\n\n${text}` : text);
358
+ });
293
359
  server.registerTool('ofw_list_drafts', {
294
360
  description: 'List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.',
295
361
  annotations: { readOnlyHint: true },
296
362
  inputSchema: {
297
- page: z.number().describe('Page number (default 1)').optional(),
298
- size: z.number().describe('Drafts per page (default 50)').optional(),
363
+ page: z.number().int().min(1).describe('Page number (default 1)').optional(),
364
+ size: z.number().int().min(1).describe('Drafts per page (default 50)').optional(),
299
365
  },
300
366
  }, async (args) => {
301
367
  const page = args.page ?? 1;
@@ -306,92 +372,96 @@ export function registerMessageTools(server, client) {
306
372
  : { drafts };
307
373
  return jsonResponse(payload);
308
374
  });
309
- server.registerTool('ofw_save_draft', {
310
- description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft — note that under the hood this creates a NEW draft and deletes the old one (OFW\'s update-in-place endpoint silently no-ops while echoing the posted body, so we don\'t use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. 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. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state.',
311
- annotations: { readOnlyHint: false },
312
- inputSchema: {
313
- subject: z.string().describe('Message subject'),
314
- body: z.string().describe('Message body text'),
315
- recipientIds: z.array(z.number()).describe('Array of recipient user IDs (optional for drafts)').optional(),
316
- messageId: z.number().describe('ID of an existing draft to replace (the new draft will have a new id; the old is deleted)').optional(),
317
- replyToId: z.number().describe('ID of the message this draft replies to').optional(),
318
- myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
319
- },
320
- }, async (args) => {
321
- const requestedReplyTo = args.replyToId ?? null;
322
- let resolvedReplyTo = requestedReplyTo;
323
- let rewriteNote = null;
324
- if (requestedReplyTo !== null) {
325
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
326
- if (resolvedReplyTo !== requestedReplyTo) {
327
- rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
375
+ if (allowDrafts)
376
+ server.registerTool('ofw_save_draft', {
377
+ description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft — note that under the hood this creates a NEW draft and deletes the old one (OFW\'s update-in-place endpoint silently no-ops while echoing the posted body, so we don\'t use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. 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. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state.',
378
+ annotations: { readOnlyHint: false },
379
+ inputSchema: {
380
+ subject: z.string().describe('Message subject'),
381
+ body: z.string().describe('Message body text'),
382
+ recipientIds: z.array(z.number()).describe('Array of recipient user IDs (optional for drafts)').optional(),
383
+ messageId: z.number().describe('ID of an existing draft to replace (the new draft will have a new id; the old is deleted)').optional(),
384
+ replyToId: z.number().describe('ID of the message this draft replies to').optional(),
385
+ myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
386
+ },
387
+ }, async (args) => {
388
+ const requestedReplyTo = args.replyToId ?? null;
389
+ let resolvedReplyTo = requestedReplyTo;
390
+ let rewriteNote = null;
391
+ if (requestedReplyTo !== null) {
392
+ resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
393
+ if (resolvedReplyTo !== requestedReplyTo) {
394
+ rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
395
+ }
328
396
  }
329
- }
330
- const myFileIDs = args.myFileIDs ?? [];
331
- // Deliberately do NOT pass `args.messageId` to OFW's POST payload.
332
- // OFW's update-by-messageId path silently no-ops on subsequent
333
- // updates while echoing the posted body in the immediate GET so
334
- // there is no honest way to detect a failure from the response.
335
- // We always create a fresh draft; if the caller provided a
336
- // messageId, we delete the old draft afterward (the "replace" path).
337
- const payload = {
338
- subject: args.subject,
339
- body: args.body,
340
- recipientIds: args.recipientIds ?? [],
341
- attachments: { myFileIDs },
342
- draft: true,
343
- includeOriginal: resolvedReplyTo !== null,
344
- replyToId: resolvedReplyTo,
345
- };
346
- const { id: newId, detail, raw } = await postMessageAndRefetch(client, payload);
347
- let persisted = null;
348
- let replaceNote = null;
349
- if (newId !== null) {
350
- persisted = {
351
- id: newId,
352
- subject: detail.subject ?? args.subject,
353
- body: detail.body ?? '',
354
- recipients: mapRecipients(detail.recipients),
355
- replyToId: detail.replyToId ?? resolvedReplyTo,
356
- modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
357
- listData: detail,
397
+ const myFileIDs = args.myFileIDs ?? [];
398
+ // Deliberately do NOT pass `args.messageId` to OFW's POST payload.
399
+ // OFW's update-by-messageId path silently no-ops on subsequent
400
+ // updates while echoing the posted body in the immediate GET — so
401
+ // there is no honest way to detect a failure from the response.
402
+ // We always create a fresh draft; if the caller provided a
403
+ // messageId, we delete the old draft afterward (the "replace" path).
404
+ const payload = {
405
+ subject: args.subject,
406
+ body: args.body,
407
+ recipientIds: args.recipientIds ?? [],
408
+ attachments: { myFileIDs },
409
+ draft: true,
410
+ includeOriginal: resolvedReplyTo !== null,
411
+ replyToId: resolvedReplyTo,
358
412
  };
359
- upsertDraft(persisted);
360
- // Replace-path: caller passed messageId, so they want the old draft
361
- // gone. Delete it after the new one is safely created+cached.
362
- if (args.messageId !== undefined && args.messageId !== newId) {
363
- try {
364
- await deleteOFWMessages(client, [args.messageId]);
365
- deleteDraft(args.messageId);
366
- 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.)`;
367
- }
368
- catch (e) {
369
- replaceNote = `WARNING: New draft ${newId} created successfully, but failed to delete the old draft (${args.messageId}): ${e.message}. You may want to clean it up manually with ofw_delete_draft.`;
413
+ const { id: newId, detail, raw } = await postMessageAndRefetch(client, payload, SavedDraftDetailSchema, 'ofw_save_draft');
414
+ let persisted = null;
415
+ let replaceNote = null;
416
+ let verifyNote = null;
417
+ if (newId !== null) {
418
+ verifyNote = verifyWriteLanded('draft', { subject: args.subject, body: args.body }, detail);
419
+ persisted = {
420
+ id: newId,
421
+ subject: detail.subject ?? args.subject,
422
+ body: detail.body ?? '',
423
+ recipients: mapRecipients(detail.recipients),
424
+ replyToId: detail.replyToId ?? resolvedReplyTo,
425
+ modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
426
+ listData: detail,
427
+ };
428
+ upsertDraft(persisted);
429
+ // Replace-path: caller passed messageId, so they want the old draft
430
+ // gone. Delete it after the new one is safely created+cached.
431
+ if (args.messageId !== undefined && args.messageId !== newId) {
432
+ try {
433
+ await deleteOFWMessages(client, [args.messageId]);
434
+ deleteDraft(args.messageId);
435
+ 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.)`;
436
+ }
437
+ catch (e) {
438
+ replaceNote = `WARNING: New draft ${newId} created successfully, but failed to delete the old draft (${args.messageId}): ${e.message}. You may want to clean it up manually with ofw_delete_draft.`;
439
+ }
370
440
  }
371
441
  }
372
- }
373
- const responseObj = persisted ?? raw;
374
- const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
375
- const notes = [rewriteNote, replaceNote].filter((n) => n !== null).join('\n\n');
376
- return textResponse(notes ? `${notes}\n\n${text}` : text);
377
- });
378
- server.registerTool('ofw_delete_draft', {
379
- description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.',
380
- annotations: { destructiveHint: true },
381
- inputSchema: {
382
- messageId: z.number().describe('Draft message ID to delete'),
383
- },
384
- }, async (args) => {
385
- const data = await deleteOFWMessages(client, [args.messageId]);
386
- deleteDraft(args.messageId);
387
- return data ? jsonResponse(data) : textResponse('Draft deleted.');
388
- });
442
+ const responseObj = persisted ?? raw;
443
+ const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
444
+ const notes = [rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join('\n\n');
445
+ return textResponse(notes ? `${notes}\n\n${text}` : text);
446
+ });
447
+ if (allowDrafts)
448
+ server.registerTool('ofw_delete_draft', {
449
+ description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.',
450
+ annotations: { destructiveHint: true },
451
+ inputSchema: {
452
+ messageId: z.number().describe('Draft message ID to delete'),
453
+ },
454
+ }, async (args) => {
455
+ const data = await deleteOFWMessages(client, [args.messageId]);
456
+ deleteDraft(args.messageId);
457
+ return data ? jsonResponse(data) : textResponse('Draft deleted.');
458
+ });
389
459
  server.registerTool('ofw_get_unread_sent', {
390
460
  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.',
391
461
  annotations: { readOnlyHint: true },
392
462
  inputSchema: {
393
- page: z.number().describe('Page (default 1)').optional(),
394
- size: z.number().describe('Per page (default 50)').optional(),
463
+ page: z.number().int().min(1).describe('Page (default 1)').optional(),
464
+ size: z.number().int().min(1).describe('Per page (default 50)').optional(),
395
465
  },
396
466
  }, async (args) => {
397
467
  const page = args.page ?? 1;
@@ -412,53 +482,54 @@ export function registerMessageTools(server, client) {
412
482
  }
413
483
  return jsonResponse(unread);
414
484
  });
415
- server.registerTool('ofw_upload_attachment', {
416
- 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.',
417
- annotations: { destructiveHint: false },
418
- inputSchema: {
419
- path: z.string().describe('Absolute path to the local file to upload. Tilde (~) is expanded.'),
420
- shareClass: z.enum(['PRIVATE', 'SHARED']).describe('Share class (default PRIVATE)').optional(),
421
- label: z.string().describe('Display label for the file in OFW (default: filename)').optional(),
422
- description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
423
- },
424
- }, async (args) => {
425
- const abs = expandPath(args.path);
426
- const stat = statSync(abs); // throws if missing
427
- if (!stat.isFile())
428
- throw new Error(`Not a file: ${abs}`);
429
- const fileName = basename(abs);
430
- const mime = mimeFromName(fileName);
431
- // Build the multipart payload matching the OFW web UI's request shape.
432
- const form = new FormData();
433
- // fileBlob streams the file off disk (a file-backed Blob) instead of buffering it.
434
- form.append('file', await fileBlob(abs, { type: mime }), fileName);
435
- form.append('source', 'message');
436
- form.append('description', args.description ?? fileName);
437
- form.append('label', args.label ?? fileName);
438
- form.append('fileName', fileName);
439
- form.append('shareClass', args.shareClass ?? 'PRIVATE');
440
- const meta = await client.request('POST', '/pub/v3/myfiles/multipart', form);
441
- // Cache metadata so subsequent ofw_get_message calls can surface it and
442
- // ofw_download_attachment can short-circuit. messageId is 0 (the
443
- // not-yet-linked sentinel) until a message actually references this file.
444
- upsertAttachmentForMessage({
445
- fileId: meta.fileId,
446
- fileName: meta.fileName ?? fileName,
447
- label: meta.label ?? args.label ?? fileName,
448
- mimeType: meta.fileType ?? mime,
449
- sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : stat.size,
450
- metadata: meta,
451
- messageId: 0,
452
- });
453
- return jsonResponse({
454
- fileId: meta.fileId,
455
- fileName: meta.fileName ?? fileName,
456
- mimeType: meta.fileType ?? mime,
457
- sizeBytes: meta.sizeInBytes ?? stat.size,
458
- shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
459
- note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
485
+ if (allowDrafts)
486
+ server.registerTool('ofw_upload_attachment', {
487
+ 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.',
488
+ annotations: { destructiveHint: false },
489
+ inputSchema: {
490
+ path: z.string().describe('Absolute path to the local file to upload. Tilde (~) is expanded.'),
491
+ shareClass: z.enum(['PRIVATE', 'SHARED']).describe('Share class (default PRIVATE)').optional(),
492
+ label: z.string().describe('Display label for the file in OFW (default: filename)').optional(),
493
+ description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
494
+ },
495
+ }, async (args) => {
496
+ const abs = expandPath(args.path);
497
+ const stat = statSync(abs); // throws if missing
498
+ if (!stat.isFile())
499
+ throw new Error(`Not a file: ${abs}`);
500
+ const fileName = basename(abs);
501
+ const mime = mimeFromName(fileName);
502
+ // Build the multipart payload matching the OFW web UI's request shape.
503
+ const form = new FormData();
504
+ // fileBlob streams the file off disk (a file-backed Blob) instead of buffering it.
505
+ form.append('file', await fileBlob(abs, { type: mime }), fileName);
506
+ form.append('source', 'message');
507
+ form.append('description', args.description ?? fileName);
508
+ form.append('label', args.label ?? fileName);
509
+ form.append('fileName', fileName);
510
+ form.append('shareClass', args.shareClass ?? 'PRIVATE');
511
+ const meta = parseOFW(UploadedFileSchema, await client.request('POST', '/pub/v3/myfiles/multipart', form), 'POST /pub/v3/myfiles/multipart (ofw_upload_attachment)', 'strict');
512
+ // Cache metadata so subsequent ofw_get_message calls can surface it and
513
+ // ofw_download_attachment can short-circuit. messageId is 0 (the
514
+ // not-yet-linked sentinel) until a message actually references this file.
515
+ upsertAttachmentForMessage({
516
+ fileId: meta.fileId,
517
+ fileName: meta.fileName ?? fileName,
518
+ label: meta.label ?? args.label ?? fileName,
519
+ mimeType: meta.fileType ?? mime,
520
+ sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : stat.size,
521
+ metadata: meta,
522
+ messageId: 0,
523
+ });
524
+ return jsonResponse({
525
+ fileId: meta.fileId,
526
+ fileName: meta.fileName ?? fileName,
527
+ mimeType: meta.fileType ?? mime,
528
+ sizeBytes: meta.sizeInBytes ?? stat.size,
529
+ shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
530
+ note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
531
+ });
460
532
  });
461
- });
462
533
  server.registerTool('ofw_download_attachment', {
463
534
  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).',
464
535
  annotations: { readOnlyHint: false },