ofw-mcp 2.0.14 → 2.0.16

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.0.14"
9
+ "version": "2.0.16"
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.0.14",
17
+ "version": "2.0.16",
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.0.14",
4
+ "version": "2.0.16",
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
@@ -36616,7 +36616,7 @@ async function loginWithPassword(username, password) {
36616
36616
  // package.json
36617
36617
  var package_default = {
36618
36618
  name: "ofw-mcp",
36619
- version: "2.0.14",
36619
+ version: "2.0.16",
36620
36620
  mcpName: "io.github.chrischall/ofw-mcp",
36621
36621
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
36622
36622
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -36748,6 +36748,15 @@ function parseContentDispositionFilename(cd) {
36748
36748
  const m = /filename="?([^";]+)"?/i.exec(cd);
36749
36749
  return m ? m[1] : null;
36750
36750
  }
36751
+ function debugLogEnabled() {
36752
+ const v = process.env.OFW_DEBUG_LOG;
36753
+ return v === "1" || v === "true" || v === "yes" || v === "on";
36754
+ }
36755
+ function redactHeaders(h) {
36756
+ const out = { ...h };
36757
+ if (out.Authorization) out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}\u2026`;
36758
+ return out;
36759
+ }
36751
36760
  var OFWClient = class {
36752
36761
  token = null;
36753
36762
  tokenExpiry = null;
@@ -36755,6 +36764,9 @@ var OFWClient = class {
36755
36764
  await this.ensureAuthenticated();
36756
36765
  const response = await this.fetchWithRetry(method, path, body, "application/json", false);
36757
36766
  const text = await response.text();
36767
+ if (debugLogEnabled()) {
36768
+ console.error(`[ofw-debug] response body: ${text || "<empty>"}`);
36769
+ }
36758
36770
  return text ? JSON.parse(text) : null;
36759
36771
  }
36760
36772
  /** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
@@ -36778,11 +36790,21 @@ var OFWClient = class {
36778
36790
  Authorization: `Bearer ${this.token}`
36779
36791
  };
36780
36792
  if (body !== void 0 && !isFormData) headers["Content-Type"] = "application/json";
36781
- const response = await fetch(`${BASE_URL2}${path}`, {
36793
+ const url2 = `${BASE_URL2}${path}`;
36794
+ if (debugLogEnabled()) {
36795
+ const bodyPreview = body === void 0 ? "<none>" : isFormData ? `<FormData entries=${Array.from(body.keys()).join(",")}>` : JSON.stringify(body);
36796
+ console.error(`[ofw-debug] \u2192 ${method} ${url2}${isRetry ? " (retry)" : ""}`);
36797
+ console.error(`[ofw-debug] headers: ${JSON.stringify(redactHeaders(headers))}`);
36798
+ console.error(`[ofw-debug] body: ${bodyPreview}`);
36799
+ }
36800
+ const response = await fetch(url2, {
36782
36801
  method,
36783
36802
  headers,
36784
36803
  ...body !== void 0 ? { body: isFormData ? body : JSON.stringify(body) } : {}
36785
36804
  });
36805
+ if (debugLogEnabled()) {
36806
+ console.error(`[ofw-debug] \u2190 ${response.status} ${response.statusText}`);
36807
+ }
36786
36808
  if (response.status === 401 && !isRetry) {
36787
36809
  this.token = null;
36788
36810
  this.tokenExpiry = null;
@@ -37344,9 +37366,6 @@ async function syncDrafts(client2, draftsFolderId) {
37344
37366
  seenIds.add(item.id);
37345
37367
  const modifiedAt = item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString();
37346
37368
  const existing = getDraft(item.id);
37347
- if (existing && existing.modifiedAt === modifiedAt) {
37348
- continue;
37349
- }
37350
37369
  const detail = await client2.request("GET", `/pub/v3/messages/${item.id}`);
37351
37370
  const row = {
37352
37371
  id: item.id,
@@ -37358,7 +37377,9 @@ async function syncDrafts(client2, draftsFolderId) {
37358
37377
  listData: item
37359
37378
  };
37360
37379
  upsertDraft(row);
37361
- synced++;
37380
+ if (!existing || existing.body !== row.body || existing.subject !== row.subject || existing.replyToId !== row.replyToId) {
37381
+ synced++;
37382
+ }
37362
37383
  }
37363
37384
  for (const id of listDraftIds()) {
37364
37385
  if (!seenIds.has(id)) deleteDraft(id);
@@ -37520,7 +37541,7 @@ function registerMessageTools(server2, client2) {
37520
37541
  return jsonResponse({ ...row, attachments });
37521
37542
  });
37522
37543
  server2.registerTool("ofw_send_message", {
37523
- description: "Send a message via OurFamilyWizard. If sending from a draft, pass draftId to delete the draft after sending. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs.",
37544
+ description: "Send a message via OurFamilyWizard. If sending from a draft, pass draftId to delete the draft after sending. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.",
37524
37545
  annotations: { destructiveHint: true },
37525
37546
  inputSchema: {
37526
37547
  subject: external_exports.string().describe("Message subject"),
@@ -37553,21 +37574,24 @@ function registerMessageTools(server2, client2) {
37553
37574
  includeOriginal: resolvedReplyTo !== null,
37554
37575
  replyToId: resolvedReplyTo
37555
37576
  });
37556
- if (data && typeof data.id === "number") {
37557
- const row = {
37558
- id: data.id,
37577
+ const newId = typeof data?.id === "number" ? data.id : typeof data?.entityId === "number" ? data.entityId : null;
37578
+ let persisted = null;
37579
+ if (newId !== null) {
37580
+ const detail = await client2.request("GET", `/pub/v3/messages/${newId}`);
37581
+ persisted = {
37582
+ id: newId,
37559
37583
  folder: "sent",
37560
- subject: data.subject ?? args.subject,
37561
- fromUser: data.from?.name ?? "",
37562
- sentAt: data.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
37563
- recipients: mapRecipients(data.recipients),
37564
- body: data.body ?? args.body,
37584
+ subject: detail.subject ?? args.subject,
37585
+ fromUser: detail.from?.name ?? "",
37586
+ sentAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
37587
+ recipients: mapRecipients(detail.recipients),
37588
+ body: detail.body ?? args.body,
37565
37589
  fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
37566
37590
  replyToId: resolvedReplyTo,
37567
37591
  chainRootId,
37568
- listData: data
37592
+ listData: detail
37569
37593
  };
37570
- upsertMessage(row);
37594
+ upsertMessage(persisted);
37571
37595
  for (const fileId of myFileIDs) {
37572
37596
  const existing = getAttachment(fileId);
37573
37597
  upsertAttachmentForMessage({
@@ -37577,7 +37601,7 @@ function registerMessageTools(server2, client2) {
37577
37601
  mimeType: existing?.mimeType ?? "application/octet-stream",
37578
37602
  sizeBytes: existing?.sizeBytes ?? null,
37579
37603
  metadata: existing?.metadata ?? {},
37580
- messageId: data.id
37604
+ messageId: newId
37581
37605
  });
37582
37606
  }
37583
37607
  }
@@ -37585,7 +37609,8 @@ function registerMessageTools(server2, client2) {
37585
37609
  await deleteOFWMessages(client2, [args.draftId]);
37586
37610
  deleteDraft(args.draftId);
37587
37611
  }
37588
- const text = data ? JSON.stringify(data, null, 2) : "Message sent successfully.";
37612
+ const responseObj = persisted ?? data;
37613
+ const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
37589
37614
  return textResponse(rewriteNote ? `${rewriteNote}
37590
37615
 
37591
37616
  ${text}` : text);
@@ -37605,7 +37630,7 @@ ${text}` : text);
37605
37630
  return jsonResponse(payload);
37606
37631
  });
37607
37632
  server2.registerTool("ofw_save_draft", {
37608
- description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. 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.",
37633
+ description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. 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 and verify what was actually persisted; if OFW silently no-ops an update (a known issue with repeated updates to the same draft), the response includes a WARNING note with a workaround.",
37609
37634
  annotations: { readOnlyHint: false },
37610
37635
  inputSchema: {
37611
37636
  subject: external_exports.string().describe("Message subject"),
@@ -37637,20 +37662,29 @@ ${text}` : text);
37637
37662
  };
37638
37663
  if (args.messageId !== void 0) payload.messageId = args.messageId;
37639
37664
  const data = await client2.request("POST", "/pub/v3/messages", payload);
37640
- if (data && typeof data.id === "number") {
37641
- const draft = {
37642
- id: data.id,
37643
- subject: data.subject ?? args.subject,
37644
- body: data.body ?? args.body,
37645
- recipients: mapRecipients(data.recipients),
37646
- replyToId: data.replyToId ?? resolvedReplyTo,
37647
- modifiedAt: data.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
37648
- listData: data
37665
+ const newId = typeof data?.id === "number" ? data.id : typeof data?.entityId === "number" ? data.entityId : null;
37666
+ let persisted = null;
37667
+ let noOpWarning = null;
37668
+ if (newId !== null) {
37669
+ const detail = await client2.request("GET", `/pub/v3/messages/${newId}`);
37670
+ persisted = {
37671
+ id: newId,
37672
+ subject: detail.subject ?? args.subject,
37673
+ body: detail.body ?? "",
37674
+ recipients: mapRecipients(detail.recipients),
37675
+ replyToId: detail.replyToId ?? resolvedReplyTo,
37676
+ modifiedAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
37677
+ listData: detail
37649
37678
  };
37650
- upsertDraft(draft);
37679
+ upsertDraft(persisted);
37680
+ if (args.messageId !== void 0 && persisted.body !== args.body) {
37681
+ noOpWarning = "WARNING: OFW reported success but the draft body it returned does not match the requested update. The OFW POST /pub/v3/messages endpoint can silently no-op on subsequent updates to the same draft. Workaround: delete this draft (ofw_delete_draft) and create a new one (ofw_save_draft without messageId).";
37682
+ }
37651
37683
  }
37652
- const text = data ? JSON.stringify(data, null, 2) : "Draft saved.";
37653
- return textResponse(rewriteNote ? `${rewriteNote}
37684
+ const responseObj = persisted ?? data;
37685
+ const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
37686
+ const notes = [rewriteNote, noOpWarning].filter((n) => n !== null).join("\n\n");
37687
+ return textResponse(notes ? `${notes}
37654
37688
 
37655
37689
  ${text}` : text);
37656
37690
  });
@@ -37979,7 +38013,7 @@ process.emit = function(event, ...args) {
37979
38013
  }
37980
38014
  return originalEmit(event, ...args);
37981
38015
  };
37982
- var server = new McpServer({ name: "ofw", version: "2.0.14" });
38016
+ var server = new McpServer({ name: "ofw", version: "2.0.16" });
37983
38017
  registerUserTools(server, client);
37984
38018
  registerMessageTools(server, client);
37985
38019
  registerCalendarTools(server, client);
package/dist/client.js CHANGED
@@ -31,6 +31,19 @@ function parseContentDispositionFilename(cd) {
31
31
  const m = /filename="?([^";]+)"?/i.exec(cd);
32
32
  return m ? m[1] : null;
33
33
  }
34
+ // Set OFW_DEBUG_LOG=1 (or true/yes/on) to log every OFW request/response to
35
+ // stderr. Authorization is redacted. Bodies are logged in full — set this
36
+ // only when debugging, never in normal use.
37
+ function debugLogEnabled() {
38
+ const v = process.env.OFW_DEBUG_LOG;
39
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
40
+ }
41
+ function redactHeaders(h) {
42
+ const out = { ...h };
43
+ if (out.Authorization)
44
+ out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}…`;
45
+ return out;
46
+ }
34
47
  export class OFWClient {
35
48
  token = null;
36
49
  tokenExpiry = null;
@@ -38,6 +51,9 @@ export class OFWClient {
38
51
  await this.ensureAuthenticated();
39
52
  const response = await this.fetchWithRetry(method, path, body, 'application/json', false);
40
53
  const text = await response.text();
54
+ if (debugLogEnabled()) {
55
+ console.error(`[ofw-debug] response body: ${text || '<empty>'}`);
56
+ }
41
57
  return (text ? JSON.parse(text) : null);
42
58
  }
43
59
  /** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
@@ -62,11 +78,25 @@ export class OFWClient {
62
78
  };
63
79
  if (body !== undefined && !isFormData)
64
80
  headers['Content-Type'] = 'application/json';
65
- const response = await fetch(`${BASE_URL}${path}`, {
81
+ const url = `${BASE_URL}${path}`;
82
+ if (debugLogEnabled()) {
83
+ const bodyPreview = body === undefined
84
+ ? '<none>'
85
+ : isFormData
86
+ ? `<FormData entries=${Array.from(body.keys()).join(',')}>`
87
+ : JSON.stringify(body);
88
+ console.error(`[ofw-debug] → ${method} ${url}${isRetry ? ' (retry)' : ''}`);
89
+ console.error(`[ofw-debug] headers: ${JSON.stringify(redactHeaders(headers))}`);
90
+ console.error(`[ofw-debug] body: ${bodyPreview}`);
91
+ }
92
+ const response = await fetch(url, {
66
93
  method,
67
94
  headers,
68
95
  ...(body !== undefined ? { body: isFormData ? body : JSON.stringify(body) } : {}),
69
96
  });
97
+ if (debugLogEnabled()) {
98
+ console.error(`[ofw-debug] ← ${response.status} ${response.statusText}`);
99
+ }
70
100
  if (response.status === 401 && !isRetry) {
71
101
  this.token = null;
72
102
  this.tokenExpiry = null;
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import { registerMessageTools } from './tools/messages.js';
17
17
  import { registerCalendarTools } from './tools/calendar.js';
18
18
  import { registerExpenseTools } from './tools/expenses.js';
19
19
  import { registerJournalTools } from './tools/journal.js';
20
- const server = new McpServer({ name: 'ofw', version: '2.0.14' });
20
+ const server = new McpServer({ name: 'ofw', version: '2.0.16' });
21
21
  registerUserTools(server, client);
22
22
  registerMessageTools(server, client);
23
23
  registerCalendarTools(server, client);
package/dist/sync.js CHANGED
@@ -127,10 +127,10 @@ export async function syncDrafts(client, draftsFolderId) {
127
127
  for (const item of items) {
128
128
  seenIds.add(item.id);
129
129
  const modifiedAt = item.date?.dateTime ?? new Date().toISOString();
130
+ // OFW's list endpoint's `date.dateTime` is NOT a reliable modification
131
+ // timestamp for drafts — direct UI edits don't bump it — so we can't
132
+ // use it to skip the detail fetch. Always re-fetch; drafts are few.
130
133
  const existing = getDraft(item.id);
131
- if (existing && existing.modifiedAt === modifiedAt) {
132
- continue;
133
- }
134
134
  const detail = await client.request('GET', `/pub/v3/messages/${item.id}`);
135
135
  const row = {
136
136
  id: item.id,
@@ -142,7 +142,12 @@ export async function syncDrafts(client, draftsFolderId) {
142
142
  listData: item,
143
143
  };
144
144
  upsertDraft(row);
145
- synced++;
145
+ if (!existing
146
+ || existing.body !== row.body
147
+ || existing.subject !== row.subject
148
+ || existing.replyToId !== row.replyToId) {
149
+ synced++;
150
+ }
146
151
  }
147
152
  for (const id of listDraftIds()) {
148
153
  if (!seenIds.has(id))
@@ -149,7 +149,7 @@ export function registerMessageTools(server, client) {
149
149
  return jsonResponse({ ...row, attachments });
150
150
  });
151
151
  server.registerTool('ofw_send_message', {
152
- description: 'Send a message via OurFamilyWizard. If sending from a draft, pass draftId to delete the draft after sending. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs.',
152
+ description: 'Send a message via OurFamilyWizard. If sending from a draft, pass draftId to delete the draft after sending. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.',
153
153
  annotations: { destructiveHint: true },
154
154
  inputSchema: {
155
155
  subject: z.string().describe('Message subject'),
@@ -173,6 +173,9 @@ export function registerMessageTools(server, client) {
173
173
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
174
174
  }
175
175
  const myFileIDs = args.myFileIDs ?? [];
176
+ // OFW's POST /pub/v3/messages response is minimal — typically just
177
+ // `{entityId: <id>}` — so the cache write needs to fetch detail
178
+ // afterwards (same shape as ofw_save_draft).
176
179
  const data = await client.request('POST', '/pub/v3/messages', {
177
180
  subject: args.subject,
178
181
  body: args.body,
@@ -182,21 +185,26 @@ export function registerMessageTools(server, client) {
182
185
  includeOriginal: resolvedReplyTo !== null,
183
186
  replyToId: resolvedReplyTo,
184
187
  });
185
- if (data && typeof data.id === 'number') {
186
- const row = {
187
- id: data.id,
188
+ const newId = typeof data?.id === 'number' ? data.id
189
+ : typeof data?.entityId === 'number' ? data.entityId
190
+ : null;
191
+ let persisted = null;
192
+ if (newId !== null) {
193
+ const detail = await client.request('GET', `/pub/v3/messages/${newId}`);
194
+ persisted = {
195
+ id: newId,
188
196
  folder: 'sent',
189
- subject: data.subject ?? args.subject,
190
- fromUser: data.from?.name ?? '',
191
- sentAt: data.date?.dateTime ?? new Date().toISOString(),
192
- recipients: mapRecipients(data.recipients),
193
- body: data.body ?? args.body,
197
+ subject: detail.subject ?? args.subject,
198
+ fromUser: detail.from?.name ?? '',
199
+ sentAt: detail.date?.dateTime ?? new Date().toISOString(),
200
+ recipients: mapRecipients(detail.recipients),
201
+ body: detail.body ?? args.body,
194
202
  fetchedBodyAt: new Date().toISOString(),
195
203
  replyToId: resolvedReplyTo,
196
204
  chainRootId,
197
- listData: data,
205
+ listData: detail,
198
206
  };
199
- upsertMessage(row);
207
+ upsertMessage(persisted);
200
208
  // Link attached files to the new message in the attachments cache.
201
209
  // We may not have full metadata if the upload happened in a prior
202
210
  // session — fall back to what we know.
@@ -209,7 +217,7 @@ export function registerMessageTools(server, client) {
209
217
  mimeType: existing?.mimeType ?? 'application/octet-stream',
210
218
  sizeBytes: existing?.sizeBytes ?? null,
211
219
  metadata: existing?.metadata ?? {},
212
- messageId: data.id,
220
+ messageId: newId,
213
221
  });
214
222
  }
215
223
  }
@@ -217,7 +225,8 @@ export function registerMessageTools(server, client) {
217
225
  await deleteOFWMessages(client, [args.draftId]);
218
226
  deleteDraft(args.draftId);
219
227
  }
220
- const text = data ? JSON.stringify(data, null, 2) : 'Message sent successfully.';
228
+ const responseObj = persisted ?? data;
229
+ const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
221
230
  return textResponse(rewriteNote ? `${rewriteNote}\n\n${text}` : text);
222
231
  });
223
232
  server.registerTool('ofw_list_drafts', {
@@ -237,7 +246,7 @@ export function registerMessageTools(server, client) {
237
246
  return jsonResponse(payload);
238
247
  });
239
248
  server.registerTool('ofw_save_draft', {
240
- description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. 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.',
249
+ description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. 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 and verify what was actually persisted; if OFW silently no-ops an update (a known issue with repeated updates to the same draft), the response includes a WARNING note with a workaround.',
241
250
  annotations: { readOnlyHint: false },
242
251
  inputSchema: {
243
252
  subject: z.string().describe('Message subject'),
@@ -269,21 +278,42 @@ export function registerMessageTools(server, client) {
269
278
  };
270
279
  if (args.messageId !== undefined)
271
280
  payload.messageId = args.messageId;
281
+ // OFW's POST /pub/v3/messages response for drafts is minimal — typically
282
+ // just `{entityId: <id>}` — and worse, it returns the same success shape
283
+ // even when the server silently no-ops on a subsequent update to the
284
+ // same draft. Don't trust the POST response: extract the id from it,
285
+ // then GET the detail endpoint to repopulate the cache from
286
+ // authoritative server state.
272
287
  const data = await client.request('POST', '/pub/v3/messages', payload);
273
- if (data && typeof data.id === 'number') {
274
- const draft = {
275
- id: data.id,
276
- subject: data.subject ?? args.subject,
277
- body: data.body ?? args.body,
278
- recipients: mapRecipients(data.recipients),
279
- replyToId: data.replyToId ?? resolvedReplyTo,
280
- modifiedAt: data.date?.dateTime ?? new Date().toISOString(),
281
- listData: data,
288
+ const newId = typeof data?.id === 'number' ? data.id
289
+ : typeof data?.entityId === 'number' ? data.entityId
290
+ : null;
291
+ let persisted = null;
292
+ let noOpWarning = null;
293
+ if (newId !== null) {
294
+ const detail = await client.request('GET', `/pub/v3/messages/${newId}`);
295
+ persisted = {
296
+ id: newId,
297
+ subject: detail.subject ?? args.subject,
298
+ body: detail.body ?? '',
299
+ recipients: mapRecipients(detail.recipients),
300
+ replyToId: detail.replyToId ?? resolvedReplyTo,
301
+ modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
302
+ listData: detail,
282
303
  };
283
- upsertDraft(draft);
304
+ upsertDraft(persisted);
305
+ // If this was an update (messageId provided) and OFW's reported body
306
+ // doesn't match what we asked it to save, the server silently
307
+ // dropped the change. Warn the caller so the model can take the
308
+ // create-then-delete fallback.
309
+ if (args.messageId !== undefined && persisted.body !== args.body) {
310
+ noOpWarning = 'WARNING: OFW reported success but the draft body it returned does not match the requested update. The OFW POST /pub/v3/messages endpoint can silently no-op on subsequent updates to the same draft. Workaround: delete this draft (ofw_delete_draft) and create a new one (ofw_save_draft without messageId).';
311
+ }
284
312
  }
285
- const text = data ? JSON.stringify(data, null, 2) : 'Draft saved.';
286
- return textResponse(rewriteNote ? `${rewriteNote}\n\n${text}` : text);
313
+ const responseObj = persisted ?? data;
314
+ const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
315
+ const notes = [rewriteNote, noOpWarning].filter((n) => n !== null).join('\n\n');
316
+ return textResponse(notes ? `${notes}\n\n${text}` : text);
287
317
  });
288
318
  server.registerTool('ofw_delete_draft', {
289
319
  description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.',
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.0.14",
3
+ "version": "2.0.16",
4
4
  "mcpName": "io.github.chrischall/ofw-mcp",
5
- "description": "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
5
+ "description": "OurFamilyWizard MCP server for Claude developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
7
7
  "repository": {
8
8
  "type": "git",
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.0.14",
9
+ "version": "2.0.16",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.0.14",
14
+ "version": "2.0.16",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },