ofw-mcp 2.6.6 → 2.6.7

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.6.6"
9
+ "version": "2.6.7"
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.6.6",
17
+ "version": "2.6.7",
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.6.6",
4
+ "version": "2.6.7",
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
@@ -38407,7 +38407,7 @@ async function loginWithPassword(username, password) {
38407
38407
  // package.json
38408
38408
  var package_default = {
38409
38409
  name: "ofw-mcp",
38410
- version: "2.6.6",
38410
+ version: "2.6.7",
38411
38411
  license: "MIT",
38412
38412
  mcpName: "io.github.chrischall/ofw-mcp",
38413
38413
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -38692,6 +38692,9 @@ var client = new OFWClient();
38692
38692
  // src/tools/_shared.ts
38693
38693
  var jsonResponse = textResult;
38694
38694
  var textResponse = rawTextResult;
38695
+ function jsonErrorResponse(data) {
38696
+ return { ...textResult(data), isError: true };
38697
+ }
38695
38698
  var ApiRecipientSchema = external_exports.looseObject({
38696
38699
  // Live OFW payloads key the recipient's id as `userId` (verified against a
38697
38700
  // real /pub/v3/messages record: `recipients[].user.userId === 3039201`). An
@@ -38721,15 +38724,15 @@ function scrapeSaysRead(listData) {
38721
38724
  const ld = listData;
38722
38725
  return ld.read === true || ld.showNeverViewed === false;
38723
38726
  }
38724
- function deriveRead(row, selfUserId) {
38727
+ function deriveRead(row) {
38728
+ const viewedByAnyone = row.recipients.some((r) => r.viewedAt !== null);
38725
38729
  if (row.folder === "inbox") {
38726
- const viewed = selfUserId !== void 0 ? row.recipients.some((r) => r.userId === selfUserId && r.viewedAt !== null) : row.recipients.some((r) => r.viewedAt !== null);
38727
- return viewed || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
38730
+ return viewedByAnyone || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
38728
38731
  }
38729
- return row.recipients.some((r) => r.viewedAt !== null) || scrapeSaysRead(row.listData);
38732
+ return viewedByAnyone || scrapeSaysRead(row.listData);
38730
38733
  }
38731
- function withReadState(row, selfUserId) {
38732
- const read = deriveRead(row, selfUserId);
38734
+ function withReadState(row) {
38735
+ const read = deriveRead(row);
38733
38736
  const listData = typeof row.listData === "object" && row.listData !== null ? { ...row.listData, read, showNeverViewed: !read } : row.listData;
38734
38737
  return { ...row, read, listData };
38735
38738
  }
@@ -39033,12 +39036,20 @@ var DraftDetailSchema = external_exports.looseObject({
39033
39036
  body: external_exports.string().optional(),
39034
39037
  subject: external_exports.string().optional()
39035
39038
  });
39039
+ var DRAFTS_CACHE_STATUS_KEY = "drafts_cache_status";
39040
+ async function getDraftsCacheStatus(store) {
39041
+ return await store.getMeta(DRAFTS_CACHE_STATUS_KEY) === "fresh" ? "fresh" : "unverified";
39042
+ }
39036
39043
  async function syncDrafts(client2, draftsFolderId, store, budget) {
39037
39044
  const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
39045
+ const defer = async () => {
39046
+ await store.setMeta(DRAFTS_CACHE_STATUS_KEY, "unverified");
39047
+ return { synced: 0, done: false };
39048
+ };
39038
39049
  const items = [];
39039
39050
  let page = 1;
39040
39051
  while (true) {
39041
- if (!b.take()) return { synced: 0, done: false };
39052
+ if (!b.take()) return defer();
39042
39053
  const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
39043
39054
  const list = parseLenient(
39044
39055
  DraftListResponseSchema,
@@ -39052,7 +39063,7 @@ async function syncDrafts(client2, draftsFolderId, store, budget) {
39052
39063
  }
39053
39064
  const rows = [];
39054
39065
  for (const item of items) {
39055
- if (!b.take()) return { synced: 0, done: false };
39066
+ if (!b.take()) return defer();
39056
39067
  const detail = parseLenient(
39057
39068
  DraftDetailSchema,
39058
39069
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
@@ -39085,16 +39096,22 @@ async function syncDrafts(client2, draftsFolderId, store, budget) {
39085
39096
  for (const id of await store.listDraftIds()) {
39086
39097
  if (!seenIds.has(id)) await store.deleteDraft(id);
39087
39098
  }
39099
+ await store.setMeta(DRAFTS_CACHE_STATUS_KEY, "fresh");
39088
39100
  return { synced, done: true };
39089
39101
  }
39090
39102
  async function syncAll(client2, opts, store) {
39091
- const folders = opts.folders ?? ["inbox", "sent", "drafts"];
39103
+ const requested = opts.folders ?? ["inbox", "sent", "drafts"];
39104
+ const folders = [
39105
+ ...requested.filter((f) => f === "drafts"),
39106
+ ...requested.filter((f) => f !== "drafts")
39107
+ ];
39092
39108
  const budget = makeBudget(opts.maxRequests ?? Number.POSITIVE_INFINITY);
39093
39109
  budget.take();
39094
39110
  const ids = await resolveFolderIds(client2, store);
39095
39111
  const synced = {};
39096
39112
  let unreadInbox = [];
39097
39113
  let done = true;
39114
+ let draftsUnverified = false;
39098
39115
  for (const folder of folders) {
39099
39116
  if (folder === "inbox") {
39100
39117
  const r = await syncMessageFolder(client2, "inbox", ids.inbox, {
@@ -39115,11 +39132,17 @@ async function syncAll(client2, opts, store) {
39115
39132
  if (!r.done) done = false;
39116
39133
  } else if (folder === "drafts") {
39117
39134
  const r = await syncDrafts(client2, ids.drafts, store, budget);
39118
- synced.drafts = r.synced;
39119
- if (!r.done) done = false;
39135
+ if (r.done) synced.drafts = r.synced;
39136
+ else {
39137
+ draftsUnverified = true;
39138
+ done = false;
39139
+ }
39120
39140
  }
39121
39141
  }
39122
39142
  const notes = [];
39143
+ if (draftsUnverified) {
39144
+ notes.push('The drafts folder was NOT checked against OurFamilyWizard on this call (the request budget ran out first), so no drafts count is reported and the cached drafts are marked "unverified". Cached draft bodies may be behind the server. Call ofw_sync_messages again \u2014 or ofw_sync_messages with folders:["drafts"] \u2014 before editing or deleting a draft.');
39145
+ }
39123
39146
  if (unreadInbox.length > 0) {
39124
39147
  notes.push(`${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them \u2014 this will mark them as read on OFW.`);
39125
39148
  }
@@ -39130,6 +39153,116 @@ async function syncAll(client2, opts, store) {
39130
39153
  return { synced, unreadInbox, done, ...note ? { note } : {} };
39131
39154
  }
39132
39155
 
39156
+ // src/tools/draft-freshness.ts
39157
+ var DraftFreshnessError = class extends Error {
39158
+ };
39159
+ var FNV_OFFSET = 0xcbf29ce484222325n;
39160
+ var FNV_PRIME = 0x100000001b3n;
39161
+ var MASK64 = 0xffffffffffffffffn;
39162
+ function fnv1a64(s) {
39163
+ let h = FNV_OFFSET;
39164
+ for (let i = 0; i < s.length; i++) {
39165
+ h = (h ^ BigInt(s.charCodeAt(i))) * FNV_PRIME & MASK64;
39166
+ }
39167
+ return h.toString(16).padStart(16, "0");
39168
+ }
39169
+ function draftRevision(d) {
39170
+ const ids = [...new Set(d.recipients.map((r) => r.userId))].sort((a, b) => a - b);
39171
+ const parts = [d.subject, d.body, String(d.replyToId ?? ""), ids.join(",")];
39172
+ return `r1:${fnv1a64(parts.map((p) => `${p.length}:${p}`).join("|"))}`;
39173
+ }
39174
+ var ServerDraftSchema = external_exports.looseObject({
39175
+ subject: external_exports.string().optional(),
39176
+ body: external_exports.string().optional(),
39177
+ replyToId: external_exports.number().nullable().optional(),
39178
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39179
+ });
39180
+ function isNotFound(e) {
39181
+ return e instanceof Error && /OFW API error: 404\b/.test(e.message);
39182
+ }
39183
+ async function fetchServerDraft(client2, id) {
39184
+ let raw;
39185
+ try {
39186
+ raw = await client2.request("GET", `/pub/v3/messages/${id}`);
39187
+ } catch (e) {
39188
+ if (isNotFound(e)) return null;
39189
+ throw new DraftFreshnessError(
39190
+ `could not read the current state of draft ${id} from OurFamilyWizard: ${e.message}`
39191
+ );
39192
+ }
39193
+ if (raw === null || raw === void 0) return null;
39194
+ const detail = parseLenient(ServerDraftSchema, raw, {
39195
+ label: "ofw-mcp",
39196
+ context: "GET /pub/v3/messages/{id} (draft freshness check)",
39197
+ mode: "strict"
39198
+ });
39199
+ return {
39200
+ subject: detail.subject ?? "",
39201
+ body: detail.body ?? "",
39202
+ replyToId: detail.replyToId ?? null,
39203
+ recipients: mapRecipients(detail.recipients)
39204
+ };
39205
+ }
39206
+ function diffFields(a, b) {
39207
+ const changed = [];
39208
+ if (a.subject !== b.subject) changed.push("subject");
39209
+ if (a.body !== b.body) changed.push("body");
39210
+ if (a.replyToId !== b.replyToId) changed.push("replyToId");
39211
+ const ids = (d) => [...new Set(d.recipients.map((r) => r.userId))].sort((x, y) => x - y).join(",");
39212
+ if (ids(a) !== ids(b)) changed.push("recipients");
39213
+ return changed;
39214
+ }
39215
+ function checkDraftFreshness(input) {
39216
+ const { server, cached: cached2, expectedRevision } = input;
39217
+ if (server === null) {
39218
+ return {
39219
+ verdict: "MISSING",
39220
+ reason: "The draft no longer exists on OurFamilyWizard \u2014 it may have been sent or deleted elsewhere.",
39221
+ changedFields: []
39222
+ };
39223
+ }
39224
+ if (expectedRevision !== void 0) {
39225
+ const actual = draftRevision(server);
39226
+ if (expectedRevision === actual) {
39227
+ return { verdict: "FRESH", reason: "expectedRevision matches the live server draft.", changedFields: [] };
39228
+ }
39229
+ return {
39230
+ verdict: "STALE",
39231
+ reason: `expectedRevision ${expectedRevision} does not match the live server draft (${actual}) \u2014 it changed after you read it.`,
39232
+ changedFields: cached2 === null ? [] : diffFields(server, cached2)
39233
+ };
39234
+ }
39235
+ if (cached2 === null) {
39236
+ return {
39237
+ verdict: "STALE",
39238
+ reason: "This draft is not in the local cache, so there is no base to confirm the edit against.",
39239
+ changedFields: []
39240
+ };
39241
+ }
39242
+ const changedFields = diffFields(server, cached2);
39243
+ if (changedFields.length === 0) {
39244
+ return { verdict: "FRESH", reason: "The cached draft matches the live server draft.", changedFields: [] };
39245
+ }
39246
+ return {
39247
+ verdict: "STALE",
39248
+ reason: `The draft on OurFamilyWizard differs from the cached copy (${changedFields.join(", ")}) \u2014 it was edited outside this tool.`,
39249
+ changedFields
39250
+ };
39251
+ }
39252
+ function staleDraftPayload(input) {
39253
+ const { error: error51, draftId, verdict, server, cached: cached2 } = input;
39254
+ return {
39255
+ error: error51,
39256
+ draftId,
39257
+ verdict: verdict.verdict,
39258
+ reason: verdict.reason,
39259
+ ...verdict.changedFields.length > 0 ? { changedFields: verdict.changedFields } : {},
39260
+ ...server !== null ? { serverBody: server.body, serverSubject: server.subject, serverRevision: draftRevision(server) } : {},
39261
+ ...cached2 !== null ? { cachedBody: cached2.body } : {},
39262
+ recovery: server === null ? "The draft is gone from OurFamilyWizard. Nothing was changed. If you still want this content saved, call ofw_save_draft WITHOUT messageId to create a new draft." : "Nothing was changed. Merge your edit into serverBody above, then retry with expectedRevision set to serverRevision. Pass force:true only if you intend to discard the server copy shown here."
39263
+ };
39264
+ }
39265
+
39133
39266
  // src/config.ts
39134
39267
  import { createHash } from "node:crypto";
39135
39268
  import { homedir as homedir3 } from "node:os";
@@ -39298,7 +39431,11 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39298
39431
  replyToId: draftRow.replyToId,
39299
39432
  chainRootId: null,
39300
39433
  listData: draftRow.listData,
39301
- attachments: []
39434
+ attachments: [],
39435
+ // Concurrency token — pass as expectedRevision to ofw_save_draft /
39436
+ // ofw_delete_draft to assert you are editing THIS version.
39437
+ revision: draftRevision(draftRow),
39438
+ cacheStatus: await getDraftsCacheStatus(cache)
39302
39439
  });
39303
39440
  }
39304
39441
  const cached2 = await cache.getMessage(id);
@@ -39488,6 +39625,60 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39488
39625
 
39489
39626
  ${text}` : text);
39490
39627
  });
39628
+ async function guardDestructiveDraftOp(input) {
39629
+ const { cache, draftId, expectedRevision, force, action } = input;
39630
+ const cachedRow = await cache.getDraft(draftId);
39631
+ const cached2 = cachedRow === null ? null : {
39632
+ subject: cachedRow.subject,
39633
+ body: cachedRow.body,
39634
+ recipients: cachedRow.recipients,
39635
+ replyToId: cachedRow.replyToId
39636
+ };
39637
+ let server2;
39638
+ try {
39639
+ server2 = await fetchServerDraft(client2, draftId);
39640
+ } catch (e) {
39641
+ const reason = e.message;
39642
+ if (force) {
39643
+ 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.` };
39644
+ }
39645
+ return {
39646
+ ok: false,
39647
+ response: jsonErrorResponse({
39648
+ error: "FRESHNESS_CHECK_FAILED",
39649
+ draftId,
39650
+ reason,
39651
+ recovery: "Nothing was changed. This is usually transient \u2014 retry. If it persists, verify the draft on ourfamilywizard.com. Pass force:true only if you accept overwriting a version you have not seen."
39652
+ })
39653
+ };
39654
+ }
39655
+ const verdict = checkDraftFreshness({ server: server2, cached: cached2, expectedRevision });
39656
+ if (verdict.verdict === "FRESH") return { ok: true, note: null };
39657
+ if (force) {
39658
+ console.error(`[ofw-mcp] WARNING: force:true overrode a ${verdict.verdict} verdict on draft ${draftId} (${action}). ${verdict.reason}`);
39659
+ const echoed = server2 === null ? "The draft no longer existed on OurFamilyWizard." : `The server version that was overwritten is preserved below under "overwrittenServerDraft".`;
39660
+ return {
39661
+ ok: true,
39662
+ note: `WARNING: force:true overrode a ${verdict.verdict} freshness verdict on draft ${draftId}. ${verdict.reason} ${echoed}
39663
+
39664
+ ${JSON.stringify(
39665
+ { overwrittenServerDraft: server2 === null ? null : { ...server2, revision: draftRevision(server2) } },
39666
+ null,
39667
+ 2
39668
+ )}`
39669
+ };
39670
+ }
39671
+ return {
39672
+ ok: false,
39673
+ response: jsonErrorResponse(staleDraftPayload({
39674
+ error: verdict.verdict === "MISSING" ? "MISSING_DRAFT" : "STALE_DRAFT",
39675
+ draftId,
39676
+ verdict,
39677
+ server: server2,
39678
+ cached: cached2
39679
+ }))
39680
+ };
39681
+ }
39491
39682
  server.registerTool("ofw_list_drafts", {
39492
39683
  description: "List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.",
39493
39684
  annotations: { readOnlyHint: true },
@@ -39498,12 +39689,21 @@ ${text}` : text);
39498
39689
  }, async (args) => {
39499
39690
  const page = args.page ?? 1;
39500
39691
  const size = args.size ?? 50;
39501
- const drafts = await cacheProvider().listDrafts({ page, size });
39502
- const payload = drafts.length === 0 ? { drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." } : { drafts };
39692
+ const cache = cacheProvider();
39693
+ const cacheStatus = await getDraftsCacheStatus(cache);
39694
+ const rows = await cache.listDrafts({ page, size });
39695
+ const drafts = rows.map((d) => ({ ...d, revision: draftRevision(d), cacheStatus }));
39696
+ if (drafts.length === 0) {
39697
+ return jsonResponse({ drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." });
39698
+ }
39699
+ const payload = { drafts };
39700
+ if (cacheStatus !== "fresh") {
39701
+ payload.note = 'cacheStatus "unverified": the last ofw_sync_messages did not finish checking the drafts folder against OurFamilyWizard, so these bodies may be behind the server (drafts edited in the OFW web app do not bump any timestamp). Run ofw_sync_messages again before relying on them. Writes are guarded regardless \u2014 ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
39702
+ }
39503
39703
  return jsonResponse(payload);
39504
39704
  });
39505
39705
  if (allowDrafts) server.registerTool("ofw_save_draft", {
39506
- 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. 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.",
39706
+ 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. 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. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if it 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). The refusal returns the current server body under serverBody \u2014 merge your edit into it and retry with expectedRevision.",
39507
39707
  annotations: { readOnlyHint: false },
39508
39708
  inputSchema: {
39509
39709
  subject: external_exports.string().describe("Message subject"),
@@ -39511,10 +39711,24 @@ ${text}` : text);
39511
39711
  recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (optional for drafts)").optional(),
39512
39712
  messageId: external_exports.number().describe("ID of an existing draft to replace (the new draft will have a new id; the old is deleted)").optional(),
39513
39713
  replyToId: external_exports.number().describe("ID of the message this draft replies to").optional(),
39514
- myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional()
39714
+ myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional(),
39715
+ expectedRevision: external_exports.string().describe('With messageId: the `revision` you got from ofw_list_drafts/ofw_get_message for that draft. Asserts you are replacing THAT version. If the draft changed on OFW since, the write is refused and the current server body is returned. Omit and the tool compares the server against the local cache instead \u2014 omitting never means "overwrite anyway".').optional(),
39716
+ force: external_exports.boolean().describe("Default false. Overwrite even when the draft changed on OurFamilyWizard since you read it. The discarded server version is echoed back in the response. Only use after showing the user the conflict.").optional()
39515
39717
  }
39516
39718
  }, async (args) => {
39517
39719
  const cache = cacheProvider();
39720
+ let forceNote = null;
39721
+ if (args.messageId !== void 0) {
39722
+ const guard = await guardDestructiveDraftOp({
39723
+ cache,
39724
+ draftId: args.messageId,
39725
+ expectedRevision: args.expectedRevision,
39726
+ force: args.force ?? false,
39727
+ action: "replace"
39728
+ });
39729
+ if (!guard.ok) return guard.response;
39730
+ forceNote = guard.note;
39731
+ }
39518
39732
  const requestedReplyTo = args.replyToId ?? null;
39519
39733
  let resolvedReplyTo = requestedReplyTo;
39520
39734
  let rewriteNote = null;
@@ -39543,6 +39757,7 @@ ${text}` : text);
39543
39757
  let persisted = null;
39544
39758
  let replaceNote = null;
39545
39759
  let verifyNote = null;
39760
+ let newRevision = null;
39546
39761
  if (newId !== null) {
39547
39762
  verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
39548
39763
  persisted = {
@@ -39555,33 +39770,48 @@ ${text}` : text);
39555
39770
  listData: detail
39556
39771
  };
39557
39772
  await cache.upsertDraft(persisted);
39773
+ newRevision = draftRevision(persisted);
39558
39774
  if (args.messageId !== void 0 && args.messageId !== newId) {
39559
39775
  try {
39560
39776
  await deleteOFWMessages(client2, [args.messageId]);
39561
39777
  await cache.deleteDraft(args.messageId);
39562
39778
  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.)`;
39563
39779
  } catch (e) {
39564
- replaceNote = `WARNING: New draft ${newId} created successfully, but failed to delete the old draft (${args.messageId}): ${e.message}. You may want to clean it up manually with ofw_delete_draft.`;
39780
+ 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.`;
39565
39781
  }
39566
39782
  }
39567
39783
  }
39568
- const responseObj = persisted ?? raw;
39784
+ const responseObj = persisted !== null ? { ...persisted, revision: newRevision, cacheStatus: "fresh" } : raw;
39569
39785
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
39570
- const notes = [rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join("\n\n");
39786
+ const notes = [forceNote, rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join("\n\n");
39571
39787
  return textResponse(notes ? `${notes}
39572
39788
 
39573
39789
  ${text}` : text);
39574
39790
  });
39575
39791
  if (allowDrafts) server.registerTool("ofw_delete_draft", {
39576
- description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.",
39792
+ description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache. Before deleting, the draft is re-read from OFW and the delete is REFUSED if it changed since you last read it (the current server body is returned so nothing is lost) \u2014 pass expectedRevision to assert which version you mean, or force:true to delete regardless.",
39577
39793
  annotations: { destructiveHint: true },
39578
39794
  inputSchema: {
39579
- messageId: external_exports.number().describe("Draft message ID to delete")
39795
+ messageId: external_exports.number().describe("Draft message ID to delete"),
39796
+ expectedRevision: external_exports.string().describe("The `revision` you got from ofw_list_drafts/ofw_get_message. Asserts you are deleting THAT version; if the draft changed on OFW since, the delete is refused and the current server body returned.").optional(),
39797
+ force: external_exports.boolean().describe("Default false. Delete even if the draft changed on OurFamilyWizard since you read it. The discarded server version is echoed back in the response.").optional()
39580
39798
  }
39581
39799
  }, async (args) => {
39800
+ const cache = cacheProvider();
39801
+ const guard = await guardDestructiveDraftOp({
39802
+ cache,
39803
+ draftId: args.messageId,
39804
+ expectedRevision: args.expectedRevision,
39805
+ force: args.force ?? false,
39806
+ action: "delete"
39807
+ });
39808
+ if (!guard.ok) return guard.response;
39582
39809
  const data = await deleteOFWMessages(client2, [args.messageId]);
39583
- await cacheProvider().deleteDraft(args.messageId);
39584
- return data ? jsonResponse(data) : textResponse("Draft deleted.");
39810
+ await cache.deleteDraft(args.messageId);
39811
+ const text = data ? JSON.stringify(data, null, 2) : "Draft deleted.";
39812
+ return textResponse(guard.note ? `${guard.note}
39813
+
39814
+ ${text}` : text);
39585
39815
  });
39586
39816
  server.registerTool("ofw_get_unread_sent", {
39587
39817
  description: "List sent messages that have not been read by one or more recipients. Reads from local cache; call ofw_sync_messages first if cache is stale.",
@@ -40572,7 +40802,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
40572
40802
  var nodeAttachmentIO = new NodeAttachmentIO();
40573
40803
  await runMcp({
40574
40804
  name: "ofw",
40575
- version: "2.6.6",
40805
+ version: "2.6.7",
40576
40806
  // x-release-please-version
40577
40807
  deps: client,
40578
40808
  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.6.6', // x-release-please-version
38
+ version: '2.6.7', // x-release-please-version
39
39
  deps: client,
40
40
  tools: [
41
41
  registerUserTools,
package/dist/sync.js CHANGED
@@ -323,9 +323,28 @@ const DraftDetailSchema = z.looseObject({
323
323
  body: z.string().optional(),
324
324
  subject: z.string().optional(),
325
325
  });
326
+ /**
327
+ * Meta key holding whether the drafts cache has been compared against OFW.
328
+ * `'fresh'` only after a COMPLETE drafts walk; `'unverified'` whenever a walk
329
+ * was deferred for budget. Read by ofw_list_drafts / ofw_get_message to stamp
330
+ * each draft's `cacheStatus`, and by the destructive draft tools to decide how
331
+ * loudly to warn. Absent (never synced) reads as unverified.
332
+ */
333
+ export const DRAFTS_CACHE_STATUS_KEY = 'drafts_cache_status';
334
+ export async function getDraftsCacheStatus(store) {
335
+ return (await store.getMeta(DRAFTS_CACHE_STATUS_KEY)) === 'fresh' ? 'fresh' : 'unverified';
336
+ }
326
337
  export async function syncDrafts(client, draftsFolderId, store, budget) {
327
338
  // No budget → unbounded (local stdio): identical to the original walk.
328
339
  const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
340
+ // Deferring means we never compared the drafts cache to OFW on this call.
341
+ // Mark it unverified so reads can say so and the destructive draft tools
342
+ // know the cache is not a trustworthy base — the count we return here is
343
+ // "nothing applied", NOT "nothing changed on the server".
344
+ const defer = async () => {
345
+ await store.setMeta(DRAFTS_CACHE_STATUS_KEY, 'unverified');
346
+ return { synced: 0, done: false };
347
+ };
329
348
  // The reconciliation step below DELETES any cached draft not seen in the
330
349
  // listing, so a partial walk must apply NOTHING. We therefore buffer the
331
350
  // entire walk (all list pages + every detail) BEFORE touching the cache: if
@@ -337,7 +356,7 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
337
356
  let page = 1;
338
357
  while (true) {
339
358
  if (!b.take())
340
- return { synced: 0, done: false };
359
+ return defer();
341
360
  const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
342
361
  const list = parseLenient(DraftListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: 'GET /pub/v3/messages?folders={drafts}' });
343
362
  const pageItems = list.data ?? [];
@@ -352,7 +371,7 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
352
371
  const rows = [];
353
372
  for (const item of items) {
354
373
  if (!b.take())
355
- return { synced: 0, done: false };
374
+ return defer();
356
375
  const detail = parseLenient(DraftDetailSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (drafts sync)' });
357
376
  rows.push({
358
377
  id: item.id,
@@ -390,10 +409,25 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
390
409
  if (!seenIds.has(id))
391
410
  await store.deleteDraft(id);
392
411
  }
412
+ // The complete walk fetched every draft's DETAIL and reconciled deletions, so
413
+ // the cache is now known-equal to the server. Only here is `synced: 0`
414
+ // truthful as "verified no changes".
415
+ await store.setMeta(DRAFTS_CACHE_STATUS_KEY, 'fresh');
393
416
  return { synced, done: true };
394
417
  }
395
418
  export async function syncAll(client, opts, store) {
396
- const folders = opts.folders ?? ['inbox', 'sent', 'drafts'];
419
+ const requested = opts.folders ?? ['inbox', 'sent', 'drafts'];
420
+ // Drafts go FIRST. They are the only folder a destructive tool
421
+ // (ofw_save_draft / ofw_delete_draft) reads as its base, and they are cheap
422
+ // and bounded — one list page plus one detail per draft. Running them last,
423
+ // behind inbox and sent, meant a bounded call (the Worker's
424
+ // OFW_SYNC_MAX_REQUESTS=40) spent its whole budget backfilling history and
425
+ // deferred drafts on every single call, so server-side draft edits stayed
426
+ // invisible indefinitely while the response reported `drafts: 0`.
427
+ const folders = [
428
+ ...requested.filter((f) => f === 'drafts'),
429
+ ...requested.filter((f) => f !== 'drafts'),
430
+ ];
397
431
  // ONE budget shared across resolveFolderIds and every requested folder, in
398
432
  // order — so the whole invocation stays under the hosting subrequest cap.
399
433
  const budget = makeBudget(opts.maxRequests ?? Number.POSITIVE_INFINITY);
@@ -405,6 +439,7 @@ export async function syncAll(client, opts, store) {
405
439
  const synced = {};
406
440
  let unreadInbox = [];
407
441
  let done = true;
442
+ let draftsUnverified = false;
408
443
  for (const folder of folders) {
409
444
  if (folder === 'inbox') {
410
445
  const r = await syncMessageFolder(client, 'inbox', ids.inbox, {
@@ -429,12 +464,22 @@ export async function syncAll(client, opts, store) {
429
464
  }
430
465
  else if (folder === 'drafts') {
431
466
  const r = await syncDrafts(client, ids.drafts, store, budget);
432
- synced.drafts = r.synced;
433
- if (!r.done)
467
+ // Only report a drafts count when the walk actually compared against
468
+ // OFW. A deferred walk applied nothing, and reporting its `0` as
469
+ // `drafts: 0` reads as "verified, no changes" — the exact lie that let a
470
+ // server-side draft edit be overwritten. Omit the number instead.
471
+ if (r.done)
472
+ synced.drafts = r.synced;
473
+ else {
474
+ draftsUnverified = true;
434
475
  done = false;
476
+ }
435
477
  }
436
478
  }
437
479
  const notes = [];
480
+ if (draftsUnverified) {
481
+ notes.push('The drafts folder was NOT checked against OurFamilyWizard on this call (the request budget ran out first), so no drafts count is reported and the cached drafts are marked "unverified". Cached draft bodies may be behind the server. Call ofw_sync_messages again — or ofw_sync_messages with folders:["drafts"] — before editing or deleting a draft.');
482
+ }
438
483
  if (unreadInbox.length > 0) {
439
484
  notes.push(`${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them — this will mark them as read on OFW.`);
440
485
  }
@@ -6,6 +6,13 @@ import { parseLenient } from '@chrischall/mcp-utils';
6
6
  export const jsonResponse = textResult;
7
7
  // Raw-string tool result. Wrapper over @chrischall/mcp-utils' `rawTextResult`.
8
8
  export const textResponse = rawTextResult;
9
+ // A STRUCTURED failure: the machine-readable payload of `jsonResponse` plus
10
+ // `isError`, so a refusal can carry recovery data (e.g. the server draft body
11
+ // we declined to overwrite) without being mistaken for a successful write.
12
+ // mcp-utils' `errorResult` only carries a string.
13
+ export function jsonErrorResponse(data) {
14
+ return { ...textResult(data), isError: true };
15
+ }
9
16
  // OFW API shape for `recipients[]` on message/draft list and detail
10
17
  // responses. Used wherever we validate the response of a `/pub/v3/messages*`
11
18
  // call. Loose: unknown keys pass through (and survive into cached listData).
@@ -74,23 +81,35 @@ function scrapeSaysRead(listData) {
74
81
  * resync (which re-scrapes the list flags) can never flip a read message back
75
82
  * to unread:
76
83
  *
77
- * - INBOX: the account holder is the recipient. When we know our own id
78
- * (`selfUserId`), that recipient's `viewedAt` is authoritative; otherwise any
79
- * recipient's `viewedAt` stands in (1:1 co-parent messaging). Fetching the
80
- * body marks the message read on OFW, so a non-null `fetchedBodyAt` is also
81
- * read=true. The stale scrape flag is only a last-resort fallback.
84
+ * - INBOX: the account holder is the recipient, so ANY recipient's `viewedAt`
85
+ * counts. OFW co-parent threads are 1:1 — the sole inbox recipient is us
86
+ * so this is exact, not an approximation. Fetching the body marks the message
87
+ * read on OFW, so a non-null `fetchedBodyAt` is also read=true. The stale
88
+ * scrape flag is only a last-resort fallback.
82
89
  * - SENT: "read" means a *recipient* has opened it — tracked via their
83
90
  * `viewedAt` (the detail endpoint's real timestamp) — never our own body
84
91
  * fetch, which is always set for sent messages.
92
+ *
93
+ * This deliberately does NOT discriminate by the account holder's own userId.
94
+ * An earlier `selfUserId` parameter did, but nothing ever passed it, so the
95
+ * branch was dead in production. Reviving it is not as simple as threading the
96
+ * argument through, for two reasons:
97
+ * 1. No non-mutating endpoint exposes our numeric id. /pub/v2/profiles returns
98
+ * name/address/contact and no id at all; /pub/v1/users/useraccountstatus
99
+ * updates last-seen status as a side effect, and view timestamps are
100
+ * evidentiary in custody matters — not something to touch for a read flag.
101
+ * 2. Rows cached before the `user.userId` parse fix (see ApiRecipientSchema)
102
+ * normalized every recipient to `userId: 0`, so an id match would silently
103
+ * fail on historical data until a full re-sync.
104
+ * If OFW ever adds third-party recipients (lawyer, parenting coordinator), both
105
+ * problems need solving together — a bare parameter would regress to dead code.
85
106
  */
86
- export function deriveRead(row, selfUserId) {
107
+ export function deriveRead(row) {
108
+ const viewedByAnyone = row.recipients.some((r) => r.viewedAt !== null);
87
109
  if (row.folder === 'inbox') {
88
- const viewed = selfUserId !== undefined
89
- ? row.recipients.some((r) => r.userId === selfUserId && r.viewedAt !== null)
90
- : row.recipients.some((r) => r.viewedAt !== null);
91
- return viewed || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
110
+ return viewedByAnyone || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
92
111
  }
93
- return row.recipients.some((r) => r.viewedAt !== null) || scrapeSaysRead(row.listData);
112
+ return viewedByAnyone || scrapeSaysRead(row.listData);
94
113
  }
95
114
  /**
96
115
  * Return the row augmented with an authoritative top-level `read` boolean and a
@@ -99,8 +118,8 @@ export function deriveRead(row, selfUserId) {
99
118
  * carrying `listData.read: false` alongside a populated recipient `viewedAt`).
100
119
  * A non-object `listData` (null / legacy string) is passed through untouched.
101
120
  */
102
- export function withReadState(row, selfUserId) {
103
- const read = deriveRead(row, selfUserId);
121
+ export function withReadState(row) {
122
+ const read = deriveRead(row);
104
123
  const listData = (typeof row.listData === 'object' && row.listData !== null)
105
124
  ? { ...row.listData, read, showNeverViewed: !read }
106
125
  : row.listData;
@@ -0,0 +1,166 @@
1
+ import { z } from 'zod';
2
+ import { parseLenient } from '@chrischall/mcp-utils';
3
+ import { ApiRecipientSchema, mapRecipients } from './_shared.js';
4
+ /** Thrown when the freshness check itself could not be completed. */
5
+ export class DraftFreshnessError extends Error {
6
+ }
7
+ // FNV-1a (64-bit) over a canonical encoding. Not cryptographic — this is a
8
+ // change detector, and it is never the sole guard: an unsupplied token falls
9
+ // back to a full field-by-field comparison against the cached base.
10
+ // BigInt keeps it byte-identical on node and on the Workers runtime.
11
+ const FNV_OFFSET = 0xcbf29ce484222325n;
12
+ const FNV_PRIME = 0x100000001b3n;
13
+ const MASK64 = 0xffffffffffffffffn;
14
+ function fnv1a64(s) {
15
+ let h = FNV_OFFSET;
16
+ for (let i = 0; i < s.length; i++) {
17
+ h = (h ^ BigInt(s.charCodeAt(i))) * FNV_PRIME & MASK64;
18
+ }
19
+ return h.toString(16).padStart(16, '0');
20
+ }
21
+ /**
22
+ * A stable content revision for a draft. Callers get this back from
23
+ * `ofw_list_drafts` / `ofw_get_message` and pass it to `ofw_save_draft` /
24
+ * `ofw_delete_draft` as `expectedRevision`.
25
+ *
26
+ * Recipients reduce to a SORTED set of user ids: their display names and
27
+ * `viewedAt` are presentation detail that differs between a list-sourced and a
28
+ * detail-sourced copy of the same draft, and would otherwise produce a false
29
+ * STALE. Fields are length-prefixed so content cannot shift across a field
30
+ * boundary without changing the hash.
31
+ */
32
+ export function draftRevision(d) {
33
+ const ids = [...new Set(d.recipients.map((r) => r.userId))].sort((a, b) => a - b);
34
+ const parts = [d.subject, d.body, String(d.replyToId ?? ''), ids.join(',')];
35
+ return `r1:${fnv1a64(parts.map((p) => `${p.length}:${p}`).join('|'))}`;
36
+ }
37
+ const ServerDraftSchema = z.looseObject({
38
+ subject: z.string().optional(),
39
+ body: z.string().optional(),
40
+ replyToId: z.number().nullable().optional(),
41
+ recipients: z.array(ApiRecipientSchema).optional(),
42
+ });
43
+ function isNotFound(e) {
44
+ return e instanceof Error && /OFW API error: 404\b/.test(e.message);
45
+ }
46
+ /**
47
+ * Read a draft's AUTHORITATIVE state straight from OFW, bypassing the cache.
48
+ *
49
+ * Returns `null` when the draft no longer exists (404). Any other failure
50
+ * throws `DraftFreshnessError`: a freshness check that could not run must
51
+ * abort the write, never wave it through — see the callers in messages.ts.
52
+ */
53
+ export async function fetchServerDraft(client, id) {
54
+ let raw;
55
+ try {
56
+ raw = await client.request('GET', `/pub/v3/messages/${id}`);
57
+ }
58
+ catch (e) {
59
+ if (isNotFound(e))
60
+ return null;
61
+ throw new DraftFreshnessError(`could not read the current state of draft ${id} from OurFamilyWizard: ${e.message}`);
62
+ }
63
+ // An empty/null body is OFW's other way of saying "no such message". Treat
64
+ // it as MISSING — which still ABORTS the write — rather than letting the
65
+ // strict parse throw an opaque shape error.
66
+ if (raw === null || raw === undefined)
67
+ return null;
68
+ const detail = parseLenient(ServerDraftSchema, raw, {
69
+ label: 'ofw-mcp',
70
+ context: 'GET /pub/v3/messages/{id} (draft freshness check)',
71
+ mode: 'strict',
72
+ });
73
+ return {
74
+ subject: detail.subject ?? '',
75
+ body: detail.body ?? '',
76
+ replyToId: detail.replyToId ?? null,
77
+ recipients: mapRecipients(detail.recipients),
78
+ };
79
+ }
80
+ function diffFields(a, b) {
81
+ const changed = [];
82
+ if (a.subject !== b.subject)
83
+ changed.push('subject');
84
+ if (a.body !== b.body)
85
+ changed.push('body');
86
+ if (a.replyToId !== b.replyToId)
87
+ changed.push('replyToId');
88
+ const ids = (d) => [...new Set(d.recipients.map((r) => r.userId))].sort((x, y) => x - y).join(',');
89
+ if (ids(a) !== ids(b))
90
+ changed.push('recipients');
91
+ return changed;
92
+ }
93
+ /**
94
+ * Decide whether it is safe to destroy the server's copy of a draft.
95
+ *
96
+ * Two independent ways to earn FRESH, in priority order:
97
+ *
98
+ * 1. `expectedRevision` matches the live server revision. The caller has named
99
+ * the exact server state it edited from, which is what optimistic
100
+ * concurrency asserts. A stale cached copy alongside a matching token is
101
+ * not evidence of a conflict, so the token wins.
102
+ * 2. No token supplied → the cached base must match the server EXACTLY. This
103
+ * is the safe default: "no token" never means "force".
104
+ *
105
+ * Everything else — server ahead of cache, no cached base to compare, draft
106
+ * gone from the server — refuses.
107
+ */
108
+ export function checkDraftFreshness(input) {
109
+ const { server, cached, expectedRevision } = input;
110
+ if (server === null) {
111
+ return {
112
+ verdict: 'MISSING',
113
+ reason: 'The draft no longer exists on OurFamilyWizard — it may have been sent or deleted elsewhere.',
114
+ changedFields: [],
115
+ };
116
+ }
117
+ if (expectedRevision !== undefined) {
118
+ const actual = draftRevision(server);
119
+ if (expectedRevision === actual) {
120
+ return { verdict: 'FRESH', reason: 'expectedRevision matches the live server draft.', changedFields: [] };
121
+ }
122
+ return {
123
+ verdict: 'STALE',
124
+ reason: `expectedRevision ${expectedRevision} does not match the live server draft (${actual}) — it changed after you read it.`,
125
+ changedFields: cached === null ? [] : diffFields(server, cached),
126
+ };
127
+ }
128
+ if (cached === null) {
129
+ return {
130
+ verdict: 'STALE',
131
+ reason: 'This draft is not in the local cache, so there is no base to confirm the edit against.',
132
+ changedFields: [],
133
+ };
134
+ }
135
+ const changedFields = diffFields(server, cached);
136
+ if (changedFields.length === 0) {
137
+ return { verdict: 'FRESH', reason: 'The cached draft matches the live server draft.', changedFields: [] };
138
+ }
139
+ return {
140
+ verdict: 'STALE',
141
+ reason: `The draft on OurFamilyWizard differs from the cached copy (${changedFields.join(', ')}) — it was edited outside this tool.`,
142
+ changedFields,
143
+ };
144
+ }
145
+ /**
146
+ * Build the structured refusal returned when a destructive draft op is blocked.
147
+ * ALWAYS carries the current server body when there is one, so the content we
148
+ * declined to overwrite is recoverable from the tool result itself.
149
+ */
150
+ export function staleDraftPayload(input) {
151
+ const { error, draftId, verdict, server, cached } = input;
152
+ return {
153
+ error,
154
+ draftId,
155
+ verdict: verdict.verdict,
156
+ reason: verdict.reason,
157
+ ...(verdict.changedFields.length > 0 ? { changedFields: verdict.changedFields } : {}),
158
+ ...(server !== null
159
+ ? { serverBody: server.body, serverSubject: server.subject, serverRevision: draftRevision(server) }
160
+ : {}),
161
+ ...(cached !== null ? { cachedBody: cached.body } : {}),
162
+ recovery: server === null
163
+ ? 'The draft is gone from OurFamilyWizard. Nothing was changed. If you still want this content saved, call ofw_save_draft WITHOUT messageId to create a new draft.'
164
+ : 'Nothing was changed. Merge your edit into serverBody above, then retry with expectedRevision set to serverRevision. Pass force:true only if you intend to discard the server copy shown here.',
165
+ };
166
+ }
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
- import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
2
+ import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage, getDraftsCacheStatus } from '../sync.js';
3
+ import { checkDraftFreshness, draftRevision, fetchServerDraft, staleDraftPayload, } from './draft-freshness.js';
3
4
  import { getAttachmentsDir, getDefaultInlineAttachments, getSyncMaxRequests, getWriteMode } from '../config.js';
4
5
  import { basename, join } from 'node:path';
5
- import { ApiRecipientSchema, expandPath, hasRealView, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
6
+ import { ApiRecipientSchema, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
6
7
  import { parseLenient } from '@chrischall/mcp-utils';
7
8
  // Schemas for the load-bearing fields of each /pub/v3 response this file
8
9
  // reads (issue #83). Loose: unknown keys pass through into cached listData.
@@ -159,6 +160,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
159
160
  chainRootId: null,
160
161
  listData: draftRow.listData,
161
162
  attachments: [],
163
+ // Concurrency token — pass as expectedRevision to ofw_save_draft /
164
+ // ofw_delete_draft to assert you are editing THIS version.
165
+ revision: draftRevision(draftRow),
166
+ cacheStatus: await getDraftsCacheStatus(cache),
162
167
  });
163
168
  }
164
169
  const cached = await cache.getMessage(id);
@@ -376,6 +381,64 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
376
381
  const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join('\n\n');
377
382
  return textResponse(notes ? `${notes}\n\n${text}` : text);
378
383
  });
384
+ async function guardDestructiveDraftOp(input) {
385
+ const { cache, draftId, expectedRevision, force, action } = input;
386
+ const cachedRow = await cache.getDraft(draftId);
387
+ const cached = cachedRow === null ? null : {
388
+ subject: cachedRow.subject,
389
+ body: cachedRow.body,
390
+ recipients: cachedRow.recipients,
391
+ replyToId: cachedRow.replyToId,
392
+ };
393
+ let server;
394
+ try {
395
+ server = await fetchServerDraft(client, draftId);
396
+ }
397
+ catch (e) {
398
+ // fetchServerDraft funnels every non-404 failure into DraftFreshnessError,
399
+ // so anything landing here means the check could not RUN. That is not
400
+ // permission to proceed: a transient 5xx must not degrade into a blind
401
+ // overwrite.
402
+ const reason = e.message;
403
+ if (force) {
404
+ 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.` };
405
+ }
406
+ return {
407
+ ok: false,
408
+ response: jsonErrorResponse({
409
+ error: 'FRESHNESS_CHECK_FAILED',
410
+ draftId,
411
+ reason,
412
+ recovery: 'Nothing was changed. This is usually transient — retry. If it persists, verify the draft on ourfamilywizard.com. Pass force:true only if you accept overwriting a version you have not seen.',
413
+ }),
414
+ };
415
+ }
416
+ const verdict = checkDraftFreshness({ server, cached, expectedRevision });
417
+ if (verdict.verdict === 'FRESH')
418
+ return { ok: true, note: null };
419
+ if (force) {
420
+ // Loud, and the overwritten content rides along in the response so it is
421
+ // recoverable from the tool result itself.
422
+ console.error(`[ofw-mcp] WARNING: force:true overrode a ${verdict.verdict} verdict on draft ${draftId} (${action}). ${verdict.reason}`);
423
+ const echoed = server === null
424
+ ? 'The draft no longer existed on OurFamilyWizard.'
425
+ : `The server version that was overwritten is preserved below under "overwrittenServerDraft".`;
426
+ return {
427
+ ok: true,
428
+ 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)}`,
429
+ };
430
+ }
431
+ return {
432
+ ok: false,
433
+ response: jsonErrorResponse(staleDraftPayload({
434
+ error: verdict.verdict === 'MISSING' ? 'MISSING_DRAFT' : 'STALE_DRAFT',
435
+ draftId,
436
+ verdict,
437
+ server,
438
+ cached,
439
+ })),
440
+ };
441
+ }
379
442
  server.registerTool('ofw_list_drafts', {
380
443
  description: 'List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.',
381
444
  annotations: { readOnlyHint: true },
@@ -386,15 +449,24 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
386
449
  }, async (args) => {
387
450
  const page = args.page ?? 1;
388
451
  const size = args.size ?? 50;
389
- const drafts = await cacheProvider().listDrafts({ page, size });
390
- const payload = drafts.length === 0
391
- ? { drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' }
392
- : { drafts };
452
+ const cache = cacheProvider();
453
+ const cacheStatus = await getDraftsCacheStatus(cache);
454
+ const rows = await cache.listDrafts({ page, size });
455
+ // Every draft carries the concurrency token to echo back on a write, plus
456
+ // whether the last sync actually compared this cache against OFW.
457
+ const drafts = rows.map((d) => ({ ...d, revision: draftRevision(d), cacheStatus }));
458
+ if (drafts.length === 0) {
459
+ return jsonResponse({ drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' });
460
+ }
461
+ const payload = { drafts };
462
+ if (cacheStatus !== 'fresh') {
463
+ payload.note = 'cacheStatus "unverified": the last ofw_sync_messages did not finish checking the drafts folder against OurFamilyWizard, so these bodies may be behind the server (drafts edited in the OFW web app do not bump any timestamp). Run ofw_sync_messages again before relying on them. Writes are guarded regardless — ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
464
+ }
393
465
  return jsonResponse(payload);
394
466
  });
395
467
  if (allowDrafts)
396
468
  server.registerTool('ofw_save_draft', {
397
- description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft — note that under the hood this creates a NEW draft and deletes the old one (OFW\'s update-in-place endpoint silently no-ops while echoing the posted body, so we don\'t use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state.',
469
+ description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft — note that under the hood this creates a NEW draft and deletes the old one (OFW\'s update-in-place endpoint silently no-ops while echoing the posted body, so we don\'t use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if it 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). The refusal returns the current server body under serverBody — merge your edit into it and retry with expectedRevision.',
398
470
  annotations: { readOnlyHint: false },
399
471
  inputSchema: {
400
472
  subject: z.string().describe('Message subject'),
@@ -403,9 +475,26 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
403
475
  messageId: z.number().describe('ID of an existing draft to replace (the new draft will have a new id; the old is deleted)').optional(),
404
476
  replyToId: z.number().describe('ID of the message this draft replies to').optional(),
405
477
  myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
478
+ expectedRevision: z.string().describe('With messageId: the `revision` you got from ofw_list_drafts/ofw_get_message for that draft. Asserts you are replacing THAT version. If the draft changed on OFW since, the write is refused and the current server body is returned. Omit and the tool compares the server against the local cache instead — omitting never means "overwrite anyway".').optional(),
479
+ force: z.boolean().describe('Default false. Overwrite even when the draft changed on OurFamilyWizard since you read it. The discarded server version is echoed back in the response. Only use after showing the user the conflict.').optional(),
406
480
  },
407
481
  }, async (args) => {
408
482
  const cache = cacheProvider();
483
+ // Guard BEFORE the POST: refusing after creating a replacement would leave
484
+ // a stray draft behind for a write we then decline to finish.
485
+ let forceNote = null;
486
+ if (args.messageId !== undefined) {
487
+ const guard = await guardDestructiveDraftOp({
488
+ cache,
489
+ draftId: args.messageId,
490
+ expectedRevision: args.expectedRevision,
491
+ force: args.force ?? false,
492
+ action: 'replace',
493
+ });
494
+ if (!guard.ok)
495
+ return guard.response;
496
+ forceNote = guard.note;
497
+ }
409
498
  const requestedReplyTo = args.replyToId ?? null;
410
499
  let resolvedReplyTo = requestedReplyTo;
411
500
  let rewriteNote = null;
@@ -435,6 +524,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
435
524
  let persisted = null;
436
525
  let replaceNote = null;
437
526
  let verifyNote = null;
527
+ let newRevision = null;
438
528
  if (newId !== null) {
439
529
  verifyNote = verifyWriteLanded('draft', { subject: args.subject, body: args.body }, detail);
440
530
  persisted = {
@@ -447,6 +537,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
447
537
  listData: detail,
448
538
  };
449
539
  await cache.upsertDraft(persisted);
540
+ newRevision = draftRevision(persisted);
450
541
  // Replace-path: caller passed messageId, so they want the old draft
451
542
  // gone. Delete it after the new one is safely created+cached.
452
543
  if (args.messageId !== undefined && args.messageId !== newId) {
@@ -456,26 +547,44 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
456
547
  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.)`;
457
548
  }
458
549
  catch (e) {
459
- replaceNote = `WARNING: New draft ${newId} created successfully, but failed to delete the old draft (${args.messageId}): ${e.message}. You may want to clean it up manually with ofw_delete_draft.`;
550
+ // Partial-failure safety: the new draft is already created and
551
+ // cached, so BOTH drafts now exist. That is the correct end state —
552
+ // deleting first and failing to create would have lost the content.
553
+ 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.`;
460
554
  }
461
555
  }
462
556
  }
463
- const responseObj = persisted ?? raw;
557
+ const responseObj = persisted !== null
558
+ ? { ...persisted, revision: newRevision, cacheStatus: 'fresh' }
559
+ : raw;
464
560
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
465
- const notes = [rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join('\n\n');
561
+ const notes = [forceNote, rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join('\n\n');
466
562
  return textResponse(notes ? `${notes}\n\n${text}` : text);
467
563
  });
468
564
  if (allowDrafts)
469
565
  server.registerTool('ofw_delete_draft', {
470
- description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.',
566
+ description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache. Before deleting, the draft is re-read from OFW and the delete is REFUSED if it changed since you last read it (the current server body is returned so nothing is lost) — pass expectedRevision to assert which version you mean, or force:true to delete regardless.',
471
567
  annotations: { destructiveHint: true },
472
568
  inputSchema: {
473
569
  messageId: z.number().describe('Draft message ID to delete'),
570
+ expectedRevision: z.string().describe('The `revision` you got from ofw_list_drafts/ofw_get_message. Asserts you are deleting THAT version; if the draft changed on OFW since, the delete is refused and the current server body returned.').optional(),
571
+ force: z.boolean().describe('Default false. Delete even if the draft changed on OurFamilyWizard since you read it. The discarded server version is echoed back in the response.').optional(),
474
572
  },
475
573
  }, async (args) => {
574
+ const cache = cacheProvider();
575
+ const guard = await guardDestructiveDraftOp({
576
+ cache,
577
+ draftId: args.messageId,
578
+ expectedRevision: args.expectedRevision,
579
+ force: args.force ?? false,
580
+ action: 'delete',
581
+ });
582
+ if (!guard.ok)
583
+ return guard.response;
476
584
  const data = await deleteOFWMessages(client, [args.messageId]);
477
- await cacheProvider().deleteDraft(args.messageId);
478
- return data ? jsonResponse(data) : textResponse('Draft deleted.');
585
+ await cache.deleteDraft(args.messageId);
586
+ const text = data ? JSON.stringify(data, null, 2) : 'Draft deleted.';
587
+ return textResponse(guard.note ? `${guard.note}\n\n${text}` : text);
479
588
  });
480
589
  server.registerTool('ofw_get_unread_sent', {
481
590
  description: 'List sent messages that have not been read by one or more recipients. Reads from local cache; call ofw_sync_messages first if cache is stale.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.6.6",
3
+ "version": "2.6.7",
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.6.6",
9
+ "version": "2.6.7",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.6.6",
14
+ "version": "2.6.7",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },