ofw-mcp 2.0.16 → 2.0.18

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.16"
9
+ "version": "2.0.18"
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.16",
17
+ "version": "2.0.18",
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.16",
4
+ "version": "2.0.18",
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/README.md CHANGED
@@ -18,9 +18,31 @@ Ask Claude things like:
18
18
  ## Requirements
19
19
 
20
20
  - [Claude Desktop](https://claude.ai/download)
21
- - [Node.js](https://nodejs.org) 18 or later
21
+ - [Node.js](https://nodejs.org) 22.5 or later (`node:sqlite` is the cache backend)
22
22
  - An active OurFamilyWizard account
23
23
 
24
+ ## Acknowledgement of Terms
25
+
26
+ By using this MCP server, you acknowledge and agree to the following:
27
+
28
+ **1. This server accesses your own OurFamilyWizard account.** Auth happens via your own credentials. It does not — and cannot — access your co-parent's account, your children's accounts, or anyone else's.
29
+
30
+ **2. [OurFamilyWizard's Terms](https://www.ourfamilywizard.com/legal/terms) govern your use of this server**, just as they govern your direct use of OFW. There is no explicit anti-scraping clause; the governing language is broader:
31
+
32
+ > Users may not obtain or attempt to obtain any materials or information through any means not intentionally made available.
33
+
34
+ And on credentials: *"You are solely responsible for (1) maintaining the strict confidentiality of assigned Authentication Methods, (2) instructing any individual to whom the assigned Authentication Method is shared ('Authorized User') to not allow another person to use the Authentication Method."* OFW does contemplate "Authorized Users" and third-party-enabled integrations — but the account holder remains responsible.
35
+
36
+ You are agreeing to those terms — read by the maintainer 2026-05-23 — every time you invoke a tool in this server.
37
+
38
+ **3. Personal, family use only.** This project is not affiliated with, endorsed by, sponsored by, or in partnership with OurFamilyWizard, LLC or its parent. It is a personal automation tool for the named account holder. Do not use it on behalf of a co-parent without their consent, do not share credentials with anyone, and do not use it to bulk-extract another family's data.
39
+
40
+ **4. OFW is a court-of-record platform.** Messages, expenses, calendar entries, and journal entries on OFW may be entered into legal proceedings — including custody, divorce, and parenting-plan-modification cases. Anything this server writes to OFW (drafts you save, events you create, expenses you log) will appear with the same legal weight as if you had typed it yourself. **Do not let this MCP send a message, create an event, or log an expense that you have not read and approved.** Review every write operation before confirming.
41
+
42
+ **5. You accept full responsibility** for any consequences — both technical (account warnings, suspension) and legal (anything OFW records about your account activity). The MCP author is not your attorney; if you're using OFW in connection with an active legal matter, talk to your actual attorney before automating anything.
43
+
44
+ This section is the maintainer's good-faith summary of the terms — it is not legal advice and does not modify or supersede OurFamilyWizard's actual ToS.
45
+
24
46
  ## Installation
25
47
 
26
48
  ### 1. Clone and build
@@ -147,25 +169,32 @@ Read-only tools run automatically. Write tools ask for your confirmation first.
147
169
  ## Development
148
170
 
149
171
  ```bash
150
- npm test # run the test suite
151
- npm run build # compile TypeScript → dist/
172
+ npm test # run the vitest suite
173
+ npm run build # tsc dist/, then esbuild bundle → dist/bundle.js
174
+ npm run dev # node --env-file=.env dist/index.js (requires built dist)
152
175
  ```
153
176
 
177
+ Main is protected. All changes land via PR — open with `gh pr create --label <release-notes-label>` and add `ready-to-merge` once you're satisfied with the auto-review feedback. See `CLAUDE.md` for the full PR + release flow.
178
+
154
179
  ### Project structure
155
180
 
156
181
  ```
157
182
  src/
158
- client.ts OFW auth and HTTP client
159
- index.ts MCP server entry point
160
- tools/
161
- user.ts ofw_get_profile, ofw_get_notifications
162
- messages.ts folders, list, get, send
163
- calendar.ts list, create, update, delete events
164
- expenses.ts totals, list, create
165
- journal.ts list, create entries
166
- tests/
167
- client.test.ts
183
+ index.ts MCP server entry (McpServer + StdioServerTransport)
184
+ client.ts OFW HTTP client with Bearer token + 401/429 retry
185
+ auth.ts resolveAuth(): env-var creds → fetchproxy → error
186
+ auth-password.ts Spring Security form login (legacy env-var path)
187
+ cache.ts SQLite cache (messages, drafts, attachments, sync state)
188
+ sync.ts Folder ID resolution + per-folder sync logic
189
+ config.ts Cache dir, attachment dir, env parsing
168
190
  tools/
191
+ _shared.ts Recipient mapping, response helpers, path expansion
192
+ user.ts ofw_get_profile, ofw_get_notifications
193
+ messages.ts Folders, list, get, send, drafts, sync, attachments
194
+ calendar.ts List, create, update, delete events
195
+ expenses.ts Totals, list, create
196
+ journal.ts List, create entries
197
+ tests/ Mirrors src/; mocks OFWClient.request via vi.spyOn
169
198
  ```
170
199
 
171
200
  ### Auth flow
@@ -9,11 +9,7 @@
9
9
  // This file exists as a standalone helper (not a method on `OFWClient`) so
10
10
  // `resolveAuth()` in `./auth.ts` can call it without a Client instance, and
11
11
  // so tests can mock it at the module boundary.
12
- const BASE_URL = 'https://ofw.ourfamilywizard.com';
13
- const OFW_PROTOCOL_HEADERS = {
14
- 'ofw-client': 'WebApplication',
15
- 'ofw-version': '1.0.0',
16
- };
12
+ import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS } from './protocol.js';
17
13
  export async function loginWithPassword(username, password) {
18
14
  // Step 1: get a SESSION cookie (Spring Security refuses the POST without it).
19
15
  const initResponse = await fetch(`${BASE_URL}/ofw/login.form`, {
@@ -49,9 +45,6 @@ export async function loginWithPassword(username, password) {
49
45
  const data = (await response.json());
50
46
  return {
51
47
  token: data.auth,
52
- // OFW's login endpoint omits expiry. 6h is the empirical TTL and matches
53
- // the historical behavior of this client (a single 401 → re-auth + replay
54
- // covers the edge case where this estimate is wrong).
55
- expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000),
48
+ expiresAt: new Date(Date.now() + OFW_TOKEN_TTL_MS),
56
49
  };
57
50
  }
package/dist/auth.js CHANGED
@@ -47,6 +47,7 @@
47
47
  // selection logic independent of either implementation.
48
48
  import { bootstrap } from '@fetchproxy/bootstrap';
49
49
  import { loginWithPassword } from './auth-password.js';
50
+ import { parseBoolEnv } from './config.js';
50
51
  import pkg from '../package.json' with { type: 'json' };
51
52
  /**
52
53
  * Read an env var, trim, and treat blank / `${UNEXPANDED}` placeholders as
@@ -68,10 +69,7 @@ function readEnv(key) {
68
69
  }
69
70
  /** True if the user has explicitly disabled the fetchproxy fallback. */
70
71
  function fetchproxyDisabled() {
71
- const raw = readEnv('OFW_DISABLE_FETCHPROXY');
72
- if (raw === undefined)
73
- return false;
74
- return ['1', 'true', 'yes', 'on'].includes(raw.toLowerCase());
72
+ return parseBoolEnv('OFW_DISABLE_FETCHPROXY');
75
73
  }
76
74
  /**
77
75
  * Resolve OFW auth using the three-path priority described at the top of
package/dist/bundle.js CHANGED
@@ -34490,7 +34490,7 @@ var StdioServerTransport = class {
34490
34490
  };
34491
34491
 
34492
34492
  // src/client.ts
34493
- import { dirname, join as join2 } from "path";
34493
+ import { dirname, join as join3 } from "path";
34494
34494
  import { fileURLToPath } from "url";
34495
34495
 
34496
34496
  // node_modules/@fetchproxy/protocol/dist/frames.js
@@ -36567,12 +36567,16 @@ var BootstrapDisabledError = class extends Error {
36567
36567
  }
36568
36568
  };
36569
36569
 
36570
- // src/auth-password.ts
36570
+ // src/protocol.ts
36571
36571
  var BASE_URL = "https://ofw.ourfamilywizard.com";
36572
36572
  var OFW_PROTOCOL_HEADERS = {
36573
36573
  "ofw-client": "WebApplication",
36574
36574
  "ofw-version": "1.0.0"
36575
36575
  };
36576
+ var OFW_TOKEN_TTL_MS = 6 * 60 * 60 * 1e3;
36577
+ var OFW_TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1e3;
36578
+
36579
+ // src/auth-password.ts
36576
36580
  async function loginWithPassword(username, password) {
36577
36581
  const initResponse = await fetch(`${BASE_URL}/ofw/login.form`, {
36578
36582
  headers: { ...OFW_PROTOCOL_HEADERS },
@@ -36606,17 +36610,49 @@ async function loginWithPassword(username, password) {
36606
36610
  const data = await response.json();
36607
36611
  return {
36608
36612
  token: data.auth,
36609
- // OFW's login endpoint omits expiry. 6h is the empirical TTL and matches
36610
- // the historical behavior of this client (a single 401 → re-auth + replay
36611
- // covers the edge case where this estimate is wrong).
36612
- expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1e3)
36613
+ expiresAt: new Date(Date.now() + OFW_TOKEN_TTL_MS)
36613
36614
  };
36614
36615
  }
36615
36616
 
36617
+ // src/config.ts
36618
+ import { createHash } from "node:crypto";
36619
+ import { homedir as homedir2 } from "node:os";
36620
+ import { join as join2 } from "node:path";
36621
+ function readCacheIdentity() {
36622
+ const explicit = process.env.OFW_CACHE_IDENTITY;
36623
+ if (typeof explicit === "string" && explicit.trim().length > 0) return explicit.trim();
36624
+ const username = process.env.OFW_USERNAME;
36625
+ if (typeof username === "string" && username.trim().length > 0) return username.trim();
36626
+ return "_default";
36627
+ }
36628
+ function getCacheDir() {
36629
+ const override = process.env.OFW_CACHE_DIR;
36630
+ if (override && override.trim().length > 0) return override.trim();
36631
+ return join2(homedir2(), ".cache", "ofw-mcp");
36632
+ }
36633
+ function getCacheDbPath() {
36634
+ const identity = readCacheIdentity();
36635
+ const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
36636
+ return join2(getCacheDir(), `${hash2}.db`);
36637
+ }
36638
+ function getAttachmentsDir() {
36639
+ const override = process.env.OFW_ATTACHMENTS_DIR;
36640
+ if (override && override.trim().length > 0) return override.trim();
36641
+ return join2(homedir2(), "Downloads", "ofw-mcp");
36642
+ }
36643
+ function parseBoolEnv(name) {
36644
+ const raw = process.env[name];
36645
+ if (typeof raw !== "string") return false;
36646
+ return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
36647
+ }
36648
+ function getDefaultInlineAttachments() {
36649
+ return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
36650
+ }
36651
+
36616
36652
  // package.json
36617
36653
  var package_default = {
36618
36654
  name: "ofw-mcp",
36619
- version: "2.0.16",
36655
+ version: "2.0.18",
36620
36656
  mcpName: "io.github.chrischall/ofw-mcp",
36621
36657
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
36622
36658
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -36668,9 +36704,7 @@ function readEnv(key) {
36668
36704
  return trimmed;
36669
36705
  }
36670
36706
  function fetchproxyDisabled() {
36671
- const raw = readEnv("OFW_DISABLE_FETCHPROXY");
36672
- if (raw === void 0) return false;
36673
- return ["1", "true", "yes", "on"].includes(raw.toLowerCase());
36707
+ return parseBoolEnv("OFW_DISABLE_FETCHPROXY");
36674
36708
  }
36675
36709
  async function resolveAuth() {
36676
36710
  const username = readEnv("OFW_USERNAME");
@@ -36727,14 +36761,9 @@ async function resolveAuth() {
36727
36761
  try {
36728
36762
  const { config: config2 } = await import("dotenv");
36729
36763
  const __dirname = dirname(fileURLToPath(import.meta.url));
36730
- config2({ path: join2(__dirname, "..", ".env"), override: false, quiet: true });
36764
+ config2({ path: join3(__dirname, "..", ".env"), override: false, quiet: true });
36731
36765
  } catch {
36732
36766
  }
36733
- var BASE_URL2 = "https://ofw.ourfamilywizard.com";
36734
- var OFW_PROTOCOL_HEADERS2 = {
36735
- "ofw-client": "WebApplication",
36736
- "ofw-version": "1.0.0"
36737
- };
36738
36767
  function parseContentDispositionFilename(cd) {
36739
36768
  const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
36740
36769
  if (extMatch) {
@@ -36749,8 +36778,7 @@ function parseContentDispositionFilename(cd) {
36749
36778
  return m ? m[1] : null;
36750
36779
  }
36751
36780
  function debugLogEnabled() {
36752
- const v = process.env.OFW_DEBUG_LOG;
36753
- return v === "1" || v === "true" || v === "yes" || v === "on";
36781
+ return parseBoolEnv("OFW_DEBUG_LOG");
36754
36782
  }
36755
36783
  function redactHeaders(h) {
36756
36784
  const out = { ...h };
@@ -36785,12 +36813,12 @@ var OFWClient = class {
36785
36813
  async fetchWithRetry(method, path, body, accept, isRetry) {
36786
36814
  const isFormData = body instanceof FormData;
36787
36815
  const headers = {
36788
- ...OFW_PROTOCOL_HEADERS2,
36816
+ ...OFW_PROTOCOL_HEADERS,
36789
36817
  Accept: accept,
36790
36818
  Authorization: `Bearer ${this.token}`
36791
36819
  };
36792
36820
  if (body !== void 0 && !isFormData) headers["Content-Type"] = "application/json";
36793
- const url2 = `${BASE_URL2}${path}`;
36821
+ const url2 = `${BASE_URL}${path}`;
36794
36822
  if (debugLogEnabled()) {
36795
36823
  const bodyPreview = body === void 0 ? "<none>" : isFormData ? `<FormData entries=${Array.from(body.keys()).join(",")}>` : JSON.stringify(body);
36796
36824
  console.error(`[ofw-debug] \u2192 ${method} ${url2}${isRetry ? " (retry)" : ""}`);
@@ -36838,17 +36866,17 @@ var OFWClient = class {
36838
36866
  async login() {
36839
36867
  const { token, expiresAt } = await resolveAuth();
36840
36868
  this.token = token;
36841
- this.tokenExpiry = expiresAt ?? new Date(Date.now() + 6 * 60 * 60 * 1e3);
36869
+ this.tokenExpiry = expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS);
36842
36870
  }
36843
36871
  isTokenExpiredSoon() {
36844
36872
  if (!this.token || !this.tokenExpiry) return true;
36845
- return this.tokenExpiry.getTime() - Date.now() < 5 * 60 * 1e3;
36873
+ return this.tokenExpiry.getTime() - Date.now() < OFW_TOKEN_EXPIRY_SKEW_MS;
36846
36874
  }
36847
36875
  };
36848
36876
  var client = new OFWClient();
36849
36877
 
36850
36878
  // src/tools/_shared.ts
36851
- import { isAbsolute, join as join3, resolve } from "node:path";
36879
+ import { isAbsolute, join as join4, resolve } from "node:path";
36852
36880
  function jsonResponse(payload) {
36853
36881
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
36854
36882
  }
@@ -36863,9 +36891,20 @@ function mapRecipients(items) {
36863
36891
  }));
36864
36892
  }
36865
36893
  function expandPath(p) {
36866
- const expanded = p.startsWith("~/") ? join3(process.env.HOME ?? "", p.slice(2)) : p;
36894
+ const expanded = p.startsWith("~/") ? join4(process.env.HOME ?? "", p.slice(2)) : p;
36867
36895
  return isAbsolute(expanded) ? expanded : resolve(expanded);
36868
36896
  }
36897
+ async function postMessageAndRefetch(client2, payload) {
36898
+ const raw = await client2.request(
36899
+ "POST",
36900
+ "/pub/v3/messages",
36901
+ payload
36902
+ );
36903
+ const id = typeof raw?.id === "number" ? raw.id : typeof raw?.entityId === "number" ? raw.entityId : null;
36904
+ if (id === null) return { id: null, detail: null, raw };
36905
+ const detail = await client2.request("GET", `/pub/v3/messages/${id}`);
36906
+ return { id, detail, raw };
36907
+ }
36869
36908
 
36870
36909
  // src/tools/user.ts
36871
36910
  function registerUserTools(server2, client2) {
@@ -36889,40 +36928,6 @@ function registerUserTools(server2, client2) {
36889
36928
  import { DatabaseSync } from "node:sqlite";
36890
36929
  import { mkdirSync } from "node:fs";
36891
36930
  import { dirname as dirname2 } from "node:path";
36892
-
36893
- // src/config.ts
36894
- import { createHash } from "node:crypto";
36895
- import { homedir as homedir2 } from "node:os";
36896
- import { join as join4 } from "node:path";
36897
- function readCacheIdentity() {
36898
- const explicit = process.env.OFW_CACHE_IDENTITY;
36899
- if (typeof explicit === "string" && explicit.trim().length > 0) return explicit.trim();
36900
- const username = process.env.OFW_USERNAME;
36901
- if (typeof username === "string" && username.trim().length > 0) return username.trim();
36902
- return "_default";
36903
- }
36904
- function getCacheDir() {
36905
- const override = process.env.OFW_CACHE_DIR;
36906
- if (override && override.trim().length > 0) return override.trim();
36907
- return join4(homedir2(), ".cache", "ofw-mcp");
36908
- }
36909
- function getCacheDbPath() {
36910
- const identity = readCacheIdentity();
36911
- const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
36912
- return join4(getCacheDir(), `${hash2}.db`);
36913
- }
36914
- function getAttachmentsDir() {
36915
- const override = process.env.OFW_ATTACHMENTS_DIR;
36916
- if (override && override.trim().length > 0) return override.trim();
36917
- return join4(homedir2(), "Downloads", "ofw-mcp");
36918
- }
36919
- function getDefaultInlineAttachments() {
36920
- const raw = process.env.OFW_INLINE_ATTACHMENTS;
36921
- if (typeof raw !== "string") return false;
36922
- return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
36923
- }
36924
-
36925
- // src/cache.ts
36926
36931
  var instance = null;
36927
36932
  var SCHEMA_V1 = `
36928
36933
  CREATE TABLE IF NOT EXISTS messages (
@@ -37056,6 +37061,10 @@ function getMessage(id) {
37056
37061
  const r = db.prepare("SELECT * FROM messages WHERE id = ?").get(id);
37057
37062
  return r ? rowFromDb(r) : null;
37058
37063
  }
37064
+ function deleteMessage(id) {
37065
+ const { db } = openCache();
37066
+ db.prepare("DELETE FROM messages WHERE id = ?").run(id);
37067
+ }
37059
37068
  function buildMessageFilter(opts) {
37060
37069
  const wheres = [];
37061
37070
  const params = [];
@@ -37266,12 +37275,7 @@ async function fetchAttachmentMeta(client2, fileId, messageId) {
37266
37275
  });
37267
37276
  }
37268
37277
  async function fetchAttachmentMetaForMessage(client2, messageId, fileIds) {
37269
- for (const fid of fileIds) {
37270
- try {
37271
- await fetchAttachmentMeta(client2, fid, messageId);
37272
- } catch {
37273
- }
37274
- }
37278
+ await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client2, fid, messageId)));
37275
37279
  }
37276
37280
  async function resolveFolderIds(client2) {
37277
37281
  const data = await client2.request(
@@ -37377,6 +37381,7 @@ async function syncDrafts(client2, draftsFolderId) {
37377
37381
  listData: item
37378
37382
  };
37379
37383
  upsertDraft(row);
37384
+ if (getMessage(item.id)) deleteMessage(item.id);
37380
37385
  if (!existing || existing.body !== row.body || existing.subject !== row.subject || existing.replyToId !== row.replyToId) {
37381
37386
  synced++;
37382
37387
  }
@@ -37496,13 +37501,33 @@ function registerMessageTools(server2, client2) {
37496
37501
  return jsonResponse(payload);
37497
37502
  });
37498
37503
  server2.registerTool("ofw_get_message", {
37499
- description: "Get a single OurFamilyWizard message by ID. Reads from local cache when available; otherwise fetches from OFW (which will mark unread inbox messages as read on OFW).",
37504
+ description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW (which will mark unread inbox messages as read on OFW). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) \u2014 drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
37500
37505
  annotations: { readOnlyHint: false },
37501
37506
  inputSchema: {
37502
- messageId: external_exports.string().describe("Message ID")
37507
+ messageId: external_exports.string().describe("Message ID (also accepts draft IDs \u2014 drafts are routed via the drafts cache)")
37503
37508
  }
37504
37509
  }, async (args) => {
37505
37510
  const id = Number(args.messageId);
37511
+ const draftRow = getDraft(id);
37512
+ if (draftRow !== null) {
37513
+ return jsonResponse({
37514
+ id: draftRow.id,
37515
+ folder: "drafts",
37516
+ subject: draftRow.subject,
37517
+ fromUser: "",
37518
+ sentAt: draftRow.modifiedAt,
37519
+ recipients: draftRow.recipients,
37520
+ body: draftRow.body,
37521
+ // Best approximation: drafts don't separately track when the body
37522
+ // was last *fetched* — we last wrote it on the last sync, which
37523
+ // also updates modifiedAt.
37524
+ fetchedBodyAt: draftRow.modifiedAt,
37525
+ replyToId: draftRow.replyToId,
37526
+ chainRootId: null,
37527
+ listData: draftRow.listData,
37528
+ attachments: []
37529
+ });
37530
+ }
37506
37531
  const cached2 = getMessage(id);
37507
37532
  if (cached2 && cached2.body !== null) {
37508
37533
  let attachments2 = listAttachmentsForMessage(id);
@@ -37565,7 +37590,7 @@ function registerMessageTools(server2, client2) {
37565
37590
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
37566
37591
  }
37567
37592
  const myFileIDs = args.myFileIDs ?? [];
37568
- const data = await client2.request("POST", "/pub/v3/messages", {
37593
+ const { id: newId, detail, raw } = await postMessageAndRefetch(client2, {
37569
37594
  subject: args.subject,
37570
37595
  body: args.body,
37571
37596
  recipientIds: args.recipientIds,
@@ -37574,10 +37599,8 @@ function registerMessageTools(server2, client2) {
37574
37599
  includeOriginal: resolvedReplyTo !== null,
37575
37600
  replyToId: resolvedReplyTo
37576
37601
  });
37577
- const newId = typeof data?.id === "number" ? data.id : typeof data?.entityId === "number" ? data.entityId : null;
37578
37602
  let persisted = null;
37579
37603
  if (newId !== null) {
37580
- const detail = await client2.request("GET", `/pub/v3/messages/${newId}`);
37581
37604
  persisted = {
37582
37605
  id: newId,
37583
37606
  folder: "sent",
@@ -37609,7 +37632,7 @@ function registerMessageTools(server2, client2) {
37609
37632
  await deleteOFWMessages(client2, [args.draftId]);
37610
37633
  deleteDraft(args.draftId);
37611
37634
  }
37612
- const responseObj = persisted ?? data;
37635
+ const responseObj = persisted ?? raw;
37613
37636
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
37614
37637
  return textResponse(rewriteNote ? `${rewriteNote}
37615
37638
 
@@ -37630,13 +37653,13 @@ ${text}` : text);
37630
37653
  return jsonResponse(payload);
37631
37654
  });
37632
37655
  server2.registerTool("ofw_save_draft", {
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.",
37656
+ 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.",
37634
37657
  annotations: { readOnlyHint: false },
37635
37658
  inputSchema: {
37636
37659
  subject: external_exports.string().describe("Message subject"),
37637
37660
  body: external_exports.string().describe("Message body text"),
37638
37661
  recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (optional for drafts)").optional(),
37639
- messageId: external_exports.number().describe("ID of an existing draft to update (omit to create a new draft)").optional(),
37662
+ 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(),
37640
37663
  replyToId: external_exports.number().describe("ID of the message this draft replies to").optional(),
37641
37664
  myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional()
37642
37665
  }
@@ -37660,13 +37683,10 @@ ${text}` : text);
37660
37683
  includeOriginal: resolvedReplyTo !== null,
37661
37684
  replyToId: resolvedReplyTo
37662
37685
  };
37663
- if (args.messageId !== void 0) payload.messageId = args.messageId;
37664
- const data = await client2.request("POST", "/pub/v3/messages", payload);
37665
- const newId = typeof data?.id === "number" ? data.id : typeof data?.entityId === "number" ? data.entityId : null;
37686
+ const { id: newId, detail, raw } = await postMessageAndRefetch(client2, payload);
37666
37687
  let persisted = null;
37667
- let noOpWarning = null;
37688
+ let replaceNote = null;
37668
37689
  if (newId !== null) {
37669
- const detail = await client2.request("GET", `/pub/v3/messages/${newId}`);
37670
37690
  persisted = {
37671
37691
  id: newId,
37672
37692
  subject: detail.subject ?? args.subject,
@@ -37677,13 +37697,19 @@ ${text}` : text);
37677
37697
  listData: detail
37678
37698
  };
37679
37699
  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).";
37700
+ if (args.messageId !== void 0 && args.messageId !== newId) {
37701
+ try {
37702
+ await deleteOFWMessages(client2, [args.messageId]);
37703
+ deleteDraft(args.messageId);
37704
+ 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.)`;
37705
+ } catch (e) {
37706
+ 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.`;
37707
+ }
37682
37708
  }
37683
37709
  }
37684
- const responseObj = persisted ?? data;
37710
+ const responseObj = persisted ?? raw;
37685
37711
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
37686
- const notes = [rewriteNote, noOpWarning].filter((n) => n !== null).join("\n\n");
37712
+ const notes = [rewriteNote, replaceNote].filter((n) => n !== null).join("\n\n");
37687
37713
  return textResponse(notes ? `${notes}
37688
37714
 
37689
37715
  ${text}` : text);
@@ -37911,7 +37937,7 @@ function registerCalendarTools(server2, client2) {
37911
37937
  });
37912
37938
  server2.registerTool("ofw_update_event", {
37913
37939
  description: "Update an existing OurFamilyWizard calendar event",
37914
- annotations: { destructiveHint: false },
37940
+ annotations: { destructiveHint: true },
37915
37941
  inputSchema: {
37916
37942
  eventId: external_exports.string(),
37917
37943
  title: external_exports.string().optional(),
@@ -38013,7 +38039,7 @@ process.emit = function(event, ...args) {
38013
38039
  }
38014
38040
  return originalEmit(event, ...args);
38015
38041
  };
38016
- var server = new McpServer({ name: "ofw", version: "2.0.16" });
38042
+ var server = new McpServer({ name: "ofw", version: "2.0.18" });
38017
38043
  registerUserTools(server, client);
38018
38044
  registerMessageTools(server, client);
38019
38045
  registerCalendarTools(server, client);
package/dist/cache.js CHANGED
@@ -131,6 +131,16 @@ export function getMessage(id) {
131
131
  const r = db.prepare('SELECT * FROM messages WHERE id = ?').get(id);
132
132
  return r ? rowFromDb(r) : null;
133
133
  }
134
+ /**
135
+ * Remove a row from the `messages` table. Used by syncDrafts to evict
136
+ * stale rows that were cached when a draft was previously read through
137
+ * `ofw_get_message` (which would have wrongly classified it as `inbox`)
138
+ * — the drafts table is the authoritative source for that id now.
139
+ */
140
+ export function deleteMessage(id) {
141
+ const { db } = openCache();
142
+ db.prepare('DELETE FROM messages WHERE id = ?').run(id);
143
+ }
134
144
  // Build the WHERE clause + bound params for message queries. listMessages and
135
145
  // countMessages share this so the filter semantics can't drift.
136
146
  function buildMessageFilter(opts) {
package/dist/client.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { dirname, join } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { resolveAuth } from './auth.js';
4
+ import { parseBoolEnv } from './config.js';
5
+ import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
4
6
  // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb bundle)
5
7
  try {
6
8
  const { config } = await import('dotenv');
@@ -10,11 +12,6 @@ try {
10
12
  catch {
11
13
  // not available — rely on process.env (mcpb sets credentials via mcp_config.env)
12
14
  }
13
- const BASE_URL = 'https://ofw.ourfamilywizard.com';
14
- const OFW_PROTOCOL_HEADERS = {
15
- 'ofw-client': 'WebApplication',
16
- 'ofw-version': '1.0.0',
17
- };
18
15
  // Parse a Content-Disposition header for a filename. Prefers RFC 6266
19
16
  // `filename*=UTF-8''…` (percent-decoded) and falls back to `filename="…"`.
20
17
  function parseContentDispositionFilename(cd) {
@@ -35,8 +32,7 @@ function parseContentDispositionFilename(cd) {
35
32
  // stderr. Authorization is redacted. Bodies are logged in full — set this
36
33
  // only when debugging, never in normal use.
37
34
  function debugLogEnabled() {
38
- const v = process.env.OFW_DEBUG_LOG;
39
- return v === '1' || v === 'true' || v === 'yes' || v === 'on';
35
+ return parseBoolEnv('OFW_DEBUG_LOG');
40
36
  }
41
37
  function redactHeaders(h) {
42
38
  const out = { ...h };
@@ -131,12 +127,12 @@ export class OFWClient {
131
127
  async login() {
132
128
  const { token, expiresAt } = await resolveAuth();
133
129
  this.token = token;
134
- this.tokenExpiry = expiresAt ?? new Date(Date.now() + 6 * 60 * 60 * 1000);
130
+ this.tokenExpiry = expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS);
135
131
  }
136
132
  isTokenExpiredSoon() {
137
133
  if (!this.token || !this.tokenExpiry)
138
134
  return true;
139
- return this.tokenExpiry.getTime() - Date.now() < 5 * 60 * 1000;
135
+ return this.tokenExpiry.getTime() - Date.now() < OFW_TOKEN_EXPIRY_SKEW_MS;
140
136
  }
141
137
  }
142
138
  export const client = new OFWClient();
package/dist/config.js CHANGED
@@ -40,14 +40,22 @@ export function getAttachmentsDir() {
40
40
  // location across macOS/Linux/Windows.
41
41
  return join(homedir(), 'Downloads', 'ofw-mcp');
42
42
  }
43
+ /**
44
+ * True when a boolean-shaped env var is set to "1", "true", "yes", or "on"
45
+ * (case-insensitive, trimmed). Anything else — unset, empty, or other
46
+ * values — is false. Used for OFW_INLINE_ATTACHMENTS, OFW_DISABLE_FETCHPROXY,
47
+ * OFW_DEBUG_LOG, etc.
48
+ */
49
+ export function parseBoolEnv(name) {
50
+ const raw = process.env[name];
51
+ if (typeof raw !== 'string')
52
+ return false;
53
+ return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
54
+ }
43
55
  // Default for ofw_download_attachment's `inline` arg when the caller doesn't
44
56
  // pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
45
57
  // MCP content blocks by default (skipping disk) — useful on sandboxed MCP
46
58
  // hosts where filesystem reads back to the model aren't available.
47
- // Accepts: "1", "true", "yes", "on" (case-insensitive) → true; anything else → false.
48
59
  export function getDefaultInlineAttachments() {
49
- const raw = process.env.OFW_INLINE_ATTACHMENTS;
50
- if (typeof raw !== 'string')
51
- return false;
52
- return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
60
+ return parseBoolEnv('OFW_INLINE_ATTACHMENTS');
53
61
  }
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.16' });
20
+ const server = new McpServer({ name: 'ofw', version: '2.0.18' }); // x-release-please-version
21
21
  registerUserTools(server, client);
22
22
  registerMessageTools(server, client);
23
23
  registerCalendarTools(server, client);
@@ -0,0 +1,17 @@
1
+ // Wire-level constants shared by client.ts (general API calls) and
2
+ // auth-password.ts (form-login). Kept in a leaf module to avoid an import
3
+ // cycle between client.ts → auth.ts → auth-password.ts.
4
+ export const BASE_URL = 'https://ofw.ourfamilywizard.com';
5
+ // Required on every OFW API request. `ofw-version` is the OFW protocol
6
+ // version, not this package's version — do NOT bump it during a release.
7
+ export const OFW_PROTOCOL_HEADERS = {
8
+ 'ofw-client': 'WebApplication',
9
+ 'ofw-version': '1.0.0',
10
+ };
11
+ // OFW doesn't return a token expiry, so we synthesize one. Six hours is
12
+ // empirically long enough to be useful and short enough that the 401
13
+ // re-auth replay path stays a rare event rather than the common case.
14
+ export const OFW_TOKEN_TTL_MS = 6 * 60 * 60 * 1000;
15
+ // How early we treat a token as expiring. Re-auth before this skew so a
16
+ // long-running request doesn't get a stale token mid-flight.
17
+ export const OFW_TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000;
package/dist/sync.js CHANGED
@@ -1,4 +1,4 @@
1
- import { setMeta, upsertMessage, getMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
1
+ import { setMeta, upsertMessage, getMessage, deleteMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
2
2
  import { mapRecipients } from './tools/_shared.js';
3
3
  // Fetches OFW attachment metadata for one file id and writes it to the cache.
4
4
  // Throws on network/HTTP errors — callers in bulk-sync paths wrap this in the
@@ -17,15 +17,11 @@ export async function fetchAttachmentMeta(client, fileId, messageId) {
17
17
  });
18
18
  }
19
19
  export async function fetchAttachmentMetaForMessage(client, messageId, fileIds) {
20
- for (const fid of fileIds) {
21
- // Best-effort: a single bad attachment shouldn't break the surrounding
22
- // sync. The file id stays in the message's listData; the model can
23
- // retry later via ofw_download_attachment, which surfaces the real error.
24
- try {
25
- await fetchAttachmentMeta(client, fid, messageId);
26
- }
27
- catch { /* swallow */ }
28
- }
20
+ // Fan out in parallel — each fetch is independent and the file id stays
21
+ // in listData on failure (model can retry via ofw_download_attachment,
22
+ // which surfaces the real error). Promise.allSettled so one bad
23
+ // attachment doesn't break the surrounding sync.
24
+ await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client, fid, messageId)));
29
25
  }
30
26
  export async function resolveFolderIds(client) {
31
27
  const data = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
@@ -142,6 +138,12 @@ export async function syncDrafts(client, draftsFolderId) {
142
138
  listData: item,
143
139
  };
144
140
  upsertDraft(row);
141
+ // If a stale `messages` row exists for this id (cached by a prior
142
+ // ofw_get_message call before the drafts table knew about this id),
143
+ // evict it. The drafts table is the source of truth for drafts; we
144
+ // don't want ofw_get_message returning a stale messages-table copy.
145
+ if (getMessage(item.id))
146
+ deleteMessage(item.id);
145
147
  if (!existing
146
148
  || existing.body !== row.body
147
149
  || existing.subject !== row.subject
@@ -20,3 +20,30 @@ export function expandPath(p) {
20
20
  const expanded = p.startsWith('~/') ? join(process.env.HOME ?? '', p.slice(2)) : p;
21
21
  return isAbsolute(expanded) ? expanded : resolve(expanded);
22
22
  }
23
+ /**
24
+ * POST a payload to /pub/v3/messages, then immediately GET the detail
25
+ * endpoint for the resulting message id. This is the only correct way to
26
+ * populate the cache after `ofw_send_message` or `ofw_save_draft`:
27
+ *
28
+ * - OFW's POST response is minimal (typically just `{entityId: <id>}`
29
+ * or sometimes legacy `{id: <id>}`), so we can't build a full row
30
+ * from it directly.
31
+ * - Worse, on draft updates OFW returns the same success shape even
32
+ * when the server silently no-ops, so the GET is also how we verify
33
+ * the write landed (callers compare detail.body to args.body).
34
+ *
35
+ * Returns a discriminated union so callers can narrow with
36
+ * `if (result.id !== null)`. When id is null (no id field in the
37
+ * response — never observed in production, but defensive), `raw`
38
+ * carries the POST response so the caller can still surface it.
39
+ */
40
+ export async function postMessageAndRefetch(client, payload) {
41
+ const raw = await client.request('POST', '/pub/v3/messages', payload);
42
+ const id = typeof raw?.id === 'number' ? raw.id
43
+ : typeof raw?.entityId === 'number' ? raw.entityId
44
+ : null;
45
+ if (id === null)
46
+ return { id: null, detail: null, raw };
47
+ const detail = await client.request('GET', `/pub/v3/messages/${id}`);
48
+ return { id, detail, raw };
49
+ }
@@ -36,7 +36,7 @@ export function registerCalendarTools(server, client) {
36
36
  });
37
37
  server.registerTool('ofw_update_event', {
38
38
  description: 'Update an existing OurFamilyWizard calendar event',
39
- annotations: { destructiveHint: false },
39
+ annotations: { destructiveHint: true },
40
40
  inputSchema: {
41
41
  eventId: z.string(),
42
42
  title: z.string().optional(),
@@ -1,10 +1,10 @@
1
1
  import { z } from 'zod';
2
2
  import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
3
- import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, listAttachmentsForMessage, getAttachment, upsertAttachmentForMessage, markAttachmentDownloaded, } from '../cache.js';
3
+ import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, listAttachmentsForMessage, getAttachment, upsertAttachmentForMessage, markAttachmentDownloaded, getDraft, } from '../cache.js';
4
4
  import { getAttachmentsDir, getDefaultInlineAttachments } from '../config.js';
5
5
  import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
6
  import { basename, dirname, extname, join } from 'node:path';
7
- import { expandPath, jsonResponse, mapRecipients, textResponse } from './_shared.js';
7
+ import { expandPath, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse } from './_shared.js';
8
8
  // Lightweight mime sniff from extension. OFW re-derives mime from the filename
9
9
  // server-side anyway, so this is just a polite Content-Type for the Blob.
10
10
  const MIME_BY_EXT = {
@@ -95,13 +95,39 @@ export function registerMessageTools(server, client) {
95
95
  return jsonResponse(payload);
96
96
  });
97
97
  server.registerTool('ofw_get_message', {
98
- description: 'Get a single OurFamilyWizard message by ID. Reads from local cache when available; otherwise fetches from OFW (which will mark unread inbox messages as read on OFW).',
98
+ description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW (which will mark unread inbox messages as read on OFW). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) — drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
99
99
  annotations: { readOnlyHint: false },
100
100
  inputSchema: {
101
- messageId: z.string().describe('Message ID'),
101
+ messageId: z.string().describe('Message ID (also accepts draft IDs — drafts are routed via the drafts cache)'),
102
102
  },
103
103
  }, async (args) => {
104
104
  const id = Number(args.messageId);
105
+ // Draft routing: if this id is in the drafts cache, return a
106
+ // MessageRow-shaped synthesis built from the draft. The drafts table
107
+ // is the source of truth for draft bodies (sync keeps it fresh);
108
+ // the messages-table cache for the same id is stale by construction
109
+ // when ofw_get_message was called on a draft id before sync caught
110
+ // up — see syncDrafts, which also evicts these stale rows.
111
+ const draftRow = getDraft(id);
112
+ if (draftRow !== null) {
113
+ return jsonResponse({
114
+ id: draftRow.id,
115
+ folder: 'drafts',
116
+ subject: draftRow.subject,
117
+ fromUser: '',
118
+ sentAt: draftRow.modifiedAt,
119
+ recipients: draftRow.recipients,
120
+ body: draftRow.body,
121
+ // Best approximation: drafts don't separately track when the body
122
+ // was last *fetched* — we last wrote it on the last sync, which
123
+ // also updates modifiedAt.
124
+ fetchedBodyAt: draftRow.modifiedAt,
125
+ replyToId: draftRow.replyToId,
126
+ chainRootId: null,
127
+ listData: draftRow.listData,
128
+ attachments: [],
129
+ });
130
+ }
105
131
  const cached = getMessage(id);
106
132
  if (cached && cached.body !== null) {
107
133
  let attachments = listAttachmentsForMessage(id);
@@ -173,10 +199,7 @@ export function registerMessageTools(server, client) {
173
199
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
174
200
  }
175
201
  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).
179
- const data = await client.request('POST', '/pub/v3/messages', {
202
+ const { id: newId, detail, raw } = await postMessageAndRefetch(client, {
180
203
  subject: args.subject,
181
204
  body: args.body,
182
205
  recipientIds: args.recipientIds,
@@ -185,12 +208,8 @@ export function registerMessageTools(server, client) {
185
208
  includeOriginal: resolvedReplyTo !== null,
186
209
  replyToId: resolvedReplyTo,
187
210
  });
188
- const newId = typeof data?.id === 'number' ? data.id
189
- : typeof data?.entityId === 'number' ? data.entityId
190
- : null;
191
211
  let persisted = null;
192
212
  if (newId !== null) {
193
- const detail = await client.request('GET', `/pub/v3/messages/${newId}`);
194
213
  persisted = {
195
214
  id: newId,
196
215
  folder: 'sent',
@@ -225,7 +244,7 @@ export function registerMessageTools(server, client) {
225
244
  await deleteOFWMessages(client, [args.draftId]);
226
245
  deleteDraft(args.draftId);
227
246
  }
228
- const responseObj = persisted ?? data;
247
+ const responseObj = persisted ?? raw;
229
248
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
230
249
  return textResponse(rewriteNote ? `${rewriteNote}\n\n${text}` : text);
231
250
  });
@@ -246,13 +265,13 @@ export function registerMessageTools(server, client) {
246
265
  return jsonResponse(payload);
247
266
  });
248
267
  server.registerTool('ofw_save_draft', {
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.',
268
+ 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.',
250
269
  annotations: { readOnlyHint: false },
251
270
  inputSchema: {
252
271
  subject: z.string().describe('Message subject'),
253
272
  body: z.string().describe('Message body text'),
254
273
  recipientIds: z.array(z.number()).describe('Array of recipient user IDs (optional for drafts)').optional(),
255
- messageId: z.number().describe('ID of an existing draft to update (omit to create a new draft)').optional(),
274
+ messageId: z.number().describe('ID of an existing draft to replace (the new draft will have a new id; the old is deleted)').optional(),
256
275
  replyToId: z.number().describe('ID of the message this draft replies to').optional(),
257
276
  myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
258
277
  },
@@ -267,6 +286,12 @@ export function registerMessageTools(server, client) {
267
286
  }
268
287
  }
269
288
  const myFileIDs = args.myFileIDs ?? [];
289
+ // Deliberately do NOT pass `args.messageId` to OFW's POST payload.
290
+ // OFW's update-by-messageId path silently no-ops on subsequent
291
+ // updates while echoing the posted body in the immediate GET — so
292
+ // there is no honest way to detect a failure from the response.
293
+ // We always create a fresh draft; if the caller provided a
294
+ // messageId, we delete the old draft afterward (the "replace" path).
270
295
  const payload = {
271
296
  subject: args.subject,
272
297
  body: args.body,
@@ -276,22 +301,10 @@ export function registerMessageTools(server, client) {
276
301
  includeOriginal: resolvedReplyTo !== null,
277
302
  replyToId: resolvedReplyTo,
278
303
  };
279
- if (args.messageId !== undefined)
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.
287
- const data = await client.request('POST', '/pub/v3/messages', payload);
288
- const newId = typeof data?.id === 'number' ? data.id
289
- : typeof data?.entityId === 'number' ? data.entityId
290
- : null;
304
+ const { id: newId, detail, raw } = await postMessageAndRefetch(client, payload);
291
305
  let persisted = null;
292
- let noOpWarning = null;
306
+ let replaceNote = null;
293
307
  if (newId !== null) {
294
- const detail = await client.request('GET', `/pub/v3/messages/${newId}`);
295
308
  persisted = {
296
309
  id: newId,
297
310
  subject: detail.subject ?? args.subject,
@@ -302,17 +315,22 @@ export function registerMessageTools(server, client) {
302
315
  listData: detail,
303
316
  };
304
317
  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).';
318
+ // Replace-path: caller passed messageId, so they want the old draft
319
+ // gone. Delete it after the new one is safely created+cached.
320
+ if (args.messageId !== undefined && args.messageId !== newId) {
321
+ try {
322
+ await deleteOFWMessages(client, [args.messageId]);
323
+ deleteDraft(args.messageId);
324
+ 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.)`;
325
+ }
326
+ catch (e) {
327
+ 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.`;
328
+ }
311
329
  }
312
330
  }
313
- const responseObj = persisted ?? data;
331
+ const responseObj = persisted ?? raw;
314
332
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
315
- const notes = [rewriteNote, noOpWarning].filter((n) => n !== null).join('\n\n');
333
+ const notes = [rewriteNote, replaceNote].filter((n) => n !== null).join('\n\n');
316
334
  return textResponse(notes ? `${notes}\n\n${text}` : text);
317
335
  });
318
336
  server.registerTool('ofw_delete_draft', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "mcpName": "io.github.chrischall/ofw-mcp",
5
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>",
package/server.json CHANGED
@@ -6,26 +6,26 @@
6
6
  "url": "https://github.com/chrischall/ofw-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.0.16",
9
+ "version": "2.0.18",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.0.16",
14
+ "version": "2.0.18",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
18
18
  "environmentVariables": [
19
19
  {
20
20
  "name": "OFW_USERNAME",
21
- "description": "Your OurFamilyWizard login email address",
22
- "isRequired": true,
21
+ "description": "Your OurFamilyWizard login email address. Optional — if omitted, the server falls back to the fetchproxy browser extension (requires being signed in to ourfamilywizard.com).",
22
+ "isRequired": false,
23
23
  "format": "string"
24
24
  },
25
25
  {
26
26
  "name": "OFW_PASSWORD",
27
- "description": "Your OurFamilyWizard password",
28
- "isRequired": true,
27
+ "description": "Your OurFamilyWizard password. Optional — see OFW_USERNAME.",
28
+ "isRequired": false,
29
29
  "format": "string",
30
30
  "isSecret": true
31
31
  }
@@ -89,13 +89,17 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
89
89
  ### Messages
90
90
  | Tool | Notes |
91
91
  |------|-------|
92
- | `ofw_list_message_folders` | Get folder IDs (inbox, sent, etc.) call this first |
93
- | `ofw_list_messages(folderId)` | List messages in a folder |
94
- | `ofw_get_message(messageId)` | Read a message. ⚠️ Marks unread messages as read. |
95
- | `ofw_send_message(subject, body, recipientIds[], replyToId?, draftId?)` | Send a message. Pass `replyToId` to thread the original message history (like email reply). Pass `draftId` to auto-delete the draft after sending. |
96
- | `ofw_list_drafts` | List saved drafts |
97
- | `ofw_save_draft(subject, body, recipientIds?, messageId?, replyToId?)` | Create or update a draft |
98
- | `ofw_delete_draft(messageId)` | Delete a draft |
92
+ | `ofw_sync_messages(folders?, deep?, fetchUnreadBodies?)` | Sync OFW local cache. **Call first if the cache might be stale.** Returns unread inbox hints (bodies not fetched, to avoid mark-as-read). |
93
+ | `ofw_list_message_folders` | List OFW folders with unread counts. Most reads use the cache; this is mainly for folder IDs and live unread counts. |
94
+ | `ofw_list_messages(folderId?, since?, until?, q?, page?, size?)` | Cache-backed list. Supports folder ("inbox"/"sent"/"both"), date range, and substring search. |
95
+ | `ofw_get_message(messageId)` | Read a message OR draft body. Cache-first. Ids in the drafts cache return `folder: "drafts"`. ⚠️ Falls through to OFW for unread inbox messages, which marks them as read. |
96
+ | `ofw_send_message(subject, body, recipientIds[], replyToId?, draftId?, myFileIDs?)` | Send a message. Pass `replyToId` to thread original history. Pass `draftId` to auto-delete the draft after sending. Pass `myFileIDs` (from `ofw_upload_attachment`) to attach files. |
97
+ | `ofw_get_unread_sent` | Sent messages your co-parent hasn't read yet (from cache). |
98
+ | `ofw_list_drafts` | List saved drafts (cache-backed). |
99
+ | `ofw_save_draft(subject, body, recipientIds?, messageId?, replyToId?, myFileIDs?)` | Create a new draft. Pass `messageId` to **replace** an existing draft: the tool creates a fresh draft and deletes the old one (OFW's update-in-place endpoint silently no-ops). The returned `id` is the NEW id; the response includes a `NOTE` documenting the swap. |
100
+ | `ofw_delete_draft(messageId)` | Delete a draft. |
101
+ | `ofw_upload_attachment(path, shareClass?, label?, description?)` | Upload a local file to My Files; returns a fileId to pass into `myFileIDs`. |
102
+ | `ofw_download_attachment(fileId, inline?, saveTo?, force?)` | Download an attachment. `inline:true` returns bytes as MCP content; default writes to `~/Downloads/ofw-mcp/`. |
99
103
 
100
104
  ### Calendar
101
105
  | Tool | Notes |