ofw-mcp 2.9.2 → 2.10.0

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.
@@ -8,7 +8,7 @@ import { buildInlineDelivery, tryExtract } from './delivery.js';
8
8
  import { resolveDownloadMime } from './attachments.js';
9
9
  import { getAllowMarkRead, getAttachmentsDir, getAutoRefreshStaleReads, getDefaultInlineAttachments, getFetchUnreadBodies, getSyncMaxRequests, getWriteMode, } from '../config.js';
10
10
  import { basename, join } from 'node:path';
11
- import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
11
+ import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, reportsThreaded, reportsUnthreaded, textResponse, threadedReplyTo, verifyWriteLanded, withReadState } from './_shared.js';
12
12
  import { parseLenient } from '@chrischall/mcp-utils';
13
13
  // Schemas for the load-bearing fields of each /pub/v3 response this file
14
14
  // reads (issue #83). Loose: unknown keys pass through into cached listData.
@@ -23,12 +23,24 @@ const SentDetailSchema = z.looseObject({
23
23
  date: DateSchema.optional(),
24
24
  from: z.looseObject({ name: z.string().optional() }).optional(),
25
25
  recipients: z.array(ApiRecipientSchema).optional(),
26
+ // The threading echo, in BOTH spellings plus showContext — OFW reports the
27
+ // reply target inconsistently across payloads (see ThreadingEcho in
28
+ // _shared.ts). Backs the `threaded` verdict on ofw_send_message.
29
+ replyToId: z.number().nullable().optional(),
30
+ inReplyTo: z.number().nullable().optional(),
31
+ showContext: z.boolean().optional(),
26
32
  });
27
33
  const SavedDraftDetailSchema = z.looseObject({
28
34
  subject: z.string().optional(),
29
35
  body: z.string().optional(),
30
36
  date: DateSchema.optional(),
37
+ // All three threading-echo fields. Reading ONLY `replyToId` here fired a
38
+ // false "OurFamilyWizard did not thread this draft" warning on nearly every
39
+ // threaded save, while the same payload's `inReplyTo`/`showContext` showed
40
+ // the draft WAS threaded — see threadedReplyTo in _shared.ts.
31
41
  replyToId: z.number().nullable().optional(),
42
+ inReplyTo: z.number().nullable().optional(),
43
+ showContext: z.boolean().optional(),
32
44
  recipients: z.array(ApiRecipientSchema).optional(),
33
45
  // Read to audit whether requested myFileIDs actually attached (Defect 3).
34
46
  files: z.array(z.number()).optional(),
@@ -357,6 +369,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
357
369
  if (draftRow !== null) {
358
370
  const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
359
371
  return jsonResponse({
372
+ // Stable identity FIRST — the id below changes on every edit
373
+ // (create-then-delete), so callers should key off draftKey. Null when
374
+ // this draft was never written through this tool (e.g. authored in
375
+ // the web app).
376
+ draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
360
377
  id: draftRow.id,
361
378
  folder: 'drafts',
362
379
  subject: draftRow.subject,
@@ -373,13 +390,9 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
373
390
  listData: draftRow.listData,
374
391
  attachments: [],
375
392
  // Concurrency token — pass as expectedRevision to ofw_save_draft /
376
- // ofw_delete_draft to assert you are editing THIS version.
393
+ // ofw_delete_draft / ofw_send_message to assert you are acting on
394
+ // THIS version.
377
395
  revision: draftRevision(draftRow),
378
- // Stable logical identity. Survives the create-then-delete id churn of
379
- // editing AND the transition to sent — pass it to ofw_status to ask
380
- // "what happened to the thing I was working on?". Null when this draft
381
- // was never written through this tool (e.g. authored in the web app).
382
- draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
383
396
  cacheStatus,
384
397
  // False = this draft's existence and unsent status are remembered from
385
398
  // a cache, not confirmed on OFW. Call ofw_check_freshness before
@@ -489,16 +502,19 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
489
502
  });
490
503
  if (allowSend)
491
504
  server.registerTool('ofw_send_message', {
492
- 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.',
505
+ description: 'Send a message via OurFamilyWizard — the ONE irreversible operation here, so it carries the strongest guard. TO SEND AN EXISTING DRAFT (the safe default): pass draftId (or messageId — same thing). The tool re-reads the draft from OFW and sends the SERVER\'S version, so what goes out is what is on OurFamilyWizard, not what this session remembers — subject/body act only as explicit overrides. It is guarded exactly like ofw_save_draft: pass expectedRevision to assert which version you are sending; if the draft changed on OFW since you read it — or no longer exists (it may already have been SENT) — the send is REFUSED with the current server content echoed back, and nothing goes out. RECIPIENTS: OurFamilyWizard does not persist recipients on drafts, so recipientIds is usually still required at send time (ids from ofw_get_profile). After the send is CONFIRMED (OFW returned the new message id and the re-fetched sent record matches what was posted), the source draft is deleted automatically; pass deleteDraftOnSuccess:false to keep it. On ANY failure or ambiguity the draft is never deleted — the response carries draftRetained:true with the reason. TO COMPOSE FROM SCRATCH: supply subject/body/recipientIds with no draftId. If replyToId is provided (or inherited from the draft), the cache may rewrite it to the latest reply in the same thread (a note is included when this happens). ATTACHMENTS: when sending by draftId, the server draft\'s own attachments carry over automatically; myFileIDs (from ofw_upload_attachment) overrides or attaches files on a fresh compose. The response leads with sentMessageId and the stable draftKey, and reports threaded (whether OFW actually linked the reply) and draftDeleted.',
493
506
  annotations: { destructiveHint: true },
494
507
  inputSchema: {
495
- subject: z.string().describe('Message subject. Required unless messageId/draftId references a cached draft.').optional(),
496
- body: z.string().describe('Message body text. Required unless messageId/draftId references a cached draft.').optional(),
497
- 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(),
498
- replyToId: z.number().describe('ID of the message being replied to').optional(),
499
- 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(),
500
- draftId: z.number().describe('Legacy synonym for messageId. If both are passed they must be equal.').optional(),
501
- myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment) to attach to the message').optional(),
508
+ subject: z.string().describe('Message subject. Required unless draftId/messageId is given (then it overrides the server draft\'s subject).').optional(),
509
+ body: z.string().describe('Message body text. Required unless draftId/messageId is given (then it overrides the server draft\'s body — omit it to send exactly what is on OurFamilyWizard).').optional(),
510
+ recipientIds: z.array(z.number()).describe('Array of recipient user IDs (get from ofw_get_profile). Usually required even when sending a draft: OurFamilyWizard does not persist recipients on drafts.').optional(),
511
+ replyToId: z.number().describe('ID of the message being replied to. Defaults to the draft\'s stored reply target when sending by draftId.').optional(),
512
+ draftId: z.number().describe('ID of an existing draft to send. The draft is re-read from OurFamilyWizard and its SERVER content is sent; missing subject/body default from it. Guarded: a draft that changed since you read it, or that was already sent/deleted, refuses rather than sending blind.').optional(),
513
+ messageId: z.number().describe('Synonym for draftId (if both are passed they must be equal).').optional(),
514
+ expectedRevision: z.string().describe('With draftId: the `revision` from ofw_list_drafts / ofw_get_message / ofw_check_freshness for that draft. Asserts you are sending THAT version; if the draft changed on OFW since, the send is refused and the current server content returned. Omit and the tool compares the server against the local cache instead — omitting never means "send whatever is there now".').optional(),
515
+ deleteDraftOnSuccess: z.boolean().describe('Default true. Delete the source draft after — and ONLY after — the send is confirmed (new message id returned and the re-fetched sent record checks out). Set false to keep the draft. On a failed or unverifiable send the draft is ALWAYS kept, regardless of this flag.').optional(),
516
+ force: z.boolean().describe('Default false. Send even when the draft changed on OurFamilyWizard since you read it, or its current state could not be read. Only use after showing the user the conflict.').optional(),
517
+ myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment) to attach to the message. When sending by draftId, omit it to carry the server draft\'s own attachments over; passing it overrides them.').optional(),
502
518
  },
503
519
  }, async (args) => {
504
520
  if (args.messageId !== undefined && args.draftId !== undefined && args.messageId !== args.draftId) {
@@ -506,38 +522,67 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
506
522
  }
507
523
  const draftRef = args.messageId ?? args.draftId;
508
524
  const cache = cacheProvider();
509
- // Best-effort draft lookup: when draftRef points at a cached draft, use
510
- // its stored fields (including replyToId) as defaults for anything the
511
- // caller didn't supply. The "missing draft" case only matters when we
512
- // actually NEED the defaults — a caller passing all fields explicitly
513
- // can use draftId as a pure delete-target even on an empty cache.
525
+ const deleteOnSuccess = args.deleteDraftOnSuccess ?? true;
514
526
  let subject = args.subject;
515
527
  let body = args.body;
516
528
  let recipientIds = args.recipientIds;
517
529
  let draftReplyToId = null;
518
- let draftLookupAttempted = false;
519
- let draftFound = false;
530
+ let guardNote = null;
531
+ let serverDraft;
520
532
  if (draftRef !== undefined) {
521
- draftLookupAttempted = true;
522
- const draft = await cache.getDraft(draftRef);
523
- if (draft !== null) {
524
- draftFound = true;
525
- subject = subject ?? draft.subject;
526
- body = body ?? draft.body;
527
- recipientIds = recipientIds ?? draft.recipients.map((r) => r.userId);
528
- draftReplyToId = draft.replyToId;
533
+ const cachedDraft = await cache.getDraft(draftRef);
534
+ // The guard runs whenever this call would TRUST the draft (a content
535
+ // field defaults from it) or DESTROY it (delete after send). Only a call
536
+ // that overrides every field AND keeps the draft touches nothing that
537
+ // needs guarding. Sending is the one irreversible operation here, so it
538
+ // is never less protected than ofw_save_draft.
539
+ const needsContent = subject === undefined || body === undefined || recipientIds === undefined;
540
+ if (needsContent || deleteOnSuccess) {
541
+ const guard = await guardDestructiveDraftOp({
542
+ cache,
543
+ draftId: draftRef,
544
+ expectedRevision: args.expectedRevision,
545
+ force: args.force ?? false,
546
+ action: 'send',
547
+ });
548
+ if (!guard.ok)
549
+ return guard.response;
550
+ guardNote = guard.note;
551
+ serverDraft = guard.server;
552
+ }
553
+ // Content defaults come from the SERVER draft the guard just read — the
554
+ // point of sending by id is that the artifact sent is the artifact on
555
+ // the server. The cached row is a fallback only for the force paths
556
+ // where the server copy could not be read (or the guard was skipped).
557
+ const base = serverDraft ?? cachedDraft;
558
+ if (base != null) {
559
+ subject = subject ?? base.subject;
560
+ body = body ?? base.body;
561
+ draftReplyToId = base.replyToId;
562
+ }
563
+ if (recipientIds === undefined) {
564
+ // OFW does not persist recipients on drafts (the server copy routinely
565
+ // reports []), so take any NON-EMPTY recipient set we have — server
566
+ // first, then cache — and otherwise require the caller to supply one.
567
+ // Defaulting to [] would "send" to nobody.
568
+ const source = [serverDraft ?? null, cachedDraft].find((s) => s !== null && s !== undefined && s.recipients.some((r) => r.userId !== 0));
569
+ if (source != null) {
570
+ recipientIds = [...new Set(source.recipients.map((r) => r.userId).filter((id) => id !== 0))];
571
+ }
529
572
  }
530
573
  }
531
574
  if (subject === undefined || body === undefined || recipientIds === undefined) {
532
- if (draftLookupAttempted && !draftFound) {
533
- throw new Error(`draft ${draftRef} not found in local cache. Call ofw_sync_messages first, or supply subject/body/recipientIds explicitly.`);
534
- }
535
575
  const missing = [
536
576
  subject === undefined ? 'subject' : null,
537
577
  body === undefined ? 'body' : null,
538
578
  recipientIds === undefined ? 'recipientIds' : null,
539
579
  ].filter((n) => n !== null).join(', ');
540
- throw new Error(`ofw_send_message requires ${missing}. Pass it directly, or pass messageId to default missing fields from a cached draft.`);
580
+ const hint = draftRef === undefined
581
+ ? 'Pass them directly, or pass draftId to send an existing draft.'
582
+ : missing === 'recipientIds'
583
+ ? `Draft ${draftRef} carries no stored recipients — OurFamilyWizard does not persist recipients on drafts, so they must be supplied at send time. Get the co-parent's user id from ofw_get_profile and pass recipientIds.`
584
+ : `Draft ${draftRef}'s content was not readable from OurFamilyWizard or the local cache, so it cannot supply the missing fields. Pass them explicitly.`;
585
+ throw new Error(`ofw_send_message requires ${missing}. ${hint}`);
541
586
  }
542
587
  // Inherit the draft's replyToId when the caller didn't supply one. A
543
588
  // reply-draft saved with replyToId would otherwise be sent as a
@@ -554,7 +599,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
554
599
  const parent = await cache.getMessage(resolvedReplyTo);
555
600
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
556
601
  }
557
- const myFileIDs = args.myFileIDs ?? [];
602
+ // Attachments carry over from the SERVER draft the guard read — sending
603
+ // "the draft as it exists on the server" includes its files, or the send
604
+ // would silently strip them. Explicit myFileIDs still overrides.
605
+ const myFileIDs = args.myFileIDs ?? serverDraft?.files ?? [];
558
606
  const { id: newId, detail, raw } = await postMessageAndRefetch(client, {
559
607
  subject,
560
608
  body,
@@ -567,19 +615,65 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
567
615
  let persisted = null;
568
616
  let verifyNote = null;
569
617
  let sentDraftKey = null;
618
+ let threaded = false;
619
+ let threadNote = null;
570
620
  if (newId !== null) {
571
621
  verifyNote = verifyWriteLanded('message', { subject, body }, detail);
622
+ // Threading verdict, from OFW's own echo on the re-fetched sent record.
623
+ // Both directions demand POSITIVE evidence (see reportsUnthreaded): a
624
+ // bare `replyToId: null` — OFW's normal shape even on threaded items —
625
+ // and a total absence of echo fields are both "not echoed", NOT
626
+ // "dropped". A warning the caller can see is false is a warning it
627
+ // learns to skip.
628
+ const echoed = threadedReplyTo(detail);
629
+ if (resolvedReplyTo === null) {
630
+ threaded = reportsThreaded(detail);
631
+ }
632
+ else if (reportsThreaded(detail)) {
633
+ threaded = true;
634
+ if (echoed !== null && echoed !== resolvedReplyTo) {
635
+ threadNote = `NOTE: the sent message threads to ${echoed}, not the requested ${resolvedReplyTo} — OurFamilyWizard re-targeted the reply within the thread.`;
636
+ }
637
+ }
638
+ else if (reportsUnthreaded(detail)) {
639
+ threaded = false;
640
+ threadNote = `WARNING: the sent message came back UNTHREADED — replyToId ${resolvedReplyTo} was posted but OurFamilyWizard reports no reply linkage on the sent record, so it went out as a new top-level conversation. Verify on ourfamilywizard.com.`;
641
+ }
642
+ else {
643
+ threaded = true;
644
+ }
645
+ // Recipient confirmation is part of "the send is confirmed" (it gates
646
+ // the draft delete below). Only a NON-EMPTY echo can disconfirm: an
647
+ // omitted or empty recipients array is "not echoed" (OFW routinely
648
+ // echoes [] the way it does on drafts), and crying wolf on it would
649
+ // retain the draft after every ordinary send.
650
+ const storedRecipients = mapRecipients(detail.recipients);
651
+ if (Array.isArray(detail.recipients) && detail.recipients.length > 0) {
652
+ const landed = new Set(storedRecipients.map((r) => r.userId));
653
+ const missingRecipients = recipientIds.filter((rid) => !landed.has(rid));
654
+ if (missingRecipients.length > 0) {
655
+ verifyNote = [
656
+ verifyNote,
657
+ `WARNING: the sent record does not list requested recipient id(s) ${missingRecipients.join(', ')}, so the send could not be fully confirmed. Verify on ourfamilywizard.com.`,
658
+ ].filter((n) => n !== null).join('\n\n');
659
+ }
660
+ }
572
661
  persisted = {
573
662
  id: newId,
574
663
  folder: 'sent',
575
664
  subject: detail.subject ?? subject,
576
665
  fromUser: detail.from?.name ?? '',
577
666
  sentAt: detail.date?.dateTime ?? new Date().toISOString(),
578
- recipients: mapRecipients(detail.recipients),
667
+ recipients: storedRecipients,
579
668
  body: detail.body ?? body,
580
669
  fetchedBodyAt: new Date().toISOString(),
581
- replyToId: resolvedReplyTo,
582
- chainRootId,
670
+ // Prefer OFW's own echo of where the reply landed; keep what was
671
+ // posted when OFW echoed nothing (sent rows feed findLatestReplyTip,
672
+ // and a null would break the chain for a message that IS threaded).
673
+ // A positively UNTHREADED send stores null — the chain link OFW says
674
+ // does not exist must not be invented.
675
+ replyToId: threaded ? echoed ?? resolvedReplyTo : null,
676
+ chainRootId: threaded ? chainRootId : null,
583
677
  listData: detail,
584
678
  };
585
679
  await cache.upsertMessage(persisted);
@@ -614,25 +708,66 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
614
708
  });
615
709
  }
616
710
  }
617
- // Only clean up the draft once the send is confirmed (the POST response
618
- // carried an id). On the unconfirmed path the draft is the user's only
619
- // copy of the message — keep it.
711
+ // Clean up the draft ONLY once the send is confirmed: OFW returned the new
712
+ // message id AND the re-fetched sent record raised no verification warning
713
+ // (subject/body landed, requested recipients listed). On any failure or
714
+ // ambiguity the draft is the user's only reliable copy — keep it and say
715
+ // why, never silently.
620
716
  let unconfirmedNote = null;
717
+ let draftDeleted = false;
718
+ let draftRetainedReason = null;
621
719
  if (newId === null) {
622
720
  const draftClause = draftRef !== undefined
623
721
  ? `Draft ${draftRef} was NOT deleted — check`
624
722
  : 'Check';
625
723
  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.`;
724
+ if (draftRef !== undefined) {
725
+ draftRetainedReason = 'the send could not be confirmed (OFW returned no message id), so the draft is your only reliable copy of the message';
726
+ }
626
727
  }
627
728
  else if (draftRef !== undefined) {
628
- await deleteOFWMessages(client, [draftRef]);
629
- await cache.deleteDraft(draftRef);
729
+ if (verifyNote !== null) {
730
+ draftRetainedReason = 'the sent record could not be fully verified against what was posted (see WARNING above) — the draft is kept until you confirm the send on ourfamilywizard.com';
731
+ }
732
+ else if (!deleteOnSuccess) {
733
+ draftRetainedReason = 'deleteDraftOnSuccess:false — kept by request';
734
+ }
735
+ else {
736
+ try {
737
+ await deleteOFWMessages(client, [draftRef]);
738
+ await cache.deleteDraft(draftRef);
739
+ draftDeleted = true;
740
+ }
741
+ catch (e) {
742
+ draftRetainedReason = `the send succeeded but the draft delete failed (${e.message}) — remove it with ofw_delete_draft once you have verified the sent message`;
743
+ }
744
+ }
630
745
  }
746
+ const retainNote = draftRef !== undefined && newId !== null && !draftDeleted
747
+ ? `NOTE: draft ${draftRef} was retained: ${draftRetainedReason}.`
748
+ : null;
749
+ // Leads with the stable identifiers (sentMessageId, draftKey) — the
750
+ // volatile ids and the full sent row follow.
631
751
  const responseObj = persisted === null
632
- ? raw
633
- : { ...persisted, ...(sentDraftKey !== null ? { draftKey: sentDraftKey, previousId: draftRef } : {}) };
752
+ ? (draftRef !== undefined
753
+ ? { sendConfirmed: false, draftDeleted: false, draftRetained: true, draftRetainedReason, raw }
754
+ : raw)
755
+ : {
756
+ sentMessageId: newId,
757
+ draftKey: sentDraftKey,
758
+ threaded,
759
+ ...(draftRef !== undefined
760
+ ? {
761
+ draftDeleted,
762
+ ...(draftDeleted ? {} : { draftRetained: true, draftRetainedReason }),
763
+ previousId: draftRef,
764
+ }
765
+ : {}),
766
+ ...persisted,
767
+ };
634
768
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
635
- const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join('\n\n');
769
+ const notes = [guardNote, rewriteNote, verifyNote, threadNote, unconfirmedNote, retainNote]
770
+ .filter((n) => n !== null).join('\n\n');
636
771
  return textResponse(notes ? `${notes}\n\n${text}` : text);
637
772
  });
638
773
  async function guardDestructiveDraftOp(input) {
@@ -658,7 +793,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
658
793
  // `.message`, which every Error carries.)
659
794
  const reason = e.message;
660
795
  if (force) {
661
- return { ok: true, note: `WARNING: force:true — proceeded with ${action} on draft ${draftId} even though its current state could not be read from OurFamilyWizard (${reason}). Any newer server-side version was destroyed and is NOT recoverable from this response.` };
796
+ return { ok: true, note: `WARNING: force:true — proceeded with ${action} on draft ${draftId} even though its current state could not be read from OurFamilyWizard (${reason}). Any newer server-side version was destroyed and is NOT recoverable from this response.`, server: undefined };
662
797
  }
663
798
  return {
664
799
  ok: false,
@@ -678,7 +813,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
678
813
  const note = verdict.metadataOnly
679
814
  ? `NOTE: draft ${draftId} was treated as current for this ${action}. Since you read it, OurFamilyWizard normalized connector-authored metadata (${verdict.changedFields.join(', ')}); the subject, body and recipients are unchanged, so this is not a conflict.`
680
815
  : null;
681
- return { ok: true, note };
816
+ return { ok: true, note, server };
682
817
  }
683
818
  if (force) {
684
819
  // Loud, and the overwritten content rides along in the response so it is
@@ -690,6 +825,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
690
825
  return {
691
826
  ok: true,
692
827
  note: `WARNING: force:true overrode a ${verdict.verdict} freshness verdict on draft ${draftId}. ${verdict.reason} ${echoed}\n\n${JSON.stringify({ overwrittenServerDraft: server === null ? null : { ...server, revision: draftRevision(server) } }, null, 2)}`,
828
+ server,
693
829
  };
694
830
  }
695
831
  return {
@@ -704,17 +840,41 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
704
840
  };
705
841
  }
706
842
  server.registerTool('ofw_list_drafts', {
707
- description: 'List draft messages from the local OurFamilyWizard cache. Returns an explicit `complete` boolean describing the RESULT SET: true means "these are ALL the drafts on OurFamilyWizard as of freshness.asOf" — check it before saying "you have N drafts". Each draft carries its `draftKey` (stable across the create-then-delete churn of editing) when one is known. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY"); pass autoRefresh:true to sync and answer instead. For a live, one-call answer prefer ofw_status(includeDraftInventory:true).',
843
+ description: 'List draft messages, verified against OurFamilyWizard in ONE call: when the local drafts cache is not verified-fresh, a cheap drafts sync runs first by default (verify:true), so the answer is server-confirmed without a second call. Pass verify:false to answer purely from the cache (no OFW requests). Returns an explicit `complete` boolean describing the RESULT SET: true means "these are ALL the drafts on OurFamilyWizard as of freshness.asOf" — check it before saying "you have N drafts". Each draft carries its `draftKey` (stable across the create-then-delete churn of editing) when one is known. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY"); pass autoRefresh:true to sync and answer instead.',
708
844
  annotations: { readOnlyHint: false },
709
845
  inputSchema: {
710
846
  page: z.number().int().min(1).describe('Page number (default 1)').optional(),
711
847
  size: z.number().int().min(1).describe('Drafts per page (default 50)').optional(),
848
+ verify: z.boolean().describe('Default true: when the drafts cache is not verified-fresh, run a drafts sync first (cheap — one list page plus one detail per draft) so the response is server-confirmed in one call. Set false to serve straight from the local cache with no OFW requests.').optional(),
712
849
  autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
713
850
  },
714
851
  }, async (args) => {
715
852
  const page = args.page ?? 1;
716
853
  const size = args.size ?? 50;
717
854
  const cache = cacheProvider();
855
+ // Auto-verify (default on): drafts change rarely but INVISIBLY — a web-app
856
+ // edit bumps no timestamp — so an aged cache used to answer "unverified,
857
+ // don't state a count" and force a second call. The drafts walk is cheap
858
+ // enough to just run it. Two honesty rules: `autoVerified` is derived from
859
+ // the POST-sync cache status, because a budget-paused walk applies nothing
860
+ // and claiming autoVerified next to serverConfirmed:false would be one
861
+ // payload contradicting itself; and a sync that CANNOT run (OFW/network
862
+ // down) degrades to the honestly-labelled cache answer below rather than
863
+ // turning a previously infallible cache read into a hard error.
864
+ let autoVerified = false;
865
+ let verifyNote = null;
866
+ if (args.verify ?? true) {
867
+ const { cacheStatus } = await draftsFreshness(cache);
868
+ if (cacheStatus !== 'fresh') {
869
+ try {
870
+ await syncAll(client, { folders: ['drafts'], maxRequests: getSyncMaxRequests() }, cache);
871
+ autoVerified = await getDraftsCacheStatus(cache) === 'fresh';
872
+ }
873
+ catch (e) {
874
+ verifyNote = `The automatic drafts verification could not reach OurFamilyWizard (${e.message}). Answering from the local cache — the freshness block below labels its age, and an empty result will still be refused rather than reported as an absence.`;
875
+ }
876
+ }
877
+ }
718
878
  const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
719
879
  client,
720
880
  cache,
@@ -748,7 +908,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
748
908
  freshness: value.freshness,
749
909
  refreshed,
750
910
  remedy: 'Call ofw_sync_messages(folders:["drafts"]) and retry, re-call with autoRefresh:true, or use ofw_status(includeDraftInventory:true) for a single live answer.',
751
- extra: { page, size },
911
+ extra: { page, size, ...(verifyNote !== null ? { verifyNote } : {}) },
752
912
  });
753
913
  }
754
914
  const { drafts, total, freshness, serverConfirmed } = value;
@@ -771,11 +931,17 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
771
931
  if (refreshed) {
772
932
  payload.autoRefreshed = true;
773
933
  }
934
+ if (autoVerified) {
935
+ payload.autoVerified = true;
936
+ }
937
+ if (verifyNote !== null) {
938
+ payload.verifyNote = verifyNote;
939
+ }
774
940
  return jsonResponse(payload);
775
941
  });
776
942
  if (allowDrafts)
777
943
  server.registerTool('ofw_save_draft', {
778
- 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 that also lists which fields (subject/body/recipients/replyToId/attachments) were carried over. 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, and the returned `revision` reflects that authoritative state (so it will match on your next edit). FIELD PRESERVATION: the response echoes the effective threading (replyToId/inReplyTo) and, whenever OFW did not carry over a requested replyToId, recipient or attachment, a `warnings[]` entry naming what was dropped — never a silent null. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody — merge your edit into it and retry with expectedRevision.',
944
+ description: 'Save a message as a draft in OurFamilyWizard. RECIPIENTS: OurFamilyWizard does NOT persist recipients on drafts — recipientIds are accepted but the saved draft comes back with none (documented OFW behavior, noted once in the response, not warned about; supply recipientIds at send time instead). IDENTITY: the response leads with `draftKey`, the stable identity that survives editing — key off it, because the `id` changes on EVERY edit (replacing a draft creates a NEW draft and deletes the old one; OFW\'s update-in-place endpoint silently no-ops, so we never use it). Pass messageId to replace an existing draft; the response.id will be the NEW id, and a transparency NOTE documents the swap and which fields were carried over. THREADING: if replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included). The threading verdict is read from OFW\'s full echo (replyToId/inReplyTo/showContext) — a warning appears ONLY when the reply linkage was genuinely dropped or re-targeted, and the response\'s top-level replyToId/inReplyTo always agree with its listData. Attach files via myFileIDs (from ofw_upload_attachment). After saving, the tool re-fetches the draft from OFW, and the returned `revision` reflects that authoritative state (so it will match on your next edit). SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody — merge your edit into it and retry with expectedRevision.',
779
945
  annotations: { readOnlyHint: false },
780
946
  inputSchema: {
781
947
  subject: z.string().describe('Message subject'),
@@ -833,6 +999,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
833
999
  let persisted = null;
834
1000
  let replaceNote = null;
835
1001
  let verifyNote = null;
1002
+ let recipientsNote = null;
836
1003
  let newRevision = null;
837
1004
  let draftKey = null;
838
1005
  // Fields accepted on the write that the saved draft must carry — or their
@@ -845,9 +1012,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
845
1012
  // normalizes/drops threading after a save, and masking that with our own
846
1013
  // intent (the old `detail.replyToId ?? resolvedReplyTo`) both returned a
847
1014
  // revision that was stale on arrival (Defect 1) and hid a dropped reply
848
- // link (Defect 3). `?? null` keeps a genuinely-echoed value and reflects a
849
- // null/absent one honestly.
850
- const effectiveReplyTo = detail.replyToId ?? null;
1015
+ // link (Defect 3). The echo is read via threadedReplyTo because OFW
1016
+ // reports the target as `inReplyTo` on payloads where `replyToId` is
1017
+ // null — reading only `replyToId` fired a false "did not thread" warning
1018
+ // on nearly every threaded save while the same response's listData
1019
+ // showed inReplyTo populated.
1020
+ const effectiveReplyTo = threadedReplyTo(detail);
851
1021
  const storedRecipients = mapRecipients(detail.recipients);
852
1022
  persisted = {
853
1023
  id: newId,
@@ -892,26 +1062,40 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
892
1062
  });
893
1063
  // Audit every field the caller supplied against what actually landed, so a
894
1064
  // silent normalization becomes a visible warning rather than a surprise.
895
- if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
1065
+ // The verdict comes from the FULL threading echo (replyToId, inReplyTo,
1066
+ // showContext) — a draft reporting inReplyTo (or showContext:true) IS
1067
+ // threaded, and warning about it anyway is the false positive that
1068
+ // trained callers to skim warnings. The "dropped" warning demands
1069
+ // POSITIVE evidence (reportsUnthreaded): a bare `replyToId: null` or a
1070
+ // detail omitting every echo field is "not echoed", never "dropped".
1071
+ // The stored replyToId is still the server echo (null when unreported) —
1072
+ // masking it with intent was Defect 1, and the revision must hash what
1073
+ // the next sync will read.
1074
+ if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo && reportsUnthreaded(detail)) {
1075
+ const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : '';
1076
+ warnings.push(`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId null — OurFamilyWizard did not thread this draft (its inReplyTo/showContext are empty). The subject and body were saved; only the reply linkage was dropped. If threading matters, verify on ourfamilywizard.com.`);
1077
+ }
1078
+ else if (resolvedReplyTo !== null && effectiveReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
1079
+ // OFW RE-TARGETED the link to another message in the thread — the
1080
+ // draft IS threaded, just not to the requested id, and the inReplyTo
1081
+ // in this response reflects where it actually landed.
896
1082
  const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : '';
897
- // Two different outcomes reach this branch, and they need different
898
- // warnings. OFW either DROPPED the link (null) or RE-TARGETED it to
899
- // another message in the thread. Describing both as "did not thread
900
- // this draft (its inReplyTo/showContext will be empty)" contradicted
901
- // the non-null inReplyTo the same response echoes — and a warning the
902
- // caller can see is false is a warning it learns to skip.
903
- const outcome = effectiveReplyTo === null
904
- ? 'OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped.'
905
- : `OurFamilyWizard re-targeted the reply to message ${effectiveReplyTo} instead. The draft IS threaded — to that message, not the one requested — and the inReplyTo in this response reflects where it actually landed.`;
906
- warnings.push(`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? 'null' : effectiveReplyTo} — ${outcome} If threading matters, verify on ourfamilywizard.com.`);
1083
+ warnings.push(`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo} — OurFamilyWizard re-targeted the reply to message ${effectiveReplyTo} instead. The draft IS threaded — to that message, not the one requested. If threading matters, verify on ourfamilywizard.com.`);
907
1084
  }
908
- // Only warn on recipients/attachments when the detail actually reported
909
- // them — an omitted array is "not echoed", not "dropped", and crying wolf
910
- // there would desensitize the caller to the real drops.
911
- if (args.recipientIds !== undefined && Array.isArray(detail.recipients)) {
1085
+ // Recipients: OFW does NOT persist recipients on drafts — the saved copy
1086
+ // routinely comes back with [] no matter what was posted. That is
1087
+ // documented OFW behavior (and doubles as an accidental-send guard), so
1088
+ // it gets a one-line NOTE, not a per-call WARNING that cries wolf on
1089
+ // every save. A PARTIAL echo (some stored, but not what was asked) is a
1090
+ // genuine drop and still warns. Both only when the detail actually
1091
+ // reported recipients — an omitted array is "not echoed", not "dropped".
1092
+ if (args.recipientIds !== undefined && args.recipientIds.length > 0 && Array.isArray(detail.recipients)) {
912
1093
  const requested = [...new Set(args.recipientIds)].sort((a, b) => a - b);
913
1094
  const stored = [...new Set(storedRecipients.map((r) => r.userId))].sort((a, b) => a - b);
914
- if (requested.join(',') !== stored.join(',')) {
1095
+ if (stored.length === 0) {
1096
+ recipientsNote = 'NOTE: OurFamilyWizard does not persist recipients on drafts — the recipientIds you passed were accepted but are not stored on the draft (documented OFW behavior, not an error; it also means a draft cannot be sent by accident). Supply recipientIds when you send: ofw_send_message requires them when the draft carries none.';
1097
+ }
1098
+ else if (requested.join(',') !== stored.join(',')) {
915
1099
  warnings.push(`recipientIds were requested as [${requested.join(', ')}] but the saved draft has [${stored.join(', ')}]. Verify the recipients on ourfamilywizard.com.`);
916
1100
  }
917
1101
  }
@@ -928,7 +1112,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
928
1112
  try {
929
1113
  await deleteOFWMessages(client, [args.messageId]);
930
1114
  await cache.deleteDraft(args.messageId);
931
- 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.) Fields carried over to the new draft: subject, body, recipients (${persisted.recipients.length}), replyToId (${persisted.replyToId === null ? 'none' : persisted.replyToId}), attachments (${myFileIDs.length}).${warnings.length > 0 ? ' See warnings above for any field OurFamilyWizard did not carry over.' : ''}`;
1115
+ 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. The draftKey is UNCHANGED — key off it rather than the volatile id, which changes on every edit. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it.) Fields carried over to the new draft: subject, body, recipients (${persisted.recipients.length}), replyToId (${persisted.replyToId === null ? 'none' : persisted.replyToId}), attachments (${myFileIDs.length}).${warnings.length > 0 ? ' See warnings above for any field OurFamilyWizard did not carry over.' : ''}`;
932
1116
  }
933
1117
  catch (e) {
934
1118
  // Partial-failure safety: the new draft is already created and
@@ -940,28 +1124,29 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
940
1124
  }
941
1125
  // The draft was just re-fetched from OFW by postMessageAndRefetch, so this
942
1126
  // one row IS server-confirmed regardless of the drafts folder's overall
943
- // cache freshness. `inReplyTo` echoes the effective threading alongside the
944
- // volatile `id`, and `warnings` names any requested field that did not land.
1127
+ // cache freshness. The response LEADS with `draftKey` (stable across the
1128
+ // create-then-delete id churn of editing — key off it, not `id`) and the
1129
+ // `revision` concurrency token; the volatile `id` is carried further down
1130
+ // as an implementation detail. `inReplyTo` echoes the effective threading,
1131
+ // and `warnings` names any requested field that did not land.
945
1132
  const responseObj = persisted !== null
946
1133
  ? {
1134
+ draftKey,
1135
+ revision: newRevision,
947
1136
  ...persisted,
948
1137
  inReplyTo: persisted.replyToId,
949
- revision: newRevision,
950
- // The id above is volatile — it changes on every edit. `draftKey` is
951
- // not: pass it to ofw_status to resolve the chain's CURRENT id, or to
952
- // find out that the draft was sent and when.
953
- draftKey,
954
1138
  previousId: args.messageId ?? null,
955
1139
  cacheStatus: 'fresh',
956
1140
  serverConfirmed: true,
957
1141
  ...(warnings.length > 0 ? { warnings } : {}),
1142
+ ...(recipientsNote !== null ? { recipientsNote } : {}),
958
1143
  }
959
1144
  : raw;
960
1145
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
961
1146
  const warnNote = warnings.length > 0
962
1147
  ? `WARNING: ${warnings.join('\n\n')}`
963
1148
  : null;
964
- const notes = [forceNote, rewriteNote, verifyNote, warnNote, replaceNote]
1149
+ const notes = [forceNote, rewriteNote, verifyNote, warnNote, recipientsNote, replaceNote]
965
1150
  .filter((n) => n !== null).join('\n\n');
966
1151
  return textResponse(notes ? `${notes}\n\n${text}` : text);
967
1152
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.9.2",
3
+ "version": "2.10.0",
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)",
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.9.2",
9
+ "version": "2.10.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.9.2",
14
+ "version": "2.10.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },