ofw-mcp 2.7.0 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "OurFamilyWizard tools for Claude Code",
9
- "version": "2.7.0"
9
+ "version": "2.7.1"
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.7.0",
17
+ "version": "2.7.1",
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.7.0",
4
+ "version": "2.7.1",
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.7.0",
38410
+ version: "2.7.1",
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)",
@@ -38449,13 +38449,13 @@ var package_default = {
38449
38449
  zod: "^4.4.3"
38450
38450
  },
38451
38451
  devDependencies: {
38452
- "@chrischall/mcp-connector": "^1.0.0",
38452
+ "@chrischall/mcp-connector": "^1.1.1",
38453
38453
  "@cloudflare/vitest-pool-workers": "^0.18.4",
38454
38454
  "@cloudflare/workers-oauth-provider": "^0.8.1",
38455
38455
  "@cloudflare/workers-types": "^5.20260708.1",
38456
38456
  "@types/node": "^26.0.0",
38457
38457
  "@vitest/coverage-v8": "^4.1.7",
38458
- agents: "^0.17.3",
38458
+ agents: "^0.19.0",
38459
38459
  esbuild: "^0.28.0",
38460
38460
  typescript: "^7.0.2",
38461
38461
  vitest: "^4.1.7",
@@ -39408,6 +39408,10 @@ async function fetchServerDraft(client2, id) {
39408
39408
  recipients: mapRecipients(detail.recipients)
39409
39409
  };
39410
39410
  }
39411
+ var SUBSTANTIVE_FIELDS = ["subject", "body", "recipients"];
39412
+ function substantiveChanges(changed) {
39413
+ return changed.filter((f) => SUBSTANTIVE_FIELDS.includes(f));
39414
+ }
39411
39415
  function diffFields(a, b) {
39412
39416
  const changed = [];
39413
39417
  if (a.subject !== b.subject) changed.push("subject");
@@ -39431,6 +39435,17 @@ function checkDraftFreshness(input) {
39431
39435
  if (expectedRevision === actual) {
39432
39436
  return { verdict: "FRESH", reason: "expectedRevision matches the live server draft.", changedFields: [] };
39433
39437
  }
39438
+ if (cached2 !== null && draftRevision(cached2) === expectedRevision) {
39439
+ const changedFields2 = diffFields(server, cached2);
39440
+ if (substantiveChanges(changedFields2).length === 0) {
39441
+ return {
39442
+ verdict: "FRESH",
39443
+ reason: `Only connector-authored metadata (${changedFields2.join(", ")}) changed since you read the draft; its subject, body and recipients are unchanged, so this is not a conflict.`,
39444
+ changedFields: changedFields2,
39445
+ metadataOnly: true
39446
+ };
39447
+ }
39448
+ }
39434
39449
  return {
39435
39450
  verdict: "STALE",
39436
39451
  reason: `expectedRevision ${expectedRevision} does not match the live server draft (${actual}) \u2014 it changed after you read it.`,
@@ -39448,6 +39463,14 @@ function checkDraftFreshness(input) {
39448
39463
  if (changedFields.length === 0) {
39449
39464
  return { verdict: "FRESH", reason: "The cached draft matches the live server draft.", changedFields: [] };
39450
39465
  }
39466
+ if (substantiveChanges(changedFields).length === 0) {
39467
+ return {
39468
+ verdict: "FRESH",
39469
+ reason: `Only connector-authored metadata (${changedFields.join(", ")}) differs from the cached copy; subject, body and recipients match, so this is not a conflict.`,
39470
+ changedFields,
39471
+ metadataOnly: true
39472
+ };
39473
+ }
39451
39474
  return {
39452
39475
  verdict: "STALE",
39453
39476
  reason: `The draft on OurFamilyWizard differs from the cached copy (${changedFields.join(", ")}) \u2014 it was edited outside this tool.`,
@@ -39570,7 +39593,9 @@ var SavedDraftDetailSchema = external_exports.looseObject({
39570
39593
  body: external_exports.string().optional(),
39571
39594
  date: DateSchema.optional(),
39572
39595
  replyToId: external_exports.number().nullable().optional(),
39573
- recipients: external_exports.array(ApiRecipientSchema).optional()
39596
+ recipients: external_exports.array(ApiRecipientSchema).optional(),
39597
+ // Read to audit whether requested myFileIDs actually attached (Defect 3).
39598
+ files: external_exports.array(external_exports.number()).optional()
39574
39599
  });
39575
39600
  var MessageDetailSchema = external_exports.looseObject({
39576
39601
  id: external_exports.number(),
@@ -39935,7 +39960,10 @@ ${text}` : text);
39935
39960
  };
39936
39961
  }
39937
39962
  const verdict = checkDraftFreshness({ server: server2, cached: cached2, expectedRevision });
39938
- if (verdict.verdict === "FRESH") return { ok: true, note: null };
39963
+ if (verdict.verdict === "FRESH") {
39964
+ const note = verdict.metadataOnly ? `NOTE: draft ${draftId} was treated as current for this ${action}. Since you read it, OurFamilyWizard normalized connector-authored metadata (${verdict.changedFields.join(", ")}); the subject, body and recipients are unchanged, so this is not a conflict.` : null;
39965
+ return { ok: true, note };
39966
+ }
39939
39967
  if (force) {
39940
39968
  console.error(`[ofw-mcp] WARNING: force:true overrode a ${verdict.verdict} verdict on draft ${draftId} (${action}). ${verdict.reason}`);
39941
39969
  const echoed = server2 === null ? "The draft no longer existed on OurFamilyWizard." : `The server version that was overwritten is preserved below under "overwrittenServerDraft".`;
@@ -39995,7 +40023,7 @@ ${JSON.stringify(
39995
40023
  return jsonResponse(payload);
39996
40024
  });
39997
40025
  if (allowDrafts) server.registerTool("ofw_save_draft", {
39998
- 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.",
40026
+ description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 note that under the hood this creates a NEW draft and deletes the old one (OFW's update-in-place endpoint silently no-ops while echoing the posted body, so we don't use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response that also lists which fields (subject/body/recipients/replyToId/attachments) were carried over. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state, and the returned `revision` reflects that authoritative state (so it will match on your next edit). FIELD PRESERVATION: the response echoes the effective threading (replyToId/inReplyTo) and, whenever OFW did not carry over a requested replyToId, recipient or attachment, a `warnings[]` entry naming what was dropped \u2014 never a silent null. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody \u2014 merge your edit into it and retry with expectedRevision.",
39999
40027
  annotations: { readOnlyHint: false },
40000
40028
  inputSchema: {
40001
40029
  subject: external_exports.string().describe("Message subject"),
@@ -40050,32 +40078,67 @@ ${JSON.stringify(
40050
40078
  let replaceNote = null;
40051
40079
  let verifyNote = null;
40052
40080
  let newRevision = null;
40081
+ const warnings = [];
40053
40082
  if (newId !== null) {
40054
40083
  verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
40084
+ const effectiveReplyTo = detail.replyToId ?? null;
40085
+ const storedRecipients = mapRecipients(detail.recipients);
40055
40086
  persisted = {
40056
40087
  id: newId,
40057
40088
  subject: detail.subject ?? args.subject,
40058
40089
  body: detail.body ?? "",
40059
- recipients: mapRecipients(detail.recipients),
40060
- replyToId: detail.replyToId ?? resolvedReplyTo,
40090
+ recipients: storedRecipients,
40091
+ replyToId: effectiveReplyTo,
40061
40092
  modifiedAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
40062
40093
  listData: detail
40063
40094
  };
40064
40095
  await cache.upsertDraft(persisted);
40065
40096
  newRevision = draftRevision(persisted);
40097
+ if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
40098
+ const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : "";
40099
+ warnings.push(
40100
+ `replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? "null" : effectiveReplyTo} \u2014 OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped. If threading matters, verify on ourfamilywizard.com.`
40101
+ );
40102
+ }
40103
+ if (args.recipientIds !== void 0 && Array.isArray(detail.recipients)) {
40104
+ const requested = [...new Set(args.recipientIds)].sort((a, b) => a - b);
40105
+ const stored = [...new Set(storedRecipients.map((r) => r.userId))].sort((a, b) => a - b);
40106
+ if (requested.join(",") !== stored.join(",")) {
40107
+ warnings.push(
40108
+ `recipientIds were requested as [${requested.join(", ")}] but the saved draft has [${stored.join(", ")}]. Verify the recipients on ourfamilywizard.com.`
40109
+ );
40110
+ }
40111
+ }
40112
+ if (myFileIDs.length > 0 && Array.isArray(detail.files)) {
40113
+ const storedFiles = new Set(detail.files);
40114
+ const missing = myFileIDs.filter((id) => !storedFiles.has(id));
40115
+ if (missing.length > 0) {
40116
+ warnings.push(
40117
+ `Attachment fileId(s) ${missing.join(", ")} were requested in myFileIDs but are not attached to the saved draft. Re-upload or re-attach if needed.`
40118
+ );
40119
+ }
40120
+ }
40066
40121
  if (args.messageId !== void 0 && args.messageId !== newId) {
40067
40122
  try {
40068
40123
  await deleteOFWMessages(client2, [args.messageId]);
40069
40124
  await cache.deleteDraft(args.messageId);
40070
- 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.)`;
40125
+ replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.) Fields carried over to the new draft: subject, body, recipients (${persisted.recipients.length}), replyToId (${persisted.replyToId === null ? "none" : persisted.replyToId}), attachments (${myFileIDs.length}).${warnings.length > 0 ? " See warnings above for any field OurFamilyWizard did not carry over." : ""}`;
40071
40126
  } catch (e) {
40072
40127
  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.`;
40073
40128
  }
40074
40129
  }
40075
40130
  }
40076
- const responseObj = persisted !== null ? { ...persisted, revision: newRevision, cacheStatus: "fresh", serverConfirmed: true } : raw;
40131
+ const responseObj = persisted !== null ? {
40132
+ ...persisted,
40133
+ inReplyTo: persisted.replyToId,
40134
+ revision: newRevision,
40135
+ cacheStatus: "fresh",
40136
+ serverConfirmed: true,
40137
+ ...warnings.length > 0 ? { warnings } : {}
40138
+ } : raw;
40077
40139
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
40078
- const notes = [forceNote, rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join("\n\n");
40140
+ const warnNote = warnings.length > 0 ? `WARNING: ${warnings.join("\n\n")}` : null;
40141
+ const notes = [forceNote, rewriteNote, verifyNote, warnNote, replaceNote].filter((n) => n !== null).join("\n\n");
40079
40142
  return textResponse(notes ? `${notes}
40080
40143
 
40081
40144
  ${text}` : text);
@@ -41165,7 +41228,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
41165
41228
  var nodeAttachmentIO = new NodeAttachmentIO();
41166
41229
  await runMcp({
41167
41230
  name: "ofw",
41168
- version: "2.7.0",
41231
+ version: "2.7.1",
41169
41232
  // x-release-please-version
41170
41233
  deps: client,
41171
41234
  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.7.0', // x-release-please-version
38
+ version: '2.7.1', // x-release-please-version
39
39
  deps: client,
40
40
  tools: [
41
41
  registerUserTools,
@@ -77,6 +77,22 @@ export async function fetchServerDraft(client, id) {
77
77
  recipients: mapRecipients(detail.recipients),
78
78
  };
79
79
  }
80
+ /**
81
+ * The fields whose divergence constitutes a REAL conflict — the actual message
82
+ * content a caller would lose if we overwrote a copy edited elsewhere. Anything
83
+ * NOT listed here (currently only `replyToId`) is connector/server-authored
84
+ * metadata: OFW normalizes `replyToId` after a draft is saved (dropping it, or
85
+ * re-targeting it to the thread tip), which is the connector's own mutation
86
+ * surfacing later — not third-party interference. A divergence in metadata
87
+ * alone must never refuse the write, or the guard manufactures a false STALE
88
+ * for a change the caller did not make. See issue thread on save/edit round
89
+ * trips.
90
+ */
91
+ const SUBSTANTIVE_FIELDS = ['subject', 'body', 'recipients'];
92
+ /** The substantive subset of a changed-field list (drops metadata like replyToId). */
93
+ function substantiveChanges(changed) {
94
+ return changed.filter((f) => SUBSTANTIVE_FIELDS.includes(f));
95
+ }
80
96
  function diffFields(a, b) {
81
97
  const changed = [];
82
98
  if (a.subject !== b.subject)
@@ -102,8 +118,17 @@ function diffFields(a, b) {
102
118
  * 2. No token supplied → the cached base must match the server EXACTLY. This
103
119
  * is the safe default: "no token" never means "force".
104
120
  *
105
- * Everything else server ahead of cache, no cached base to compare, draft
106
- * gone from the server refuses.
121
+ * In BOTH modes the conflict decision turns on the SUBSTANTIVE fields
122
+ * (subject/body/recipients), not on any revision delta. When the only thing
123
+ * that moved is connector/server-authored metadata — `replyToId` normalized
124
+ * after the save — the content is intact, so it is FRESH (with `metadataOnly`
125
+ * set) rather than STALE. That is the connector's own mutation resurfacing, not
126
+ * a third party editing the draft; refusing it would be a false positive that
127
+ * trains callers to distrust the guard. The fail-safe direction is untouched:
128
+ * the moment subject, body or recipients differ, it still refuses.
129
+ *
130
+ * Everything else — server ahead of cache on real content, no cached base to
131
+ * compare, draft gone from the server — refuses.
107
132
  */
108
133
  export function checkDraftFreshness(input) {
109
134
  const { server, cached, expectedRevision } = input;
@@ -119,6 +144,20 @@ export function checkDraftFreshness(input) {
119
144
  if (expectedRevision === actual) {
120
145
  return { verdict: 'FRESH', reason: 'expectedRevision matches the live server draft.', changedFields: [] };
121
146
  }
147
+ // Token mismatch. When the caller's token is the one WE cached, we can name
148
+ // exactly what drifted — and if that is metadata alone, it is our own
149
+ // post-save normalization, not a conflict.
150
+ if (cached !== null && draftRevision(cached) === expectedRevision) {
151
+ const changedFields = diffFields(server, cached);
152
+ if (substantiveChanges(changedFields).length === 0) {
153
+ return {
154
+ verdict: 'FRESH',
155
+ reason: `Only connector-authored metadata (${changedFields.join(', ')}) changed since you read the draft; its subject, body and recipients are unchanged, so this is not a conflict.`,
156
+ changedFields,
157
+ metadataOnly: true,
158
+ };
159
+ }
160
+ }
122
161
  return {
123
162
  verdict: 'STALE',
124
163
  reason: `expectedRevision ${expectedRevision} does not match the live server draft (${actual}) — it changed after you read it.`,
@@ -136,6 +175,14 @@ export function checkDraftFreshness(input) {
136
175
  if (changedFields.length === 0) {
137
176
  return { verdict: 'FRESH', reason: 'The cached draft matches the live server draft.', changedFields: [] };
138
177
  }
178
+ if (substantiveChanges(changedFields).length === 0) {
179
+ return {
180
+ verdict: 'FRESH',
181
+ reason: `Only connector-authored metadata (${changedFields.join(', ')}) differs from the cached copy; subject, body and recipients match, so this is not a conflict.`,
182
+ changedFields,
183
+ metadataOnly: true,
184
+ };
185
+ }
139
186
  return {
140
187
  verdict: 'STALE',
141
188
  reason: `The draft on OurFamilyWizard differs from the cached copy (${changedFields.join(', ')}) — it was edited outside this tool.`,
@@ -28,6 +28,8 @@ const SavedDraftDetailSchema = z.looseObject({
28
28
  date: DateSchema.optional(),
29
29
  replyToId: z.number().nullable().optional(),
30
30
  recipients: z.array(ApiRecipientSchema).optional(),
31
+ // Read to audit whether requested myFileIDs actually attached (Defect 3).
32
+ files: z.array(z.number()).optional(),
31
33
  });
32
34
  // ofw_get_message's uncached detail fetch — lenient: a mismatch warns to
33
35
  // stderr and the existing ?? fallbacks keep the tool serving.
@@ -497,8 +499,15 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
497
499
  };
498
500
  }
499
501
  const verdict = checkDraftFreshness({ server, cached, expectedRevision });
500
- if (verdict.verdict === 'FRESH')
501
- return { ok: true, note: null };
502
+ if (verdict.verdict === 'FRESH') {
503
+ // A metadata-only "conflict" is the connector's own post-save replyToId
504
+ // normalization catching up — safe to proceed, but say so rather than
505
+ // pretending nothing moved.
506
+ const note = verdict.metadataOnly
507
+ ? `NOTE: draft ${draftId} was treated as current for this ${action}. Since you read it, OurFamilyWizard normalized connector-authored metadata (${verdict.changedFields.join(', ')}); the subject, body and recipients are unchanged, so this is not a conflict.`
508
+ : null;
509
+ return { ok: true, note };
510
+ }
502
511
  if (force) {
503
512
  // Loud, and the overwritten content rides along in the response so it is
504
513
  // recoverable from the tool result itself.
@@ -560,7 +569,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
560
569
  });
561
570
  if (allowDrafts)
562
571
  server.registerTool('ofw_save_draft', {
563
- 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.',
572
+ description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft — note that under the hood this creates a NEW draft and deletes the old one (OFW\'s update-in-place endpoint silently no-ops while echoing the posted body, so we don\'t use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response that also lists which fields (subject/body/recipients/replyToId/attachments) were carried over. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state, and the returned `revision` reflects that authoritative state (so it will match on your next edit). FIELD PRESERVATION: the response echoes the effective threading (replyToId/inReplyTo) and, whenever OFW did not carry over a requested replyToId, recipient or attachment, a `warnings[]` entry naming what was dropped — never a silent null. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody — merge your edit into it and retry with expectedRevision.',
564
573
  annotations: { readOnlyHint: false },
565
574
  inputSchema: {
566
575
  subject: z.string().describe('Message subject'),
@@ -619,26 +628,63 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
619
628
  let replaceNote = null;
620
629
  let verifyNote = null;
621
630
  let newRevision = null;
631
+ // Fields accepted on the write that the saved draft must carry — or their
632
+ // loss must be reported. Never a silent drop (Defect 3).
633
+ const warnings = [];
622
634
  if (newId !== null) {
623
635
  verifyNote = verifyWriteLanded('draft', { subject: args.subject, body: args.body }, detail);
636
+ // Trust the re-fetched server detail as the source of truth for the stored
637
+ // replyToId — NOT `resolvedReplyTo` (what we intended to post). OFW
638
+ // normalizes/drops threading after a save, and masking that with our own
639
+ // intent (the old `detail.replyToId ?? resolvedReplyTo`) both returned a
640
+ // revision that was stale on arrival (Defect 1) and hid a dropped reply
641
+ // link (Defect 3). `?? null` keeps a genuinely-echoed value and reflects a
642
+ // null/absent one honestly.
643
+ const effectiveReplyTo = detail.replyToId ?? null;
644
+ const storedRecipients = mapRecipients(detail.recipients);
624
645
  persisted = {
625
646
  id: newId,
626
647
  subject: detail.subject ?? args.subject,
627
648
  body: detail.body ?? '',
628
- recipients: mapRecipients(detail.recipients),
629
- replyToId: detail.replyToId ?? resolvedReplyTo,
649
+ recipients: storedRecipients,
650
+ replyToId: effectiveReplyTo,
630
651
  modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
631
652
  listData: detail,
632
653
  };
633
654
  await cache.upsertDraft(persisted);
655
+ // The revision is now computed from the server-authoritative detail, so it
656
+ // is the value a subsequent read/verify will observe (Defect 1).
634
657
  newRevision = draftRevision(persisted);
658
+ // Audit every field the caller supplied against what actually landed, so a
659
+ // silent normalization becomes a visible warning rather than a surprise.
660
+ if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
661
+ const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : '';
662
+ warnings.push(`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? 'null' : effectiveReplyTo} — OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped. If threading matters, verify on ourfamilywizard.com.`);
663
+ }
664
+ // Only warn on recipients/attachments when the detail actually reported
665
+ // them — an omitted array is "not echoed", not "dropped", and crying wolf
666
+ // there would desensitize the caller to the real drops.
667
+ if (args.recipientIds !== undefined && Array.isArray(detail.recipients)) {
668
+ const requested = [...new Set(args.recipientIds)].sort((a, b) => a - b);
669
+ const stored = [...new Set(storedRecipients.map((r) => r.userId))].sort((a, b) => a - b);
670
+ if (requested.join(',') !== stored.join(',')) {
671
+ warnings.push(`recipientIds were requested as [${requested.join(', ')}] but the saved draft has [${stored.join(', ')}]. Verify the recipients on ourfamilywizard.com.`);
672
+ }
673
+ }
674
+ if (myFileIDs.length > 0 && Array.isArray(detail.files)) {
675
+ const storedFiles = new Set(detail.files);
676
+ const missing = myFileIDs.filter((id) => !storedFiles.has(id));
677
+ if (missing.length > 0) {
678
+ warnings.push(`Attachment fileId(s) ${missing.join(', ')} were requested in myFileIDs but are not attached to the saved draft. Re-upload or re-attach if needed.`);
679
+ }
680
+ }
635
681
  // Replace-path: caller passed messageId, so they want the old draft
636
682
  // gone. Delete it after the new one is safely created+cached.
637
683
  if (args.messageId !== undefined && args.messageId !== newId) {
638
684
  try {
639
685
  await deleteOFWMessages(client, [args.messageId]);
640
686
  await cache.deleteDraft(args.messageId);
641
- 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.)`;
687
+ replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.) Fields carried over to the new draft: subject, body, recipients (${persisted.recipients.length}), replyToId (${persisted.replyToId === null ? 'none' : persisted.replyToId}), attachments (${myFileIDs.length}).${warnings.length > 0 ? ' See warnings above for any field OurFamilyWizard did not carry over.' : ''}`;
642
688
  }
643
689
  catch (e) {
644
690
  // Partial-failure safety: the new draft is already created and
@@ -650,12 +696,24 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
650
696
  }
651
697
  // The draft was just re-fetched from OFW by postMessageAndRefetch, so this
652
698
  // one row IS server-confirmed regardless of the drafts folder's overall
653
- // cache freshness.
699
+ // cache freshness. `inReplyTo` echoes the effective threading alongside the
700
+ // volatile `id`, and `warnings` names any requested field that did not land.
654
701
  const responseObj = persisted !== null
655
- ? { ...persisted, revision: newRevision, cacheStatus: 'fresh', serverConfirmed: true }
702
+ ? {
703
+ ...persisted,
704
+ inReplyTo: persisted.replyToId,
705
+ revision: newRevision,
706
+ cacheStatus: 'fresh',
707
+ serverConfirmed: true,
708
+ ...(warnings.length > 0 ? { warnings } : {}),
709
+ }
656
710
  : raw;
657
711
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
658
- const notes = [forceNote, rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join('\n\n');
712
+ const warnNote = warnings.length > 0
713
+ ? `WARNING: ${warnings.join('\n\n')}`
714
+ : null;
715
+ const notes = [forceNote, rewriteNote, verifyNote, warnNote, replaceNote]
716
+ .filter((n) => n !== null).join('\n\n');
659
717
  return textResponse(notes ? `${notes}\n\n${text}` : text);
660
718
  });
661
719
  if (allowDrafts)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.7.0",
3
+ "version": "2.7.1",
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,13 +42,13 @@
42
42
  "zod": "^4.4.3"
43
43
  },
44
44
  "devDependencies": {
45
- "@chrischall/mcp-connector": "^1.0.0",
45
+ "@chrischall/mcp-connector": "^1.1.1",
46
46
  "@cloudflare/vitest-pool-workers": "^0.18.4",
47
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",
51
- "agents": "^0.17.3",
51
+ "agents": "^0.19.0",
52
52
  "esbuild": "^0.28.0",
53
53
  "typescript": "^7.0.2",
54
54
  "vitest": "^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.7.0",
9
+ "version": "2.7.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.7.0",
14
+ "version": "2.7.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },