ofw-mcp 2.13.0 → 2.15.0

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.
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ process.emit = function (event, ...args) {
12
12
  import { runMcp } from '@chrischall/mcp-utils';
13
13
  import { client } from './client.js';
14
14
  import { registerUserTools } from './tools/user.js';
15
+ import { registerHealthcheckTools } from './tools/healthcheck.js';
15
16
  import { registerMessageTools } from './tools/messages.js';
16
17
  import { registerCalendarTools } from './tools/calendar.js';
17
18
  import { registerExpenseTools } from './tools/expenses.js';
@@ -35,9 +36,10 @@ const nodeAttachmentIO = new NodeAttachmentIO();
35
36
  // always succeeds before any credential check runs.
36
37
  await runMcp({
37
38
  name: 'ofw',
38
- version: '2.13.0', // x-release-please-version
39
+ version: '2.15.0', // x-release-please-version
39
40
  deps: client,
40
41
  tools: [
42
+ registerHealthcheckTools,
41
43
  registerUserTools,
42
44
  (server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO),
43
45
  registerCalendarTools,
@@ -1,18 +1,28 @@
1
- import { expandPath as expandPathUtil, rawTextResult, textResult } from '@chrischall/mcp-utils';
1
+ import { expandPath as expandPathUtil, minifiedResult, rawTextResult } from '@chrischall/mcp-utils';
2
2
  import { z } from 'zod';
3
3
  import { parseLenient } from '@chrischall/mcp-utils';
4
4
  import { normalizeTimestampsInValue } from '../timestamps.js';
5
- // Pretty-printed JSON tool result. Thin wrapper over @chrischall/mcp-utils'
6
- // `textResult`, with one addition: every timestamp in the payload is rewritten
7
- // to ISO-8601 with an explicit offset and paired with a `<field>Display`
8
- // sibling in the operator's zone.
5
+ // JSON tool result, with NO formatting whitespace. Thin wrapper over
6
+ // @chrischall/mcp-utils' `minifiedResult`, with one addition: every timestamp
7
+ // in the payload is rewritten to ISO-8601 with an explicit offset and paired
8
+ // with a `<field>Display` sibling in the operator's zone.
9
9
  //
10
10
  // This is the single seam every structured tool response passes through, which
11
11
  // is the point — normalizing here rather than at each call site is what makes
12
12
  // it impossible for a tool to reintroduce the naive-local values that had
13
13
  // `sentAt` and `fetchedBodyAt` silently disagreeing by the UTC offset.
14
+ //
15
+ // Minified rather than `JSON.stringify(data, null, 2)`: indentation was 23% of
16
+ // a 135 KB message page — about 8,000 tokens a call — and nothing downstream
17
+ // reads it. This server has no `raw` rung (see tools/project.ts), so every
18
+ // response is `compact` or `full` and every response is minified.
19
+ //
20
+ // Whitespace INSIDE a value is untouched. A message body's blank lines are
21
+ // content, and `JSON.stringify` never touches them; the rule is pinned by
22
+ // tests here and in mcp-utils, so do not replace this with a text-level
23
+ // minifier.
14
24
  export function jsonResponse(data) {
15
- return textResult(normalizeTimestampsInValue(data));
25
+ return minifiedResult(normalizeTimestampsInValue(data));
16
26
  }
17
27
  // Raw-string tool result. Wrapper over @chrischall/mcp-utils' `rawTextResult`.
18
28
  export const textResponse = rawTextResult;
@@ -0,0 +1,81 @@
1
+ import { registerCredentialHealthcheckTool } from '@chrischall/mcp-utils/healthcheck';
2
+ import { resolveAuth, isNoAuthConfigured, isBridgeDown } from '../auth.js';
3
+ /**
4
+ * `ofw_healthcheck` — the one call that answers "is this connector working?".
5
+ *
6
+ * OFW had no such tool. `ofw_status` looks like one and is not: it is a
7
+ * heavyweight draft-inventory call, `readOnlyHint: false`, that answers "where
8
+ * do my drafts stand?". Asking it whether auth works spends a drafts sync and
9
+ * still cannot separate "no credential" from "OFW rejected it".
10
+ *
11
+ * The distinction matters most for the two-path auth here: the token comes
12
+ * from either OFW_USERNAME/OFW_PASSWORD or a signed-in browser tab via
13
+ * fetchproxy, and "which of those actually supplied it" is the first thing
14
+ * anyone needs when the connector misbehaves. That is why `source` is
15
+ * reported.
16
+ */
17
+ export function registerHealthcheckTools(server, client,
18
+ /** Seam: the auth resolver, injectable so tests need no network. */
19
+ resolve = resolveAuth) {
20
+ registerCredentialHealthcheckTool({
21
+ server,
22
+ prefix: 'ofw',
23
+ hostLabel: 'ourfamilywizard.com',
24
+ // The same read `ofw_get_profile` makes: authenticated, cheap, and it
25
+ // changes nothing. A healthcheck that marked a message read would be
26
+ // co-parent-visible and irreversible.
27
+ probePath: '/pub/v2/profiles',
28
+ resolveCredential: async () => {
29
+ try {
30
+ const auth = await resolve();
31
+ return {
32
+ source: auth.source,
33
+ // Never the token. Expiry is the fact that explains a connector
34
+ // that worked an hour ago and does not now.
35
+ detail: auth.expiresAt ? { expires_at: auth.expiresAt.toISOString() } : undefined,
36
+ };
37
+ }
38
+ catch (e) {
39
+ // "Nothing is configured" is a CREDENTIAL state, not a failure to
40
+ // check — it earns the `no_credential` arm and its advice. Every
41
+ // other error (a rejected password, a bridge that is down) is a real
42
+ // failure and must keep its own message rather than being flattened
43
+ // into "no credential", which would send someone to set variables
44
+ // that are already set.
45
+ // `isNoAuthConfigured` rather than a prefix match on a copy of the
46
+ // message: the copy would pass this module's own test while silently
47
+ // stopping matching the day auth.ts reworded it, and the failure mode
48
+ // is giving a rejected password the advice meant for a blank setup.
49
+ if (isNoAuthConfigured(e))
50
+ return { source: null };
51
+ throw e;
52
+ }
53
+ },
54
+ probeFn: () => client.request('GET', '/pub/v2/profiles'),
55
+ // A downed bridge is not a missing credential, and since mcp-utils 0.19.3
56
+ // the helper consults this for a `resolveCredential` failure too — so it
57
+ // gets its own arm instead of the `no_credential` copy. That copy could
58
+ // previously only hedge across both cases and point at `error.message`;
59
+ // now each answer names one cause and one fix.
60
+ classifyThrown: (err) => isBridgeDown(err)
61
+ ? {
62
+ kind: 'transport',
63
+ // The upstream `.hint` rides along in `error.message` — it carries
64
+ // the actionable "click the toolbar icon" copy this cannot know.
65
+ hint: 'The fetchproxy bridge is down, so the browser path could not be tried. This is ' +
66
+ 'not a credential problem: OFW_USERNAME/OFW_PASSWORD, if set, were not reached ' +
67
+ 'either. See error.message for the extension-specific fix.',
68
+ }
69
+ : undefined,
70
+ hints: {
71
+ // Now means exactly what it says: nothing is set up. A configured path
72
+ // that was tried and failed no longer lands here.
73
+ no_credential: 'No OFW credential is configured. Either set OFW_USERNAME + OFW_PASSWORD, or install ' +
74
+ 'the fetchproxy extension and sign in to ourfamilywizard.com in a tab (unsetting ' +
75
+ 'OFW_DISABLE_FETCHPROXY if you set it).',
76
+ credential_rejected: 'OurFamilyWizard rejected the credential. If it came from `env`, the password changed or ' +
77
+ 'the account is locked; if from `fetchproxy`, the browser session expired — sign in again ' +
78
+ 'in the tab. Retrying will not fix either.',
79
+ },
80
+ });
81
+ }
@@ -11,6 +11,8 @@ import { basename, join } from 'node:path';
11
11
  import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, reportsThreaded, reportsUnthreaded, textResponse, threadedReplyTo, verifyWriteLanded, withReadState } from './_shared.js';
12
12
  import { parseLenient } from '@chrischall/mcp-utils';
13
13
  import { pageState } from './pagination.js';
14
+ import { MESSAGE_VIEWS, viewDrafts, viewMessages, viewOne } from './project.js';
15
+ import { resolveView, viewParam } from '@chrischall/mcp-utils';
14
16
  // Schemas for the load-bearing fields of each /pub/v3 response this file
15
17
  // reads (issue #83). Loose: unknown keys pass through into cached listData.
16
18
  const DateSchema = z.looseObject({ dateTime: z.string() });
@@ -266,11 +268,15 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
266
268
  q: z.string().describe('Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.').optional(),
267
269
  sort: z.enum(['newest', 'oldest']).describe('Result order: "newest" (default, newest first) or "oldest" (oldest first). This decides which end a truncated page keeps — with "newest" page 1 of a wide date range holds its most RECENT slice, with "oldest" its earliest. Use "oldest" to start at the old end of a range instead of paging to it.').optional(),
268
270
  autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
271
+ view: viewParam(MESSAGE_VIEWS, {
272
+ note: 'compact omits OurFamilyWizard\'s raw `listData` echo, which duplicates this record\'s own id, subject, sentAt, recipients and read flag; the sender is promoted to `from`, and `files`/`replied` are kept. Pass "full" for the echo.',
273
+ }),
269
274
  },
270
275
  }, async (args) => {
271
276
  const page = args.page ?? 1;
272
277
  const size = args.size ?? 50;
273
278
  const sort = args.sort ?? 'newest';
279
+ const view = resolveView(args.view, MESSAGE_VIEWS);
274
280
  const folderArg = args.folderId ?? 'both';
275
281
  let folder;
276
282
  if (folderArg === 'inbox')
@@ -372,7 +378,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
372
378
  payload.autoRefreshed = true;
373
379
  }
374
380
  payload.freshness = freshness;
375
- payload.messages = messages;
381
+ // Projected HERE, at the last possible moment, so `returned`, `complete`
382
+ // and every note above are computed from the rows themselves — a
383
+ // projection must never be able to change what the response claims about
384
+ // its own contents.
385
+ payload.messages = viewMessages(view, messages);
376
386
  return jsonResponse(payload);
377
387
  });
378
388
  server.registerTool('ofw_get_message', {
@@ -381,9 +391,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
381
391
  inputSchema: {
382
392
  messageId: z.string().describe('Message ID (also accepts draft IDs — drafts are routed via the drafts cache)'),
383
393
  allowMarkRead: z.boolean().describe('Default true (the long-standing behaviour). Set false to refuse a fetch that would mark an unread INBOX message as READ on OurFamilyWizard — an irreversible, co-parent-visible change to the record. Reads that cannot stamp anything (a cached body, a sent message, an already-read message) still succeed. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this argument cannot raise.').optional(),
394
+ view: viewParam(MESSAGE_VIEWS, {
395
+ note: 'compact omits OurFamilyWizard\'s raw `listData` echo, which duplicates this record\'s own id, subject, sentAt, recipients and read flag; the sender is promoted to `from`, and `files`/`replied` are kept. Pass "full" for the echo.',
396
+ }),
384
397
  },
385
398
  }, async (args) => {
386
399
  const id = Number(args.messageId);
400
+ const view = resolveView(args.view, MESSAGE_VIEWS);
387
401
  const cache = cacheProvider();
388
402
  // Draft routing: if this id is in the drafts cache, return a
389
403
  // MessageRow-shaped synthesis built from the draft. The drafts table
@@ -403,7 +417,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
403
417
  id: draftRow.id,
404
418
  folder: 'drafts',
405
419
  subject: draftRow.subject,
406
- fromUser: '',
420
+ // One tool, one sender key per rung. `compact` names the sender `from`
421
+ // everywhere else, so emitting `fromUser` here would mean the same
422
+ // tool's compact rung used two different key names depending on
423
+ // whether the id happened to be a draft. `null` rather than '' because
424
+ // a draft has no sender yet — it is unsent, and an empty string reads
425
+ // as a sender whose name we failed to find.
426
+ ...(view === 'compact' ? { from: null } : { fromUser: '' }),
407
427
  sentAt: draftRow.modifiedAt,
408
428
  recipients: draftRow.recipients,
409
429
  body: draftRow.body,
@@ -413,7 +433,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
413
433
  fetchedBodyAt: draftRow.modifiedAt,
414
434
  replyToId: draftRow.replyToId,
415
435
  chainRootId: null,
416
- listData: draftRow.listData,
436
+ // OFW's raw echo, on `full` only. Everything above it is derived from
437
+ // the DRAFTS table, which is the source of truth for a draft id — the
438
+ // echo duplicates it and adds a stale copy of nothing else.
439
+ ...(view === 'compact' ? {} : { listData: draftRow.listData }),
417
440
  attachments: [],
418
441
  // Concurrency token — pass as expectedRevision to ofw_save_draft /
419
442
  // ofw_delete_draft / ofw_send_message to assert you are acting on
@@ -479,7 +502,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
479
502
  // this call may have re-hit detail for view status, the message content
480
503
  // itself was not re-verified, so report the folder's cache freshness.
481
504
  const freshness = await buildFreshness(cache, { source: 'cache', folders: [row.folder] });
482
- return jsonResponse({ ...withReadState(row), attachments, freshness });
505
+ return jsonResponse({ ...viewOne(view, withReadState(row)), attachments, freshness });
483
506
  }
484
507
  // Everything above this line was served without asking OFW for a body.
485
508
  // This is the one path that fetches one — and fetching the body of an
@@ -524,7 +547,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
524
547
  const attachments = await cache.listAttachmentsForMessage(detail.id);
525
548
  // Fetched live from OFW in this call — current by construction.
526
549
  const freshness = await buildFreshness(cache, { source: 'live', folders: [folder] });
527
- return jsonResponse({ ...withReadState(row), attachments, freshness });
550
+ return jsonResponse({ ...viewOne(view, withReadState(row)), attachments, freshness });
528
551
  });
529
552
  if (allowSend)
530
553
  server.registerTool('ofw_send_message', {
@@ -873,10 +896,14 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
873
896
  size: z.number().int().min(1).describe('Drafts per page (default 50)').optional(),
874
897
  verify: z.boolean().describe('Default true: when the drafts cache is not verified-fresh, run a drafts sync first (cheap — one list page plus one detail per draft) so the response is server-confirmed in one call. Set false to serve straight from the local cache with no OFW requests.').optional(),
875
898
  autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
899
+ view: viewParam(MESSAGE_VIEWS, {
900
+ note: 'compact omits OurFamilyWizard\'s raw `listData` echo, which duplicates this draft\'s own id, subject, modifiedAt and recipients. `revision`, `draftKey` and `cacheStatus` are kept on both rungs.',
901
+ }),
876
902
  },
877
903
  }, async (args) => {
878
904
  const page = args.page ?? 1;
879
905
  const size = args.size ?? 50;
906
+ const view = resolveView(args.view, MESSAGE_VIEWS);
880
907
  const cache = cacheProvider();
881
908
  // Auto-verify (default on): drafts change rarely but INVISIBLY — a web-app
882
909
  // edit bumps no timestamp — so an aged cache used to answer "unverified,
@@ -975,7 +1002,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
975
1002
  payload.verifyNote = verifyNote;
976
1003
  }
977
1004
  payload.freshness = freshness;
978
- payload.drafts = drafts;
1005
+ payload.drafts = viewDrafts(view, drafts);
979
1006
  return jsonResponse(payload);
980
1007
  });
981
1008
  if (allowDrafts)
@@ -1221,6 +1248,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1221
1248
  page: z.number().int().min(1).describe('Page (default 1)').optional(),
1222
1249
  size: z.number().int().min(1).describe('Per page (default 50)').optional(),
1223
1250
  autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
1251
+ // No `view` here, deliberately. This tool never emitted a cache row: it
1252
+ // builds a VERDICT list of `{id, subject, sentAt, unreadBy}`, which is
1253
+ // already narrower than anything the compact projection would produce.
1254
+ // A parameter offering a rung that changes nothing is the no-op schema
1255
+ // `viewParam` exists to refuse.
1224
1256
  },
1225
1257
  }, async (args) => {
1226
1258
  const page = args.page ?? 1;
@@ -0,0 +1,196 @@
1
+ import { projectOrRaw, pruneUndefined } from '@chrischall/mcp-utils';
2
+ /**
3
+ * The compact projection (`docs` — fleet convention "Response shape", and
4
+ * `@chrischall/mcp-utils`' `view` vocabulary).
5
+ *
6
+ * A default `ofw_list_messages()` is 50 messages, and it used to weigh 135 KB
7
+ * — roughly 34,000 tokens for one call. Measured against a real 1,335-row
8
+ * cache, `listData` was 58% of that, and 78% of `listData` duplicated fields
9
+ * the same object already emitted at the top level:
10
+ *
11
+ * - `listData.date` is 421 bytes per message: ELEVEN pre-formatted renderings
12
+ * of one timestamp (`displayDate`, `threeCharMonthWeekdayTimeNoYear`, …)
13
+ * sitting beside the `sentAt` + `sentAtDisplay` that `timestamps.ts` already
14
+ * derives — and `normalizeTimestampsInValue` then adds a twelfth inside it.
15
+ * - `listData.recipients[].user` carries eight fields per person, including
16
+ * `color` and `displayInitials`, next to the three-field recipients the row
17
+ * already normalised.
18
+ * - `listData.read` / `.showNeverViewed` are FORCED to agree with the derived
19
+ * `read` before they are emitted (`withReadState`), so the copy cannot even
20
+ * disagree usefully.
21
+ * - `listData.preview` is a truncation of the body in the same object.
22
+ *
23
+ * What compact keeps is everything a caller can act on. What it drops is what
24
+ * the response says twice, what is near-constant across every row, and what
25
+ * `folder` already answers. `full` returns the row untouched.
26
+ *
27
+ * There is no `raw` rung. A message here is ASSEMBLED — the list endpoint
28
+ * supplies `listData`, the detail GET supplies `body` and the real `viewedAt`,
29
+ * and `timestamps.ts` rewrites both — so there is no single upstream payload
30
+ * to hand back, and a `raw` that skipped normalisation would put naive local
31
+ * times back beside UTC ones on the one rung a caller reaches for when
32
+ * something already looks wrong.
33
+ */
34
+ export const MESSAGE_VIEWS = ['compact', 'full'];
35
+ const LABEL = 'ofw-mcp';
36
+ function asRecord(value) {
37
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
38
+ }
39
+ /**
40
+ * Who sent it.
41
+ *
42
+ * `fromUser` is the empty string on all 1,335 rows of a real cache — inbox and
43
+ * sent alike — because OFW names the sender in the LIST payload's `author` and
44
+ * nowhere else. So this is not a nicety: deleting `listData` without promoting
45
+ * it would have taken the sender's name off every message, and compact is
46
+ * where a field that has never worked starts working.
47
+ */
48
+ function senderOf(row) {
49
+ if (typeof row.fromUser === 'string' && row.fromUser !== '')
50
+ return row.fromUser;
51
+ const author = asRecord(asRecord(row.listData)?.author);
52
+ const name = author?.name;
53
+ return typeof name === 'string' && name !== '' ? name : null;
54
+ }
55
+ /**
56
+ * How many attachments — as a NUMBER, from a field OFW sends two ways (`1`, or
57
+ * `[]` on the nine live rows that have no files).
58
+ *
59
+ * `undefined` when OFW reported nothing, and the caller then sees no `files`
60
+ * key at all. Defaulting to 0 there would turn "we did not see a count" into
61
+ * "there are none", which is the shape that reads as a verified absence — the
62
+ * failure every guard in this server exists to prevent.
63
+ */
64
+ function fileCountOf(row) {
65
+ const files = asRecord(row.listData)?.files;
66
+ if (typeof files === 'number')
67
+ return files;
68
+ if (Array.isArray(files))
69
+ return files.length;
70
+ return undefined;
71
+ }
72
+ /** Whether this message has been replied to. Varies on 442 of 1,335 live rows. */
73
+ function repliedOf(row) {
74
+ const replied = asRecord(row.listData)?.replied;
75
+ return typeof replied === 'boolean' ? replied : undefined;
76
+ }
77
+ /**
78
+ * One cached message row, projected.
79
+ *
80
+ * Key order matches the row's, so a caller reading either rung sees the same
81
+ * fields in the same places. `undefined` values are dropped by
82
+ * `JSON.stringify`, which is how the optional keys above stay absent rather
83
+ * than becoming nulls that claim more than we know.
84
+ */
85
+ export function compactMessage(row) {
86
+ const { listData: _drop, fromUser: _dropFrom, ...rest } = row;
87
+ // `pruneUndefined`, not just `JSON.stringify`'s own dropping of undefined:
88
+ // an optional field must be ABSENT from the object a test or an in-process
89
+ // caller inspects, not present-and-undefined. "We did not see a count" and
90
+ // "there are none" have to be different facts at every layer, not only after
91
+ // serialisation.
92
+ return pruneUndefined({
93
+ id: rest.id,
94
+ folder: rest.folder,
95
+ subject: rest.subject,
96
+ from: senderOf(row),
97
+ sentAt: rest.sentAt,
98
+ recipients: rest.recipients,
99
+ read: rest.read,
100
+ replied: repliedOf(row),
101
+ files: fileCountOf(row),
102
+ replyToId: rest.replyToId,
103
+ chainRootId: rest.chainRootId,
104
+ body: rest.body,
105
+ fetchedBodyAt: rest.fetchedBodyAt,
106
+ // Anything a TOOL added on top of the cache row — `attachments`,
107
+ // `revision`, `cacheStatus`, `serverConfirmed`, `freshness` — is kept
108
+ // wholesale. Those are this server's own answers, never OFW's echo, and a
109
+ // projection that dropped one would be removing the thing the caller
110
+ // asked for rather than the thing they got twice.
111
+ ...omitKnown(rest, MESSAGE_ROW_KEYS),
112
+ });
113
+ }
114
+ /**
115
+ * Each projector's OWN columns — the ones it names explicitly above — so the
116
+ * spread adds only what a tool supplied on top of the cache row.
117
+ *
118
+ * Two sets rather than one union. A shared set is the union of `MessageRow` and
119
+ * `DraftRow`, so each projector would silently swallow any tool-supplied extra
120
+ * that happened to be named after the OTHER shape's column: a message carrying
121
+ * a `modifiedAt`, a draft carrying a `folder`. Nothing supplies those today,
122
+ * which is exactly what makes it the kind of trap that is only found once
123
+ * something does — and it would have quietly contradicted the "kept wholesale"
124
+ * promise below.
125
+ */
126
+ const MESSAGE_ROW_KEYS = new Set([
127
+ 'id', 'folder', 'subject', 'sentAt', 'recipients', 'read', 'replyToId', 'chainRootId', 'body', 'fetchedBodyAt',
128
+ ]);
129
+ const DRAFT_ROW_KEYS = new Set(['id', 'subject', 'recipients', 'replyToId', 'modifiedAt', 'body']);
130
+ function omitKnown(rest, own) {
131
+ const out = {};
132
+ for (const [key, value] of Object.entries(rest))
133
+ if (!own.has(key))
134
+ out[key] = value;
135
+ return out;
136
+ }
137
+ /**
138
+ * One cached draft row, projected.
139
+ *
140
+ * `revision`, `draftKey` and `cacheStatus` are added by the tool rather than
141
+ * the cache and survive untouched — they are the concurrency token, the
142
+ * identity that outlives the create-then-delete churn, and the statement of
143
+ * whether a walk actually compared this row against OFW. Losing any of them to
144
+ * a projection would break every guarded write in this server.
145
+ */
146
+ export function compactDraft(row) {
147
+ const { listData: _drop, ...rest } = row;
148
+ return pruneUndefined({
149
+ id: rest.id,
150
+ subject: rest.subject,
151
+ recipients: rest.recipients,
152
+ replyToId: rest.replyToId,
153
+ modifiedAt: rest.modifiedAt,
154
+ body: rest.body,
155
+ ...omitKnown(rest, DRAFT_ROW_KEYS),
156
+ });
157
+ }
158
+ /**
159
+ * Project a page of messages, or hand it back whole.
160
+ *
161
+ * The array is projected as ONE unit on purpose. Row-at-a-time would let a
162
+ * single unexpected row come back projected-to-nothing among 49 good ones —
163
+ * a hole in the middle of an answer, which is worse than a fat page and
164
+ * indistinguishable from a message with no content. It also means one stderr
165
+ * line per page rather than fifty.
166
+ */
167
+ export function viewMessages(view, rows) {
168
+ if (view !== 'compact')
169
+ return rows;
170
+ return projectOrRaw(rows, (rs) => rs.map((r) => compactMessage(r)), {
171
+ label: LABEL,
172
+ context: 'the cached message rows',
173
+ });
174
+ }
175
+ /** As {@link viewMessages}, for drafts. */
176
+ export function viewDrafts(view, rows) {
177
+ if (view !== 'compact')
178
+ return rows;
179
+ return projectOrRaw(rows, (rs) => rs.map((r) => compactDraft(r)), {
180
+ label: LABEL,
181
+ context: 'the cached draft rows',
182
+ });
183
+ }
184
+ /**
185
+ * One message, projected — `ofw_get_message`'s single-record path.
186
+ *
187
+ * Same fallback as the array form: a row this projector cannot read comes back
188
+ * whole. A detail read is the call a caller makes when they need everything
189
+ * about one message, so answering it with a record that has holes in it is the
190
+ * worst place in this server to get a projection wrong.
191
+ */
192
+ export function viewOne(view, row) {
193
+ if (view !== 'compact')
194
+ return row;
195
+ return projectOrRaw(row, (r) => compactMessage(r), { label: LABEL, context: 'a cached message row' });
196
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.13.0",
3
+ "version": "2.15.0",
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)",
@@ -34,7 +34,7 @@
34
34
  "typecheck": "tsc -p tsconfig.json --noEmit"
35
35
  },
36
36
  "dependencies": {
37
- "@chrischall/mcp-utils": "^0.18.0",
37
+ "@chrischall/mcp-utils": "^0.21.0",
38
38
  "@fetchproxy/bootstrap": "^2.2.0",
39
39
  "@modelcontextprotocol/sdk": "^1.29.0",
40
40
  "dotenv": "^17.4.2",
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.13.0",
9
+ "version": "2.15.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.13.0",
14
+ "version": "2.15.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },