ofw-mcp 2.6.3 → 2.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "OurFamilyWizard tools for Claude Code",
9
- "version": "2.6.3"
9
+ "version": "2.6.4"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -14,7 +14,7 @@
14
14
  "displayName": "OurFamilyWizard",
15
15
  "source": "./",
16
16
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
17
- "version": "2.6.3",
17
+ "version": "2.6.4",
18
18
  "author": {
19
19
  "name": "Chris Chall"
20
20
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ofw",
3
3
  "displayName": "OurFamilyWizard",
4
- "version": "2.6.3",
4
+ "version": "2.6.4",
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
@@ -38410,7 +38410,7 @@ async function loginWithPassword(username, password) {
38410
38410
  // package.json
38411
38411
  var package_default = {
38412
38412
  name: "ofw-mcp",
38413
- version: "2.6.3",
38413
+ version: "2.6.4",
38414
38414
  license: "MIT",
38415
38415
  mcpName: "io.github.chrischall/ofw-mcp",
38416
38416
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -38696,19 +38696,46 @@ var client = new OFWClient();
38696
38696
  var jsonResponse = textResult;
38697
38697
  var textResponse = rawTextResult;
38698
38698
  var ApiRecipientSchema = external_exports.looseObject({
38699
- user: external_exports.looseObject({ id: external_exports.number().optional(), name: external_exports.string().optional() }).optional(),
38699
+ // Live OFW payloads key the recipient's id as `userId` (verified against a
38700
+ // real /pub/v3/messages record: `recipients[].user.userId === 3039201`). An
38701
+ // earlier guess read `id`, which is absent — so every normalized recipient
38702
+ // came out with `userId: 0`, breaking any "find my own recipient" match. Both
38703
+ // are accepted (userId first, id fallback) so a backend that ever returns `id`
38704
+ // still resolves.
38705
+ user: external_exports.looseObject({
38706
+ userId: external_exports.number().optional(),
38707
+ id: external_exports.number().optional(),
38708
+ name: external_exports.string().optional()
38709
+ }).optional(),
38700
38710
  viewed: external_exports.looseObject({ dateTime: external_exports.string() }).nullable().optional()
38701
38711
  });
38702
38712
  function mapRecipients(items) {
38703
38713
  return (items ?? []).map((r) => {
38704
38714
  const dt = r.viewed?.dateTime;
38705
38715
  const viewedAt = typeof dt === "string" && !dt.startsWith("1970-01-01") ? dt : null;
38706
- return { userId: r.user?.id ?? 0, name: r.user?.name ?? "", viewedAt };
38716
+ return { userId: r.user?.userId ?? r.user?.id ?? 0, name: r.user?.name ?? "", viewedAt };
38707
38717
  });
38708
38718
  }
38709
38719
  function hasRealView(recipients) {
38710
38720
  return recipients.some((r) => r.viewedAt !== null && !r.viewedAt.startsWith("1970-01-01"));
38711
38721
  }
38722
+ function scrapeSaysRead(listData) {
38723
+ if (typeof listData !== "object" || listData === null) return false;
38724
+ const ld = listData;
38725
+ return ld.read === true || ld.showNeverViewed === false;
38726
+ }
38727
+ function deriveRead(row, selfUserId) {
38728
+ if (row.folder === "inbox") {
38729
+ const viewed = selfUserId !== void 0 ? row.recipients.some((r) => r.userId === selfUserId && r.viewedAt !== null) : row.recipients.some((r) => r.viewedAt !== null);
38730
+ return viewed || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
38731
+ }
38732
+ return row.recipients.some((r) => r.viewedAt !== null) || scrapeSaysRead(row.listData);
38733
+ }
38734
+ function withReadState(row, selfUserId) {
38735
+ const read = deriveRead(row, selfUserId);
38736
+ const listData = typeof row.listData === "object" && row.listData !== null ? { ...row.listData, read, showNeverViewed: !read } : row.listData;
38737
+ return { ...row, read, listData };
38738
+ }
38712
38739
  var expandPath2 = expandPath;
38713
38740
  function verifyWriteLanded(kind, sent, persisted) {
38714
38741
  const mismatches = [];
@@ -39239,7 +39266,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39239
39266
  const cache = cacheProvider();
39240
39267
  const filter = { folder, since: args.since, until: args.until, q: args.q };
39241
39268
  const total = await cache.countMessages(filter);
39242
- const messages = await cache.listMessages({ ...filter, page, size });
39269
+ const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
39243
39270
  const payload = { messages, total, page, size };
39244
39271
  if (total === 0) {
39245
39272
  payload.note = "No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.";
@@ -39312,7 +39339,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39312
39339
  } catch {
39313
39340
  }
39314
39341
  }
39315
- return jsonResponse({ ...row2, attachments: attachments2 });
39342
+ return jsonResponse({ ...withReadState(row2), attachments: attachments2 });
39316
39343
  }
39317
39344
  const detail = parseLenient(
39318
39345
  MessageDetailSchema,
@@ -39344,7 +39371,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39344
39371
  await fetchAttachmentMetaForMessage(client2, detail.id, detail.files, cache);
39345
39372
  }
39346
39373
  const attachments = await cache.listAttachmentsForMessage(detail.id);
39347
- return jsonResponse({ ...row, attachments });
39374
+ return jsonResponse({ ...withReadState(row), attachments });
39348
39375
  });
39349
39376
  if (allowSend) server.registerTool("ofw_send_message", {
39350
39377
  description: "Send a message via OurFamilyWizard. To send an existing draft, pass messageId \u2014 subject/body/recipientIds become optional overrides (missing fields default to the draft's cached values) and the draft is deleted after sending. To send a fresh message, supply subject/body/recipientIds directly. draftId is the legacy spelling of messageId and works the same way. 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.",
@@ -40548,7 +40575,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
40548
40575
  var nodeAttachmentIO = new NodeAttachmentIO();
40549
40576
  await runMcp({
40550
40577
  name: "ofw",
40551
- version: "2.6.3",
40578
+ version: "2.6.4",
40552
40579
  // x-release-please-version
40553
40580
  deps: client,
40554
40581
  tools: [
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
35
35
  // always succeeds before any credential check runs.
36
36
  await runMcp({
37
37
  name: 'ofw',
38
- version: '2.6.3', // x-release-please-version
38
+ version: '2.6.4', // x-release-please-version
39
39
  deps: client,
40
40
  tools: [
41
41
  registerUserTools,
@@ -10,7 +10,17 @@ export const textResponse = rawTextResult;
10
10
  // responses. Used wherever we validate the response of a `/pub/v3/messages*`
11
11
  // call. Loose: unknown keys pass through (and survive into cached listData).
12
12
  export const ApiRecipientSchema = z.looseObject({
13
- user: z.looseObject({ id: z.number().optional(), name: z.string().optional() }).optional(),
13
+ // Live OFW payloads key the recipient's id as `userId` (verified against a
14
+ // real /pub/v3/messages record: `recipients[].user.userId === 3039201`). An
15
+ // earlier guess read `id`, which is absent — so every normalized recipient
16
+ // came out with `userId: 0`, breaking any "find my own recipient" match. Both
17
+ // are accepted (userId first, id fallback) so a backend that ever returns `id`
18
+ // still resolves.
19
+ user: z.looseObject({
20
+ userId: z.number().optional(),
21
+ id: z.number().optional(),
22
+ name: z.string().optional(),
23
+ }).optional(),
14
24
  viewed: z.looseObject({ dateTime: z.string() }).nullable().optional(),
15
25
  });
16
26
  // Translates OFW API recipient shape into the cache's normalized Recipient.
@@ -28,7 +38,7 @@ export function mapRecipients(items) {
28
38
  return (items ?? []).map((r) => {
29
39
  const dt = r.viewed?.dateTime;
30
40
  const viewedAt = typeof dt === 'string' && !dt.startsWith('1970-01-01') ? dt : null;
31
- return { userId: r.user?.id ?? 0, name: r.user?.name ?? '', viewedAt };
41
+ return { userId: r.user?.userId ?? r.user?.id ?? 0, name: r.user?.name ?? '', viewedAt };
32
42
  });
33
43
  }
34
44
  // True if any recipient has a *real* "First Viewed" time — i.e. present and
@@ -40,6 +50,62 @@ export function mapRecipients(items) {
40
50
  export function hasRealView(recipients) {
41
51
  return recipients.some((r) => r.viewedAt !== null && !r.viewedAt.startsWith('1970-01-01'));
42
52
  }
53
+ // True when the once-scraped list flags themselves say the message is read.
54
+ // `showNeverViewed === false` is OFW's reliable "has been viewed" signal (per
55
+ // CLAUDE.md); `read === true` is the inbox list's own flag. Both are only ever
56
+ // captured at first sight, so they can go stale — they raise `read` but never
57
+ // lower it (see deriveRead).
58
+ function scrapeSaysRead(listData) {
59
+ if (typeof listData !== 'object' || listData === null)
60
+ return false;
61
+ const ld = listData;
62
+ return ld.read === true || ld.showNeverViewed === false;
63
+ }
64
+ /**
65
+ * Derive a message's authoritative read state from the cached record itself,
66
+ * rather than trusting the `read`/`showNeverViewed` flags scraped once from the
67
+ * list endpoint. Those flags are frozen at first sight and drift the moment a
68
+ * message is read after caching — most often when a body fetch
69
+ * (`ofw_get_message`) marks an inbox message read on OFW as a side effect,
70
+ * populating `fetchedBodyAt` and the recipient's `viewedAt` but leaving the
71
+ * stale `read: false` behind.
72
+ *
73
+ * The derivation is monotonic — every input can only turn read ON — so a later
74
+ * resync (which re-scrapes the list flags) can never flip a read message back
75
+ * to unread:
76
+ *
77
+ * - INBOX: the account holder is the recipient. When we know our own id
78
+ * (`selfUserId`), that recipient's `viewedAt` is authoritative; otherwise any
79
+ * recipient's `viewedAt` stands in (1:1 co-parent messaging). Fetching the
80
+ * body marks the message read on OFW, so a non-null `fetchedBodyAt` is also
81
+ * read=true. The stale scrape flag is only a last-resort fallback.
82
+ * - SENT: "read" means a *recipient* has opened it — tracked via their
83
+ * `viewedAt` (the detail endpoint's real timestamp) — never our own body
84
+ * fetch, which is always set for sent messages.
85
+ */
86
+ export function deriveRead(row, selfUserId) {
87
+ if (row.folder === 'inbox') {
88
+ const viewed = selfUserId !== undefined
89
+ ? row.recipients.some((r) => r.userId === selfUserId && r.viewedAt !== null)
90
+ : row.recipients.some((r) => r.viewedAt !== null);
91
+ return viewed || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
92
+ }
93
+ return row.recipients.some((r) => r.viewedAt !== null) || scrapeSaysRead(row.listData);
94
+ }
95
+ /**
96
+ * Return the row augmented with an authoritative top-level `read` boolean and a
97
+ * `listData` whose `read`/`showNeverViewed` flags are forced to agree with it —
98
+ * so a single response can never contradict itself (the reported bug: a record
99
+ * carrying `listData.read: false` alongside a populated recipient `viewedAt`).
100
+ * A non-object `listData` (null / legacy string) is passed through untouched.
101
+ */
102
+ export function withReadState(row, selfUserId) {
103
+ const read = deriveRead(row, selfUserId);
104
+ const listData = (typeof row.listData === 'object' && row.listData !== null)
105
+ ? { ...row.listData, read, showNeverViewed: !read }
106
+ : row.listData;
107
+ return { ...row, read, listData };
108
+ }
43
109
  // Expand a user-provided path: ~ → home, relative → absolute. Re-exports
44
110
  // @chrischall/mcp-utils' `expandPath`.
45
111
  export const expandPath = expandPathUtil;
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
3
3
  import { getAttachmentsDir, getDefaultInlineAttachments, getSyncMaxRequests, getWriteMode } from '../config.js';
4
4
  import { basename, join } from 'node:path';
5
- import { ApiRecipientSchema, expandPath, hasRealView, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
5
+ import { ApiRecipientSchema, expandPath, hasRealView, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
6
6
  import { parseLenient } from '@chrischall/mcp-utils';
7
7
  // Schemas for the load-bearing fields of each /pub/v3 response this file
8
8
  // reads (issue #83). Loose: unknown keys pass through into cached listData.
@@ -112,7 +112,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
112
112
  const cache = cacheProvider();
113
113
  const filter = { folder, since: args.since, until: args.until, q: args.q };
114
114
  const total = await cache.countMessages(filter);
115
- const messages = await cache.listMessages({ ...filter, page, size });
115
+ // Reconcile each row's read state at read time: the cached list flags can be
116
+ // stale (a message read after it was first scraped), so `read` is derived
117
+ // from the record's own `viewedAt`/`fetchedBodyAt` and `listData` is forced
118
+ // to agree — see withReadState.
119
+ const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
116
120
  const payload = { messages, total, page, size };
117
121
  if (total === 0) {
118
122
  payload.note = 'No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.';
@@ -205,7 +209,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
205
209
  // Backfill is best-effort. Fall through with whatever we have.
206
210
  }
207
211
  }
208
- return jsonResponse({ ...row, attachments });
212
+ return jsonResponse({ ...withReadState(row), attachments });
209
213
  }
210
214
  const detail = parseLenient(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (ofw_get_message)' });
211
215
  // Derive the folder for a live-fetched message. A cached row (reached here
@@ -240,7 +244,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
240
244
  await fetchAttachmentMetaForMessage(client, detail.id, detail.files, cache);
241
245
  }
242
246
  const attachments = await cache.listAttachmentsForMessage(detail.id);
243
- return jsonResponse({ ...row, attachments });
247
+ return jsonResponse({ ...withReadState(row), attachments });
244
248
  });
245
249
  if (allowSend)
246
250
  server.registerTool('ofw_send_message', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.6.3",
3
+ "version": "2.6.4",
4
4
  "license": "MIT",
5
5
  "mcpName": "io.github.chrischall/ofw-mcp",
6
6
  "description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/ofw-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.6.3",
9
+ "version": "2.6.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.6.3",
14
+ "version": "2.6.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -55,7 +55,7 @@ read on OFW):
55
55
 
56
56
  ```sh
57
57
  curl -s "https://ofw.ourfamilywizard.com/pub/v3/messages/${ID}" "${AUTH_HEADERS[@]}" \
58
- | jq '{id, subject, body, sentAt: .date.dateTime, from: .from.name, files, recipients: [.recipients[] | {id: .user.id, name: .user.name, viewedAt: .viewed.dateTime}]}'
58
+ | jq '{id, subject, body, sentAt: .date.dateTime, from: .from.name, files, recipients: [.recipients[] | {id: .user.userId, name: .user.name, viewedAt: .viewed.dateTime}]}'
59
59
  ```
60
60
 
61
61
  ## 3. Send a message / save a draft (write — confirm-by-re-GET)