ofw-mcp 2.6.5 → 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.5"
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.5",
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.5",
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
@@ -3776,6 +3776,7 @@ var require_fast_uri = __commonJS({
3776
3776
  return uriTokens.join("");
3777
3777
  }
3778
3778
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
3779
+ var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
3779
3780
  function getParseError(parsed, matches) {
3780
3781
  if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
3781
3782
  return 'URI path must start with "/" when authority is present.';
@@ -3805,6 +3806,11 @@ var require_fast_uri = __commonJS({
3805
3806
  uri = "//" + uri;
3806
3807
  }
3807
3808
  }
3809
+ const authorityMatch = uri.match(AUTHORITY_PREFIX);
3810
+ if (authorityMatch !== null && authorityMatch[1].indexOf("\\") !== -1) {
3811
+ parsed.error = "URI authority must not contain a literal backslash.";
3812
+ malformedAuthorityOrPort = true;
3813
+ }
3808
3814
  const matches = uri.match(URI_PARSE);
3809
3815
  if (matches) {
3810
3816
  parsed.scheme = matches[1];
@@ -3848,7 +3854,7 @@ var require_fast_uri = __commonJS({
3848
3854
  if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) {
3849
3855
  if (parsed.host && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) {
3850
3856
  try {
3851
- parsed.host = URL.domainToASCII(parsed.host.toLowerCase());
3857
+ parsed.host = new URL("http://" + parsed.host).hostname;
3852
3858
  } catch (e) {
3853
3859
  parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e;
3854
3860
  }
@@ -4042,7 +4048,7 @@ var require_core = __commonJS({
4042
4048
  constructor(opts = {}) {
4043
4049
  this.schemas = {};
4044
4050
  this.refs = {};
4045
- this.formats = {};
4051
+ this.formats = /* @__PURE__ */ Object.create(null);
4046
4052
  this._compilations = /* @__PURE__ */ new Set();
4047
4053
  this._loading = {};
4048
4054
  this._cache = /* @__PURE__ */ new Map();
@@ -7693,6 +7699,7 @@ var require_receiver = __commonJS({
7693
7699
  this._opcode = 0;
7694
7700
  this._totalPayloadLength = 0;
7695
7701
  this._messageLength = 0;
7702
+ this._numFragments = 0;
7696
7703
  this._fragments = [];
7697
7704
  this._errored = false;
7698
7705
  this._loop = false;
@@ -8043,23 +8050,23 @@ var require_receiver = __commonJS({
8043
8050
  this.controlMessage(data, cb);
8044
8051
  return;
8045
8052
  }
8053
+ if (this._maxFragments > 0 && ++this._numFragments > this._maxFragments) {
8054
+ const error51 = this.createError(
8055
+ RangeError,
8056
+ "Too many message fragments",
8057
+ false,
8058
+ 1008,
8059
+ "WS_ERR_TOO_MANY_BUFFERED_PARTS"
8060
+ );
8061
+ cb(error51);
8062
+ return;
8063
+ }
8046
8064
  if (this._compressed) {
8047
8065
  this._state = INFLATING;
8048
8066
  this.decompress(data, cb);
8049
8067
  return;
8050
8068
  }
8051
8069
  if (data.length) {
8052
- if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
8053
- const error51 = this.createError(
8054
- RangeError,
8055
- "Too many message fragments",
8056
- false,
8057
- 1008,
8058
- "WS_ERR_TOO_MANY_BUFFERED_PARTS"
8059
- );
8060
- cb(error51);
8061
- return;
8062
- }
8063
8070
  this._messageLength = this._totalPayloadLength;
8064
8071
  this._fragments.push(data);
8065
8072
  }
@@ -8089,17 +8096,6 @@ var require_receiver = __commonJS({
8089
8096
  cb(error51);
8090
8097
  return;
8091
8098
  }
8092
- if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
8093
- const error51 = this.createError(
8094
- RangeError,
8095
- "Too many message fragments",
8096
- false,
8097
- 1008,
8098
- "WS_ERR_TOO_MANY_BUFFERED_PARTS"
8099
- );
8100
- cb(error51);
8101
- return;
8102
- }
8103
8099
  this._fragments.push(buf);
8104
8100
  }
8105
8101
  this.dataMessage(cb);
@@ -8122,6 +8118,7 @@ var require_receiver = __commonJS({
8122
8118
  this._totalPayloadLength = 0;
8123
8119
  this._messageLength = 0;
8124
8120
  this._fragmented = 0;
8121
+ this._numFragments = 0;
8125
8122
  this._fragments = [];
8126
8123
  if (this._opcode === 2) {
8127
8124
  let data;
@@ -9622,8 +9619,8 @@ var require_websocket = __commonJS({
9622
9619
  autoPong: true,
9623
9620
  closeTimeout: CLOSE_TIMEOUT,
9624
9621
  protocolVersion: protocolVersions[1],
9625
- maxBufferedChunks: 1024 * 1024,
9626
- maxFragments: 128 * 1024,
9622
+ maxBufferedChunks: 256 * 1024,
9623
+ maxFragments: 16 * 1024,
9627
9624
  maxPayload: 100 * 1024 * 1024,
9628
9625
  skipUTF8Validation: false,
9629
9626
  perMessageDeflate: true,
@@ -10210,9 +10207,9 @@ var require_websocket_server = __commonJS({
10210
10207
  * called
10211
10208
  * @param {Function} [options.handleProtocols] A hook to handle protocols
10212
10209
  * @param {String} [options.host] The hostname where to bind the server
10213
- * @param {Number} [options.maxBufferedChunks=1048576] The maximum number of
10210
+ * @param {Number} [options.maxBufferedChunks=262144] The maximum number of
10214
10211
  * buffered data chunks
10215
- * @param {Number} [options.maxFragments=131072] The maximum number of message
10212
+ * @param {Number} [options.maxFragments=16384] The maximum number of message
10216
10213
  * fragments
10217
10214
  * @param {Number} [options.maxPayload=104857600] The maximum allowed message
10218
10215
  * size
@@ -10235,8 +10232,8 @@ var require_websocket_server = __commonJS({
10235
10232
  options = {
10236
10233
  allowSynchronousEvents: true,
10237
10234
  autoPong: true,
10238
- maxBufferedChunks: 1024 * 1024,
10239
- maxFragments: 128 * 1024,
10235
+ maxBufferedChunks: 256 * 1024,
10236
+ maxFragments: 16 * 1024,
10240
10237
  maxPayload: 100 * 1024 * 1024,
10241
10238
  skipUTF8Validation: false,
10242
10239
  perMessageDeflate: false,
@@ -38410,7 +38407,7 @@ async function loginWithPassword(username, password) {
38410
38407
  // package.json
38411
38408
  var package_default = {
38412
38409
  name: "ofw-mcp",
38413
- version: "2.6.5",
38410
+ version: "2.6.7",
38414
38411
  license: "MIT",
38415
38412
  mcpName: "io.github.chrischall/ofw-mcp",
38416
38413
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -38452,9 +38449,9 @@ var package_default = {
38452
38449
  zod: "^4.4.3"
38453
38450
  },
38454
38451
  devDependencies: {
38455
- "@chrischall/mcp-connector": "^0.1.0",
38452
+ "@chrischall/mcp-connector": "^1.0.0",
38456
38453
  "@cloudflare/vitest-pool-workers": "^0.18.4",
38457
- "@cloudflare/workers-oauth-provider": "^0.0.11",
38454
+ "@cloudflare/workers-oauth-provider": "^0.8.1",
38458
38455
  "@cloudflare/workers-types": "^5.20260708.1",
38459
38456
  "@types/node": "^26.0.0",
38460
38457
  "@vitest/coverage-v8": "^4.1.7",
@@ -38695,6 +38692,9 @@ var client = new OFWClient();
38695
38692
  // src/tools/_shared.ts
38696
38693
  var jsonResponse = textResult;
38697
38694
  var textResponse = rawTextResult;
38695
+ function jsonErrorResponse(data) {
38696
+ return { ...textResult(data), isError: true };
38697
+ }
38698
38698
  var ApiRecipientSchema = external_exports.looseObject({
38699
38699
  // Live OFW payloads key the recipient's id as `userId` (verified against a
38700
38700
  // real /pub/v3/messages record: `recipients[].user.userId === 3039201`). An
@@ -38724,15 +38724,15 @@ function scrapeSaysRead(listData) {
38724
38724
  const ld = listData;
38725
38725
  return ld.read === true || ld.showNeverViewed === false;
38726
38726
  }
38727
- function deriveRead(row, selfUserId) {
38727
+ function deriveRead(row) {
38728
+ const viewedByAnyone = row.recipients.some((r) => r.viewedAt !== null);
38728
38729
  if (row.folder === "inbox") {
38729
- const viewed = selfUserId !== void 0 ? row.recipients.some((r) => r.userId === selfUserId && r.viewedAt !== null) : row.recipients.some((r) => r.viewedAt !== null);
38730
- return viewed || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
38730
+ return viewedByAnyone || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
38731
38731
  }
38732
- return row.recipients.some((r) => r.viewedAt !== null) || scrapeSaysRead(row.listData);
38732
+ return viewedByAnyone || scrapeSaysRead(row.listData);
38733
38733
  }
38734
- function withReadState(row, selfUserId) {
38735
- const read = deriveRead(row, selfUserId);
38734
+ function withReadState(row) {
38735
+ const read = deriveRead(row);
38736
38736
  const listData = typeof row.listData === "object" && row.listData !== null ? { ...row.listData, read, showNeverViewed: !read } : row.listData;
38737
38737
  return { ...row, read, listData };
38738
38738
  }
@@ -39036,12 +39036,20 @@ var DraftDetailSchema = external_exports.looseObject({
39036
39036
  body: external_exports.string().optional(),
39037
39037
  subject: external_exports.string().optional()
39038
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
+ }
39039
39043
  async function syncDrafts(client2, draftsFolderId, store, budget) {
39040
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
+ };
39041
39049
  const items = [];
39042
39050
  let page = 1;
39043
39051
  while (true) {
39044
- if (!b.take()) return { synced: 0, done: false };
39052
+ if (!b.take()) return defer();
39045
39053
  const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
39046
39054
  const list = parseLenient(
39047
39055
  DraftListResponseSchema,
@@ -39055,7 +39063,7 @@ async function syncDrafts(client2, draftsFolderId, store, budget) {
39055
39063
  }
39056
39064
  const rows = [];
39057
39065
  for (const item of items) {
39058
- if (!b.take()) return { synced: 0, done: false };
39066
+ if (!b.take()) return defer();
39059
39067
  const detail = parseLenient(
39060
39068
  DraftDetailSchema,
39061
39069
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
@@ -39088,16 +39096,22 @@ async function syncDrafts(client2, draftsFolderId, store, budget) {
39088
39096
  for (const id of await store.listDraftIds()) {
39089
39097
  if (!seenIds.has(id)) await store.deleteDraft(id);
39090
39098
  }
39099
+ await store.setMeta(DRAFTS_CACHE_STATUS_KEY, "fresh");
39091
39100
  return { synced, done: true };
39092
39101
  }
39093
39102
  async function syncAll(client2, opts, store) {
39094
- 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
+ ];
39095
39108
  const budget = makeBudget(opts.maxRequests ?? Number.POSITIVE_INFINITY);
39096
39109
  budget.take();
39097
39110
  const ids = await resolveFolderIds(client2, store);
39098
39111
  const synced = {};
39099
39112
  let unreadInbox = [];
39100
39113
  let done = true;
39114
+ let draftsUnverified = false;
39101
39115
  for (const folder of folders) {
39102
39116
  if (folder === "inbox") {
39103
39117
  const r = await syncMessageFolder(client2, "inbox", ids.inbox, {
@@ -39118,11 +39132,17 @@ async function syncAll(client2, opts, store) {
39118
39132
  if (!r.done) done = false;
39119
39133
  } else if (folder === "drafts") {
39120
39134
  const r = await syncDrafts(client2, ids.drafts, store, budget);
39121
- synced.drafts = r.synced;
39122
- if (!r.done) done = false;
39135
+ if (r.done) synced.drafts = r.synced;
39136
+ else {
39137
+ draftsUnverified = true;
39138
+ done = false;
39139
+ }
39123
39140
  }
39124
39141
  }
39125
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
+ }
39126
39146
  if (unreadInbox.length > 0) {
39127
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.`);
39128
39148
  }
@@ -39133,6 +39153,116 @@ async function syncAll(client2, opts, store) {
39133
39153
  return { synced, unreadInbox, done, ...note ? { note } : {} };
39134
39154
  }
39135
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
+
39136
39266
  // src/config.ts
39137
39267
  import { createHash } from "node:crypto";
39138
39268
  import { homedir as homedir3 } from "node:os";
@@ -39301,7 +39431,11 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39301
39431
  replyToId: draftRow.replyToId,
39302
39432
  chainRootId: null,
39303
39433
  listData: draftRow.listData,
39304
- 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)
39305
39439
  });
39306
39440
  }
39307
39441
  const cached2 = await cache.getMessage(id);
@@ -39491,6 +39625,60 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39491
39625
 
39492
39626
  ${text}` : text);
39493
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
+ }
39494
39682
  server.registerTool("ofw_list_drafts", {
39495
39683
  description: "List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.",
39496
39684
  annotations: { readOnlyHint: true },
@@ -39501,12 +39689,21 @@ ${text}` : text);
39501
39689
  }, async (args) => {
39502
39690
  const page = args.page ?? 1;
39503
39691
  const size = args.size ?? 50;
39504
- const drafts = await cacheProvider().listDrafts({ page, size });
39505
- 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
+ }
39506
39703
  return jsonResponse(payload);
39507
39704
  });
39508
39705
  if (allowDrafts) server.registerTool("ofw_save_draft", {
39509
- 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.",
39510
39707
  annotations: { readOnlyHint: false },
39511
39708
  inputSchema: {
39512
39709
  subject: external_exports.string().describe("Message subject"),
@@ -39514,10 +39711,24 @@ ${text}` : text);
39514
39711
  recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (optional for drafts)").optional(),
39515
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(),
39516
39713
  replyToId: external_exports.number().describe("ID of the message this draft replies to").optional(),
39517
- 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()
39518
39717
  }
39519
39718
  }, async (args) => {
39520
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
+ }
39521
39732
  const requestedReplyTo = args.replyToId ?? null;
39522
39733
  let resolvedReplyTo = requestedReplyTo;
39523
39734
  let rewriteNote = null;
@@ -39546,6 +39757,7 @@ ${text}` : text);
39546
39757
  let persisted = null;
39547
39758
  let replaceNote = null;
39548
39759
  let verifyNote = null;
39760
+ let newRevision = null;
39549
39761
  if (newId !== null) {
39550
39762
  verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
39551
39763
  persisted = {
@@ -39558,33 +39770,48 @@ ${text}` : text);
39558
39770
  listData: detail
39559
39771
  };
39560
39772
  await cache.upsertDraft(persisted);
39773
+ newRevision = draftRevision(persisted);
39561
39774
  if (args.messageId !== void 0 && args.messageId !== newId) {
39562
39775
  try {
39563
39776
  await deleteOFWMessages(client2, [args.messageId]);
39564
39777
  await cache.deleteDraft(args.messageId);
39565
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.)`;
39566
39779
  } catch (e) {
39567
- 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.`;
39568
39781
  }
39569
39782
  }
39570
39783
  }
39571
- const responseObj = persisted ?? raw;
39784
+ const responseObj = persisted !== null ? { ...persisted, revision: newRevision, cacheStatus: "fresh" } : raw;
39572
39785
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
39573
- 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");
39574
39787
  return textResponse(notes ? `${notes}
39575
39788
 
39576
39789
  ${text}` : text);
39577
39790
  });
39578
39791
  if (allowDrafts) server.registerTool("ofw_delete_draft", {
39579
- 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.",
39580
39793
  annotations: { destructiveHint: true },
39581
39794
  inputSchema: {
39582
- 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()
39583
39798
  }
39584
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;
39585
39809
  const data = await deleteOFWMessages(client2, [args.messageId]);
39586
- await cacheProvider().deleteDraft(args.messageId);
39587
- 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);
39588
39815
  });
39589
39816
  server.registerTool("ofw_get_unread_sent", {
39590
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.",
@@ -40575,7 +40802,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
40575
40802
  var nodeAttachmentIO = new NodeAttachmentIO();
40576
40803
  await runMcp({
40577
40804
  name: "ofw",
40578
- version: "2.6.5",
40805
+ version: "2.6.7",
40579
40806
  // x-release-please-version
40580
40807
  deps: client,
40581
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.5', // 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.5",
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)",
@@ -42,9 +42,9 @@
42
42
  "zod": "^4.4.3"
43
43
  },
44
44
  "devDependencies": {
45
- "@chrischall/mcp-connector": "^0.1.0",
45
+ "@chrischall/mcp-connector": "^1.0.0",
46
46
  "@cloudflare/vitest-pool-workers": "^0.18.4",
47
- "@cloudflare/workers-oauth-provider": "^0.0.11",
47
+ "@cloudflare/workers-oauth-provider": "^0.8.1",
48
48
  "@cloudflare/workers-types": "^5.20260708.1",
49
49
  "@types/node": "^26.0.0",
50
50
  "@vitest/coverage-v8": "^4.1.7",
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.5",
9
+ "version": "2.6.7",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.6.5",
14
+ "version": "2.6.7",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },