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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "OurFamilyWizard tools for Claude Code",
9
- "version": "2.9.2"
9
+ "version": "2.10.0"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -14,7 +14,7 @@
14
14
  "displayName": "OurFamilyWizard",
15
15
  "source": "./",
16
16
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
17
- "version": "2.9.2",
17
+ "version": "2.10.0",
18
18
  "author": {
19
19
  "name": "Chris Chall"
20
20
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ofw",
3
3
  "displayName": "OurFamilyWizard",
4
- "version": "2.9.2",
4
+ "version": "2.10.0",
5
5
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
6
6
  "author": {
7
7
  "name": "Chris Chall"
package/dist/bundle.js CHANGED
@@ -38590,7 +38590,7 @@ async function loginWithPassword(username, password) {
38590
38590
  // package.json
38591
38591
  var package_default = {
38592
38592
  name: "ofw-mcp",
38593
- version: "2.9.2",
38593
+ version: "2.10.0",
38594
38594
  license: "MIT",
38595
38595
  mcpName: "io.github.chrischall/ofw-mcp",
38596
38596
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -39099,6 +39099,16 @@ function mapRecipients(items) {
39099
39099
  function hasRealView(recipients) {
39100
39100
  return recipients.some((r) => r.viewedAt !== null && !r.viewedAt.startsWith("1970-01-01"));
39101
39101
  }
39102
+ function threadedReplyTo(detail) {
39103
+ return detail.replyToId ?? detail.inReplyTo ?? null;
39104
+ }
39105
+ function reportsThreaded(detail) {
39106
+ return threadedReplyTo(detail) !== null || detail.showContext === true;
39107
+ }
39108
+ function reportsUnthreaded(detail) {
39109
+ if (reportsThreaded(detail)) return false;
39110
+ return detail.inReplyTo !== void 0 || detail.showContext !== void 0;
39111
+ }
39102
39112
  function scrapeSaysRead(listData) {
39103
39113
  if (typeof listData !== "object" || listData === null) return false;
39104
39114
  const ld = listData;
@@ -39413,7 +39423,12 @@ var DraftListItemSchema = external_exports.looseObject({
39413
39423
  id: external_exports.number(),
39414
39424
  subject: external_exports.string(),
39415
39425
  date: external_exports.looseObject({ dateTime: external_exports.string() }),
39426
+ // Both spellings of the threading echo — OFW reports the reply target as
39427
+ // `inReplyTo` (with showContext) on list payloads where `replyToId` is null.
39428
+ // The cached row must derive the SAME value ofw_save_draft derived from the
39429
+ // detail, or the content revision drifts between a save and the next sync.
39416
39430
  replyToId: external_exports.number().nullable().optional(),
39431
+ inReplyTo: external_exports.number().nullable().optional(),
39417
39432
  recipients: external_exports.array(ApiRecipientSchema).optional()
39418
39433
  });
39419
39434
  var DraftListResponseSchema = external_exports.looseObject({ data: external_exports.array(DraftListItemSchema).optional() });
@@ -39476,7 +39491,7 @@ async function syncDrafts(client2, draftsFolderId, store, budget) {
39476
39491
  subject: detail.subject ?? item.subject ?? "(no subject)",
39477
39492
  body: detail.body ?? "",
39478
39493
  recipients: mapRecipients(item.recipients),
39479
- replyToId: item.replyToId ?? null,
39494
+ replyToId: threadedReplyTo(item),
39480
39495
  modifiedAt: item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
39481
39496
  listData: item
39482
39497
  });
@@ -39777,8 +39792,18 @@ function draftRevision(d) {
39777
39792
  var ServerDraftSchema = external_exports.looseObject({
39778
39793
  subject: external_exports.string().optional(),
39779
39794
  body: external_exports.string().optional(),
39795
+ // BOTH spellings of the threading echo (see ThreadingEcho in _shared.ts):
39796
+ // OFW reports the reply target as `replyToId` on some payloads and as
39797
+ // `inReplyTo` on others. The snapshot derives one value from whichever is
39798
+ // present, so the revision hashed here matches the one ofw_save_draft
39799
+ // computed from the same server state — a one-sided read produced revisions
39800
+ // that disagreed about the same draft.
39780
39801
  replyToId: external_exports.number().nullable().optional(),
39802
+ inReplyTo: external_exports.number().nullable().optional(),
39781
39803
  recipients: external_exports.array(ApiRecipientSchema).optional(),
39804
+ // Attachment fileIds — read so send-by-draft carries the draft's
39805
+ // attachments onto the sent message (see DraftContent.files).
39806
+ files: external_exports.array(external_exports.number()).optional(),
39782
39807
  // Read for the LIFECYCLE answer (see tools/lifecycle.ts): which folder OFW
39783
39808
  // itself says this id lives in right now. `existsOnServer` alone cannot
39784
39809
  // distinguish "still a draft" from "was sent" — a sent draft still exists.
@@ -39821,8 +39846,9 @@ async function fetchMessageSnapshot(client2, id) {
39821
39846
  content: {
39822
39847
  subject: detail.subject ?? "",
39823
39848
  body: detail.body ?? "",
39824
- replyToId: detail.replyToId ?? null,
39825
- recipients: mapRecipients(detail.recipients)
39849
+ replyToId: threadedReplyTo(detail),
39850
+ recipients: mapRecipients(detail.recipients),
39851
+ ...detail.files !== void 0 ? { files: detail.files } : {}
39826
39852
  },
39827
39853
  folderId: detail.folder?.id === void 0 ? null : String(detail.folder.id),
39828
39854
  folderName: detail.folder?.name ?? null,
@@ -39952,13 +39978,24 @@ async function ensureFolderIdMap(client2, store) {
39952
39978
  return { map: cached2, requests: 1 };
39953
39979
  }
39954
39980
  }
39981
+ var STATE_BY_FOLDER_NAME = /* @__PURE__ */ new Map([
39982
+ ["drafts", "draft"],
39983
+ ["sent", "sent"],
39984
+ ["sent messages", "sent"],
39985
+ ["inbox", "received"]
39986
+ ]);
39955
39987
  function classifyState(snapshot, map2) {
39956
39988
  if (snapshot === null) return "deleted";
39957
- const { folderId } = snapshot;
39958
- if (folderId === null) return "unknown";
39959
- if (map2.drafts !== null && folderId === map2.drafts) return "draft";
39960
- if (map2.sent !== null && folderId === map2.sent) return "sent";
39961
- if (map2.inbox !== null && folderId === map2.inbox) return "received";
39989
+ const { folderId, folderName } = snapshot;
39990
+ if (folderId !== null) {
39991
+ if (map2.drafts !== null && folderId === map2.drafts) return "draft";
39992
+ if (map2.sent !== null && folderId === map2.sent) return "sent";
39993
+ if (map2.inbox !== null && folderId === map2.inbox) return "received";
39994
+ }
39995
+ if (folderName !== null) {
39996
+ const byName = STATE_BY_FOLDER_NAME.get(folderName.trim().toLowerCase());
39997
+ if (byName !== void 0) return byName;
39998
+ }
39962
39999
  return "unknown";
39963
40000
  }
39964
40001
  function probeWouldStamp(cachedDraft, cachedMessage) {
@@ -41100,13 +41137,25 @@ var SentDetailSchema = external_exports.looseObject({
41100
41137
  body: external_exports.string().optional(),
41101
41138
  date: DateSchema.optional(),
41102
41139
  from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
41103
- recipients: external_exports.array(ApiRecipientSchema).optional()
41140
+ recipients: external_exports.array(ApiRecipientSchema).optional(),
41141
+ // The threading echo, in BOTH spellings plus showContext — OFW reports the
41142
+ // reply target inconsistently across payloads (see ThreadingEcho in
41143
+ // _shared.ts). Backs the `threaded` verdict on ofw_send_message.
41144
+ replyToId: external_exports.number().nullable().optional(),
41145
+ inReplyTo: external_exports.number().nullable().optional(),
41146
+ showContext: external_exports.boolean().optional()
41104
41147
  });
41105
41148
  var SavedDraftDetailSchema = external_exports.looseObject({
41106
41149
  subject: external_exports.string().optional(),
41107
41150
  body: external_exports.string().optional(),
41108
41151
  date: DateSchema.optional(),
41152
+ // All three threading-echo fields. Reading ONLY `replyToId` here fired a
41153
+ // false "OurFamilyWizard did not thread this draft" warning on nearly every
41154
+ // threaded save, while the same payload's `inReplyTo`/`showContext` showed
41155
+ // the draft WAS threaded — see threadedReplyTo in _shared.ts.
41109
41156
  replyToId: external_exports.number().nullable().optional(),
41157
+ inReplyTo: external_exports.number().nullable().optional(),
41158
+ showContext: external_exports.boolean().optional(),
41110
41159
  recipients: external_exports.array(ApiRecipientSchema).optional(),
41111
41160
  // Read to audit whether requested myFileIDs actually attached (Defect 3).
41112
41161
  files: external_exports.array(external_exports.number()).optional()
@@ -41305,6 +41354,11 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41305
41354
  if (draftRow !== null) {
41306
41355
  const { freshness: freshness2, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
41307
41356
  return jsonResponse({
41357
+ // Stable identity FIRST — the id below changes on every edit
41358
+ // (create-then-delete), so callers should key off draftKey. Null when
41359
+ // this draft was never written through this tool (e.g. authored in
41360
+ // the web app).
41361
+ draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
41308
41362
  id: draftRow.id,
41309
41363
  folder: "drafts",
41310
41364
  subject: draftRow.subject,
@@ -41321,13 +41375,9 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41321
41375
  listData: draftRow.listData,
41322
41376
  attachments: [],
41323
41377
  // Concurrency token — pass as expectedRevision to ofw_save_draft /
41324
- // ofw_delete_draft to assert you are editing THIS version.
41378
+ // ofw_delete_draft / ofw_send_message to assert you are acting on
41379
+ // THIS version.
41325
41380
  revision: draftRevision(draftRow),
41326
- // Stable logical identity. Survives the create-then-delete id churn of
41327
- // editing AND the transition to sent — pass it to ofw_status to ask
41328
- // "what happened to the thing I was working on?". Null when this draft
41329
- // was never written through this tool (e.g. authored in the web app).
41330
- draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
41331
41381
  cacheStatus,
41332
41382
  // False = this draft's existence and unsent status are remembered from
41333
41383
  // a cache, not confirmed on OFW. Call ofw_check_freshness before
@@ -41410,16 +41460,19 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41410
41460
  return jsonResponse({ ...withReadState(row), attachments, freshness });
41411
41461
  });
41412
41462
  if (allowSend) server.registerTool("ofw_send_message", {
41413
- description: "Send a message via OurFamilyWizard. To send an existing draft, pass messageId \u2014 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.",
41463
+ description: "Send a message via OurFamilyWizard \u2014 the ONE irreversible operation here, so it carries the strongest guard. TO SEND AN EXISTING DRAFT (the safe default): pass draftId (or messageId \u2014 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 \u2014 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 \u2014 or no longer exists (it may already have been SENT) \u2014 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 \u2014 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.",
41414
41464
  annotations: { destructiveHint: true },
41415
41465
  inputSchema: {
41416
- subject: external_exports.string().describe("Message subject. Required unless messageId/draftId references a cached draft.").optional(),
41417
- body: external_exports.string().describe("Message body text. Required unless messageId/draftId references a cached draft.").optional(),
41418
- recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (get from ofw_get_profile). Required unless messageId/draftId references a cached draft.").optional(),
41419
- replyToId: external_exports.number().describe("ID of the message being replied to").optional(),
41420
- messageId: external_exports.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(),
41421
- draftId: external_exports.number().describe("Legacy synonym for messageId. If both are passed they must be equal.").optional(),
41422
- myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment) to attach to the message").optional()
41466
+ subject: external_exports.string().describe("Message subject. Required unless draftId/messageId is given (then it overrides the server draft's subject).").optional(),
41467
+ body: external_exports.string().describe("Message body text. Required unless draftId/messageId is given (then it overrides the server draft's body \u2014 omit it to send exactly what is on OurFamilyWizard).").optional(),
41468
+ recipientIds: external_exports.array(external_exports.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(),
41469
+ replyToId: external_exports.number().describe("ID of the message being replied to. Defaults to the draft's stored reply target when sending by draftId.").optional(),
41470
+ draftId: external_exports.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(),
41471
+ messageId: external_exports.number().describe("Synonym for draftId (if both are passed they must be equal).").optional(),
41472
+ expectedRevision: external_exports.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 \u2014 omitting never means "send whatever is there now".').optional(),
41473
+ deleteDraftOnSuccess: external_exports.boolean().describe("Default true. Delete the source draft after \u2014 and ONLY after \u2014 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(),
41474
+ force: external_exports.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(),
41475
+ myFileIDs: external_exports.array(external_exports.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()
41423
41476
  }
41424
41477
  }, async (args) => {
41425
41478
  if (args.messageId !== void 0 && args.draftId !== void 0 && args.messageId !== args.draftId) {
@@ -41427,37 +41480,51 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41427
41480
  }
41428
41481
  const draftRef = args.messageId ?? args.draftId;
41429
41482
  const cache = cacheProvider();
41483
+ const deleteOnSuccess = args.deleteDraftOnSuccess ?? true;
41430
41484
  let subject = args.subject;
41431
41485
  let body = args.body;
41432
41486
  let recipientIds = args.recipientIds;
41433
41487
  let draftReplyToId = null;
41434
- let draftLookupAttempted = false;
41435
- let draftFound = false;
41488
+ let guardNote = null;
41489
+ let serverDraft;
41436
41490
  if (draftRef !== void 0) {
41437
- draftLookupAttempted = true;
41438
- const draft = await cache.getDraft(draftRef);
41439
- if (draft !== null) {
41440
- draftFound = true;
41441
- subject = subject ?? draft.subject;
41442
- body = body ?? draft.body;
41443
- recipientIds = recipientIds ?? draft.recipients.map((r) => r.userId);
41444
- draftReplyToId = draft.replyToId;
41491
+ const cachedDraft = await cache.getDraft(draftRef);
41492
+ const needsContent = subject === void 0 || body === void 0 || recipientIds === void 0;
41493
+ if (needsContent || deleteOnSuccess) {
41494
+ const guard = await guardDestructiveDraftOp({
41495
+ cache,
41496
+ draftId: draftRef,
41497
+ expectedRevision: args.expectedRevision,
41498
+ force: args.force ?? false,
41499
+ action: "send"
41500
+ });
41501
+ if (!guard.ok) return guard.response;
41502
+ guardNote = guard.note;
41503
+ serverDraft = guard.server;
41504
+ }
41505
+ const base = serverDraft ?? cachedDraft;
41506
+ if (base != null) {
41507
+ subject = subject ?? base.subject;
41508
+ body = body ?? base.body;
41509
+ draftReplyToId = base.replyToId;
41510
+ }
41511
+ if (recipientIds === void 0) {
41512
+ const source = [serverDraft ?? null, cachedDraft].find(
41513
+ (s) => s !== null && s !== void 0 && s.recipients.some((r) => r.userId !== 0)
41514
+ );
41515
+ if (source != null) {
41516
+ recipientIds = [...new Set(source.recipients.map((r) => r.userId).filter((id) => id !== 0))];
41517
+ }
41445
41518
  }
41446
41519
  }
41447
41520
  if (subject === void 0 || body === void 0 || recipientIds === void 0) {
41448
- if (draftLookupAttempted && !draftFound) {
41449
- throw new Error(
41450
- `draft ${draftRef} not found in local cache. Call ofw_sync_messages first, or supply subject/body/recipientIds explicitly.`
41451
- );
41452
- }
41453
41521
  const missing = [
41454
41522
  subject === void 0 ? "subject" : null,
41455
41523
  body === void 0 ? "body" : null,
41456
41524
  recipientIds === void 0 ? "recipientIds" : null
41457
41525
  ].filter((n) => n !== null).join(", ");
41458
- throw new Error(
41459
- `ofw_send_message requires ${missing}. Pass it directly, or pass messageId to default missing fields from a cached draft.`
41460
- );
41526
+ const hint = draftRef === void 0 ? "Pass them directly, or pass draftId to send an existing draft." : missing === "recipientIds" ? `Draft ${draftRef} carries no stored recipients \u2014 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.` : `Draft ${draftRef}'s content was not readable from OurFamilyWizard or the local cache, so it cannot supply the missing fields. Pass them explicitly.`;
41527
+ throw new Error(`ofw_send_message requires ${missing}. ${hint}`);
41461
41528
  }
41462
41529
  const requestedReplyTo = args.replyToId ?? draftReplyToId ?? null;
41463
41530
  let resolvedReplyTo = requestedReplyTo;
@@ -41471,7 +41538,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41471
41538
  const parent = await cache.getMessage(resolvedReplyTo);
41472
41539
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
41473
41540
  }
41474
- const myFileIDs = args.myFileIDs ?? [];
41541
+ const myFileIDs = args.myFileIDs ?? serverDraft?.files ?? [];
41475
41542
  const { id: newId, detail, raw } = await postMessageAndRefetch(client2, {
41476
41543
  subject,
41477
41544
  body,
@@ -41484,19 +41551,51 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41484
41551
  let persisted = null;
41485
41552
  let verifyNote = null;
41486
41553
  let sentDraftKey = null;
41554
+ let threaded = false;
41555
+ let threadNote = null;
41487
41556
  if (newId !== null) {
41488
41557
  verifyNote = verifyWriteLanded("message", { subject, body }, detail);
41558
+ const echoed = threadedReplyTo(detail);
41559
+ if (resolvedReplyTo === null) {
41560
+ threaded = reportsThreaded(detail);
41561
+ } else if (reportsThreaded(detail)) {
41562
+ threaded = true;
41563
+ if (echoed !== null && echoed !== resolvedReplyTo) {
41564
+ threadNote = `NOTE: the sent message threads to ${echoed}, not the requested ${resolvedReplyTo} \u2014 OurFamilyWizard re-targeted the reply within the thread.`;
41565
+ }
41566
+ } else if (reportsUnthreaded(detail)) {
41567
+ threaded = false;
41568
+ threadNote = `WARNING: the sent message came back UNTHREADED \u2014 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.`;
41569
+ } else {
41570
+ threaded = true;
41571
+ }
41572
+ const storedRecipients = mapRecipients(detail.recipients);
41573
+ if (Array.isArray(detail.recipients) && detail.recipients.length > 0) {
41574
+ const landed = new Set(storedRecipients.map((r) => r.userId));
41575
+ const missingRecipients = recipientIds.filter((rid) => !landed.has(rid));
41576
+ if (missingRecipients.length > 0) {
41577
+ verifyNote = [
41578
+ verifyNote,
41579
+ `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.`
41580
+ ].filter((n) => n !== null).join("\n\n");
41581
+ }
41582
+ }
41489
41583
  persisted = {
41490
41584
  id: newId,
41491
41585
  folder: "sent",
41492
41586
  subject: detail.subject ?? subject,
41493
41587
  fromUser: detail.from?.name ?? "",
41494
41588
  sentAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
41495
- recipients: mapRecipients(detail.recipients),
41589
+ recipients: storedRecipients,
41496
41590
  body: detail.body ?? body,
41497
41591
  fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
41498
- replyToId: resolvedReplyTo,
41499
- chainRootId,
41592
+ // Prefer OFW's own echo of where the reply landed; keep what was
41593
+ // posted when OFW echoed nothing (sent rows feed findLatestReplyTip,
41594
+ // and a null would break the chain for a message that IS threaded).
41595
+ // A positively UNTHREADED send stores null — the chain link OFW says
41596
+ // does not exist must not be invented.
41597
+ replyToId: threaded ? echoed ?? resolvedReplyTo : null,
41598
+ chainRootId: threaded ? chainRootId : null,
41500
41599
  listData: detail
41501
41600
  };
41502
41601
  await cache.upsertMessage(persisted);
@@ -41524,16 +41623,43 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41524
41623
  }
41525
41624
  }
41526
41625
  let unconfirmedNote = null;
41626
+ let draftDeleted = false;
41627
+ let draftRetainedReason = null;
41527
41628
  if (newId === null) {
41528
41629
  const draftClause = draftRef !== void 0 ? `Draft ${draftRef} was NOT deleted \u2014 check` : "Check";
41529
41630
  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.`;
41631
+ if (draftRef !== void 0) {
41632
+ draftRetainedReason = "the send could not be confirmed (OFW returned no message id), so the draft is your only reliable copy of the message";
41633
+ }
41530
41634
  } else if (draftRef !== void 0) {
41531
- await deleteOFWMessages(client2, [draftRef]);
41532
- await cache.deleteDraft(draftRef);
41635
+ if (verifyNote !== null) {
41636
+ draftRetainedReason = "the sent record could not be fully verified against what was posted (see WARNING above) \u2014 the draft is kept until you confirm the send on ourfamilywizard.com";
41637
+ } else if (!deleteOnSuccess) {
41638
+ draftRetainedReason = "deleteDraftOnSuccess:false \u2014 kept by request";
41639
+ } else {
41640
+ try {
41641
+ await deleteOFWMessages(client2, [draftRef]);
41642
+ await cache.deleteDraft(draftRef);
41643
+ draftDeleted = true;
41644
+ } catch (e) {
41645
+ draftRetainedReason = `the send succeeded but the draft delete failed (${e.message}) \u2014 remove it with ofw_delete_draft once you have verified the sent message`;
41646
+ }
41647
+ }
41533
41648
  }
41534
- const responseObj = persisted === null ? raw : { ...persisted, ...sentDraftKey !== null ? { draftKey: sentDraftKey, previousId: draftRef } : {} };
41649
+ const retainNote = draftRef !== void 0 && newId !== null && !draftDeleted ? `NOTE: draft ${draftRef} was retained: ${draftRetainedReason}.` : null;
41650
+ const responseObj = persisted === null ? draftRef !== void 0 ? { sendConfirmed: false, draftDeleted: false, draftRetained: true, draftRetainedReason, raw } : raw : {
41651
+ sentMessageId: newId,
41652
+ draftKey: sentDraftKey,
41653
+ threaded,
41654
+ ...draftRef !== void 0 ? {
41655
+ draftDeleted,
41656
+ ...draftDeleted ? {} : { draftRetained: true, draftRetainedReason },
41657
+ previousId: draftRef
41658
+ } : {},
41659
+ ...persisted
41660
+ };
41535
41661
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
41536
- const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join("\n\n");
41662
+ const notes = [guardNote, rewriteNote, verifyNote, threadNote, unconfirmedNote, retainNote].filter((n) => n !== null).join("\n\n");
41537
41663
  return textResponse(notes ? `${notes}
41538
41664
 
41539
41665
  ${text}` : text);
@@ -41553,7 +41679,7 @@ ${text}` : text);
41553
41679
  } catch (e) {
41554
41680
  const reason = e.message;
41555
41681
  if (force) {
41556
- return { ok: true, note: `WARNING: force:true \u2014 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.` };
41682
+ return { ok: true, note: `WARNING: force:true \u2014 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: void 0 };
41557
41683
  }
41558
41684
  return {
41559
41685
  ok: false,
@@ -41568,7 +41694,7 @@ ${text}` : text);
41568
41694
  const verdict = checkDraftFreshness({ server: server2, cached: cached2, expectedRevision });
41569
41695
  if (verdict.verdict === "FRESH") {
41570
41696
  const note = verdict.metadataOnly ? `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.` : null;
41571
- return { ok: true, note };
41697
+ return { ok: true, note, server: server2 };
41572
41698
  }
41573
41699
  if (force) {
41574
41700
  console.error(`[ofw-mcp] WARNING: force:true overrode a ${verdict.verdict} verdict on draft ${draftId} (${action}). ${verdict.reason}`);
@@ -41581,7 +41707,8 @@ ${JSON.stringify(
41581
41707
  { overwrittenServerDraft: server2 === null ? null : { ...server2, revision: draftRevision(server2) } },
41582
41708
  null,
41583
41709
  2
41584
- )}`
41710
+ )}`,
41711
+ server: server2
41585
41712
  };
41586
41713
  }
41587
41714
  return {
@@ -41596,17 +41723,31 @@ ${JSON.stringify(
41596
41723
  };
41597
41724
  }
41598
41725
  server.registerTool("ofw_list_drafts", {
41599
- 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" \u2014 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).',
41726
+ 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" \u2014 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.',
41600
41727
  annotations: { readOnlyHint: false },
41601
41728
  inputSchema: {
41602
41729
  page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
41603
41730
  size: external_exports.number().int().min(1).describe("Drafts per page (default 50)").optional(),
41731
+ verify: external_exports.boolean().describe("Default true: when the drafts cache is not verified-fresh, run a drafts sync first (cheap \u2014 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(),
41604
41732
  autoRefresh: external_exports.boolean().describe(AUTO_REFRESH_DESC).optional()
41605
41733
  }
41606
41734
  }, async (args) => {
41607
41735
  const page = args.page ?? 1;
41608
41736
  const size = args.size ?? 50;
41609
41737
  const cache = cacheProvider();
41738
+ let autoVerified = false;
41739
+ let verifyNote = null;
41740
+ if (args.verify ?? true) {
41741
+ const { cacheStatus } = await draftsFreshness(cache);
41742
+ if (cacheStatus !== "fresh") {
41743
+ try {
41744
+ await syncAll(client2, { folders: ["drafts"], maxRequests: getSyncMaxRequests() }, cache);
41745
+ autoVerified = await getDraftsCacheStatus(cache) === "fresh";
41746
+ } catch (e) {
41747
+ verifyNote = `The automatic drafts verification could not reach OurFamilyWizard (${e.message}). Answering from the local cache \u2014 the freshness block below labels its age, and an empty result will still be refused rather than reported as an absence.`;
41748
+ }
41749
+ }
41750
+ }
41610
41751
  const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
41611
41752
  client: client2,
41612
41753
  cache,
@@ -41637,7 +41778,7 @@ ${JSON.stringify(
41637
41778
  freshness: value.freshness,
41638
41779
  refreshed,
41639
41780
  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.',
41640
- extra: { page, size }
41781
+ extra: { page, size, ...verifyNote !== null ? { verifyNote } : {} }
41641
41782
  });
41642
41783
  }
41643
41784
  const { drafts, total, freshness, serverConfirmed } = value;
@@ -41656,10 +41797,16 @@ ${JSON.stringify(
41656
41797
  if (refreshed) {
41657
41798
  payload.autoRefreshed = true;
41658
41799
  }
41800
+ if (autoVerified) {
41801
+ payload.autoVerified = true;
41802
+ }
41803
+ if (verifyNote !== null) {
41804
+ payload.verifyNote = verifyNote;
41805
+ }
41659
41806
  return jsonResponse(payload);
41660
41807
  });
41661
41808
  if (allowDrafts) server.registerTool("ofw_save_draft", {
41662
- description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 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 \u2014 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 \u2014 merge your edit into it and retry with expectedRevision.",
41809
+ description: "Save a message as a draft in OurFamilyWizard. RECIPIENTS: OurFamilyWizard does NOT persist recipients on drafts \u2014 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 \u2014 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) \u2014 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 \u2014 merge your edit into it and retry with expectedRevision.",
41663
41810
  annotations: { readOnlyHint: false },
41664
41811
  inputSchema: {
41665
41812
  subject: external_exports.string().describe("Message subject"),
@@ -41713,12 +41860,13 @@ ${JSON.stringify(
41713
41860
  let persisted = null;
41714
41861
  let replaceNote = null;
41715
41862
  let verifyNote = null;
41863
+ let recipientsNote = null;
41716
41864
  let newRevision = null;
41717
41865
  let draftKey = null;
41718
41866
  const warnings = [];
41719
41867
  if (newId !== null) {
41720
41868
  verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
41721
- const effectiveReplyTo = detail.replyToId ?? null;
41869
+ const effectiveReplyTo = threadedReplyTo(detail);
41722
41870
  const storedRecipients = mapRecipients(detail.recipients);
41723
41871
  persisted = {
41724
41872
  id: newId,
@@ -41754,17 +41902,23 @@ ${JSON.stringify(
41754
41902
  previousId: args.messageId ?? null,
41755
41903
  recordedAt: now
41756
41904
  });
41757
- if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
41905
+ if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo && reportsUnthreaded(detail)) {
41758
41906
  const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : "";
41759
- const outcome = effectiveReplyTo === null ? "OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped." : `OurFamilyWizard re-targeted the reply to message ${effectiveReplyTo} instead. The draft IS threaded \u2014 to that message, not the one requested \u2014 and the inReplyTo in this response reflects where it actually landed.`;
41760
41907
  warnings.push(
41761
- `replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? "null" : effectiveReplyTo} \u2014 ${outcome} If threading matters, verify on ourfamilywizard.com.`
41908
+ `replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId null \u2014 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.`
41909
+ );
41910
+ } else if (resolvedReplyTo !== null && effectiveReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
41911
+ const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : "";
41912
+ warnings.push(
41913
+ `replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo} \u2014 OurFamilyWizard re-targeted the reply to message ${effectiveReplyTo} instead. The draft IS threaded \u2014 to that message, not the one requested. If threading matters, verify on ourfamilywizard.com.`
41762
41914
  );
41763
41915
  }
41764
- if (args.recipientIds !== void 0 && Array.isArray(detail.recipients)) {
41916
+ if (args.recipientIds !== void 0 && args.recipientIds.length > 0 && Array.isArray(detail.recipients)) {
41765
41917
  const requested = [...new Set(args.recipientIds)].sort((a, b) => a - b);
41766
41918
  const stored = [...new Set(storedRecipients.map((r) => r.userId))].sort((a, b) => a - b);
41767
- if (requested.join(",") !== stored.join(",")) {
41919
+ if (stored.length === 0) {
41920
+ recipientsNote = "NOTE: OurFamilyWizard does not persist recipients on drafts \u2014 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.";
41921
+ } else if (requested.join(",") !== stored.join(",")) {
41768
41922
  warnings.push(
41769
41923
  `recipientIds were requested as [${requested.join(", ")}] but the saved draft has [${stored.join(", ")}]. Verify the recipients on ourfamilywizard.com.`
41770
41924
  );
@@ -41783,28 +41937,26 @@ ${JSON.stringify(
41783
41937
  try {
41784
41938
  await deleteOFWMessages(client2, [args.messageId]);
41785
41939
  await cache.deleteDraft(args.messageId);
41786
- 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." : ""}`;
41940
+ 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 \u2014 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." : ""}`;
41787
41941
  } catch (e) {
41788
41942
  replaceNote = `WARNING: New draft ${newId} was created successfully, but the old draft ${args.messageId} could NOT be deleted: ${e.message}. BOTH drafts now exist on OurFamilyWizard and nothing was lost. Verify ${newId} reads correctly, then remove ${args.messageId} with ofw_delete_draft.`;
41789
41943
  }
41790
41944
  }
41791
41945
  }
41792
41946
  const responseObj = persisted !== null ? {
41947
+ draftKey,
41948
+ revision: newRevision,
41793
41949
  ...persisted,
41794
41950
  inReplyTo: persisted.replyToId,
41795
- revision: newRevision,
41796
- // The id above is volatile — it changes on every edit. `draftKey` is
41797
- // not: pass it to ofw_status to resolve the chain's CURRENT id, or to
41798
- // find out that the draft was sent and when.
41799
- draftKey,
41800
41951
  previousId: args.messageId ?? null,
41801
41952
  cacheStatus: "fresh",
41802
41953
  serverConfirmed: true,
41803
- ...warnings.length > 0 ? { warnings } : {}
41954
+ ...warnings.length > 0 ? { warnings } : {},
41955
+ ...recipientsNote !== null ? { recipientsNote } : {}
41804
41956
  } : raw;
41805
41957
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
41806
41958
  const warnNote = warnings.length > 0 ? `WARNING: ${warnings.join("\n\n")}` : null;
41807
- const notes = [forceNote, rewriteNote, verifyNote, warnNote, replaceNote].filter((n) => n !== null).join("\n\n");
41959
+ const notes = [forceNote, rewriteNote, verifyNote, warnNote, recipientsNote, replaceNote].filter((n) => n !== null).join("\n\n");
41808
41960
  return textResponse(notes ? `${notes}
41809
41961
 
41810
41962
  ${text}` : text);
@@ -43094,7 +43246,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
43094
43246
  var nodeAttachmentIO = new NodeAttachmentIO();
43095
43247
  await runMcp({
43096
43248
  name: "ofw",
43097
- version: "2.9.2",
43249
+ version: "2.10.0",
43098
43250
  // x-release-please-version
43099
43251
  deps: client,
43100
43252
  tools: [
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
35
35
  // always succeeds before any credential check runs.
36
36
  await runMcp({
37
37
  name: 'ofw',
38
- version: '2.9.2', // x-release-please-version
38
+ version: '2.10.0', // x-release-please-version
39
39
  deps: client,
40
40
  tools: [
41
41
  registerUserTools,
package/dist/sync.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { ApiRecipientSchema, hasRealView, mapRecipients } from './tools/_shared.js';
2
+ import { ApiRecipientSchema, hasRealView, mapRecipients, threadedReplyTo } from './tools/_shared.js';
3
3
  import { parseLenient } from '@chrischall/mcp-utils';
4
4
  // Each OFW message detail returns `files: [fileId, ...]`. We fetch the metadata
5
5
  // for each file id (cheap JSON call) so the model can see filenames/mime types
@@ -347,7 +347,12 @@ const DraftListItemSchema = z.looseObject({
347
347
  id: z.number(),
348
348
  subject: z.string(),
349
349
  date: z.looseObject({ dateTime: z.string() }),
350
+ // Both spellings of the threading echo — OFW reports the reply target as
351
+ // `inReplyTo` (with showContext) on list payloads where `replyToId` is null.
352
+ // The cached row must derive the SAME value ofw_save_draft derived from the
353
+ // detail, or the content revision drifts between a save and the next sync.
350
354
  replyToId: z.number().nullable().optional(),
355
+ inReplyTo: z.number().nullable().optional(),
351
356
  recipients: z.array(ApiRecipientSchema).optional(),
352
357
  });
353
358
  const DraftListResponseSchema = z.looseObject({ data: z.array(DraftListItemSchema).optional() });
@@ -451,7 +456,7 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
451
456
  subject: detail.subject ?? item.subject ?? '(no subject)',
452
457
  body: detail.body ?? '',
453
458
  recipients: mapRecipients(item.recipients),
454
- replyToId: item.replyToId ?? null,
459
+ replyToId: threadedReplyTo(item),
455
460
  modifiedAt: item.date?.dateTime ?? new Date().toISOString(),
456
461
  listData: item,
457
462
  });