ofw-mcp 2.14.0 → 2.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +593 -144
- package/dist/index.js +1 -1
- package/dist/tools/_shared.js +16 -6
- package/dist/tools/messages.js +38 -6
- package/dist/tools/project.js +196 -0
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +36 -3
package/dist/index.js
CHANGED
|
@@ -36,7 +36,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
|
|
|
36
36
|
// always succeeds before any credential check runs.
|
|
37
37
|
await runMcp({
|
|
38
38
|
name: 'ofw',
|
|
39
|
-
version: '2.
|
|
39
|
+
version: '2.15.1', // x-release-please-version
|
|
40
40
|
deps: client,
|
|
41
41
|
tools: [
|
|
42
42
|
registerHealthcheckTools,
|
package/dist/tools/_shared.js
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
import { expandPath as expandPathUtil,
|
|
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
|
-
//
|
|
6
|
-
// `
|
|
7
|
-
// to ISO-8601 with an explicit offset and paired
|
|
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
|
|
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;
|
package/dist/tools/messages.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2.15.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"mcpName": "io.github.chrischall/ofw-mcp",
|
|
6
6
|
"description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@chrischall/mcp-utils": "^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.
|
|
9
|
+
"version": "2.15.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.15.1",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
package/skills/ofw/SKILL.md
CHANGED
|
@@ -92,11 +92,11 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
|
|
|
92
92
|
|------|-------|
|
|
93
93
|
| `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). |
|
|
94
94
|
| `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. |
|
|
95
|
-
| `ofw_list_messages(folderId?, since?, until?, q?, sort?, page?, size?, autoRefresh?)` | Cache-backed list. Supports folder ("inbox"/"sent"/"both"), date range, and substring search. `sort:"oldest"` starts at the old end of a range instead of paging to it (default `"newest"`). Returns `complete` for the RESULT SET plus **`nextPage`** (null when done) — and the paging keys come FIRST in the JSON, before `messages`. `returned` carries the record count as a scalar, beside `total`. An **empty** result from a non-fresh cache is refused (`UNVERIFIED_EMPTY`) — pass `autoRefresh:true` to sync and answer instead. |
|
|
96
|
-
| `ofw_get_message(messageId, allowMarkRead?)` | 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 read AND stamps a "First Viewed" time the co-parent can see — irreversible. Pass `allowMarkRead:false` to refuse that fetch instead; cached, sent and already-read messages are unaffected. |
|
|
95
|
+
| `ofw_list_messages(folderId?, since?, until?, q?, sort?, page?, size?, autoRefresh?, view?)` | Cache-backed list. Supports folder ("inbox"/"sent"/"both"), date range, and substring search. `sort:"oldest"` starts at the old end of a range instead of paging to it (default `"newest"`). Returns `complete` for the RESULT SET plus **`nextPage`** (null when done) — and the paging keys come FIRST in the JSON, before `messages`. `returned` carries the record count as a scalar, beside `total`. An **empty** result from a non-fresh cache is refused (`UNVERIFIED_EMPTY`) — pass `autoRefresh:true` to sync and answer instead. |
|
|
96
|
+
| `ofw_get_message(messageId, allowMarkRead?, view?)` | 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 read AND stamps a "First Viewed" time the co-parent can see — irreversible. Pass `allowMarkRead:false` to refuse that fetch instead; cached, sent and already-read messages are unaffected. |
|
|
97
97
|
| `ofw_send_message(draftId?, subject?, body?, recipientIds?, replyToId?, expectedRevision?, deleteDraftOnSuccess?, myFileIDs?, force?)` | Send a message — **the one irreversible operation**. Preferred path: pass `draftId` (+ `expectedRevision`) to send an existing draft **as it exists on the server** — the tool re-reads it from OFW first and refuses if it changed since you read it (or was already sent/deleted); `subject`/`body` become optional overrides. `recipientIds` is usually still required: OFW does not store recipients on drafts. After a **confirmed** send the draft is auto-deleted (`deleteDraftOnSuccess:false` to keep it); on any failure or ambiguity it is retained and the response says why (`draftRetained`). Response leads with `sentMessageId`, `draftKey`, `threaded`, `draftDeleted`. Compose from scratch by passing `subject`/`body`/`recipientIds` with no `draftId`. |
|
|
98
98
|
| `ofw_get_unread_sent(page?, size?, autoRefresh?)` | Sent messages your co-parent hasn't read yet (from cache). Leads with `complete`/`hasMore`/`nextPage`, then `scanned`/`total`, then `unread`; an empty sent cache that is not fresh is refused rather than reported as "nothing sent". |
|
|
99
|
-
| `ofw_list_drafts(page?, size?, verify?, autoRefresh?)` | Leads with `complete`/`hasMore`/`nextPage`; `drafts` comes last. List saved drafts, **auto-verified**: when the cache is not verified-fresh a cheap drafts sync runs first (default `verify:true`), so one call answers server-confirmed. `verify:false` serves straight from cache. Each draft carries `serverConfirmed`, `revision` and `draftKey`. Returns `complete` — **check it before saying "you have N drafts"**. See [Freshness](#freshness). |
|
|
99
|
+
| `ofw_list_drafts(page?, size?, verify?, autoRefresh?, view?)` | Leads with `complete`/`hasMore`/`nextPage`; `drafts` comes last. List saved drafts, **auto-verified**: when the cache is not verified-fresh a cheap drafts sync runs first (default `verify:true`), so one call answers server-confirmed. `verify:false` serves straight from cache. Each draft carries `serverConfirmed`, `revision` and `draftKey`. Returns `complete` — **check it before saying "you have N drafts"**. See [Freshness](#freshness). |
|
|
100
100
|
| `ofw_save_draft(subject, body, recipientIds?, messageId?, replyToId?, myFileIDs?, expectedRevision?, force?)` | 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 leads with `draftKey`, which stays the same across every edit — **track that, not the id**. Note: OFW does **not** store recipients on drafts — `recipientIds` are accepted but come back empty (a one-line NOTE says so; supply them at send time instead). Threading warnings fire only on genuine drops — a draft echoing `inReplyTo`/`showContext` IS threaded. |
|
|
101
101
|
| `ofw_delete_draft(messageId)` | Delete a draft. |
|
|
102
102
|
| `ofw_upload_attachment(path, shareClass?, label?, description?)` | Upload a local file to My Files; returns a fileId to pass into `myFileIDs`. |
|
|
@@ -125,6 +125,39 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
|
|
|
125
125
|
| `ofw_list_journal_entries(start?, max?)` | 1-based offset; default max 10 |
|
|
126
126
|
| `ofw_create_journal_entry(title, body)` | Create a new entry |
|
|
127
127
|
|
|
128
|
+
## Response shape (`view`)
|
|
129
|
+
|
|
130
|
+
`ofw_list_messages`, `ofw_get_message` and `ofw_list_drafts` take
|
|
131
|
+
`view: "compact" | "full"`, and **`compact` is the default** — you get the slim
|
|
132
|
+
shape without asking. It measured 135.1 KB → 41.1 KB on a real 50-message page.
|
|
133
|
+
|
|
134
|
+
What compact drops is `listData`, OFW's echo of the list payload. That blob was
|
|
135
|
+
58% of the response and 78% of it duplicated fields the same object already
|
|
136
|
+
emits at the top level — eleven pre-formatted renderings of one timestamp,
|
|
137
|
+
eight fields per recipient beside the three the row already normalised, and a
|
|
138
|
+
`preview` that is a truncation of the `body` in the same object.
|
|
139
|
+
|
|
140
|
+
**Two things about compact that will surprise you if you don't know them:**
|
|
141
|
+
|
|
142
|
+
- **The sender is `from`, not `fromUser`.** `fromUser` is the empty string on
|
|
143
|
+
every row — inbox and sent alike — because OFW names the sender only inside
|
|
144
|
+
`listData.author`. So compact promotes it. Dropping `listData` without that
|
|
145
|
+
would have taken the sender off every message; this is the one field the fat
|
|
146
|
+
blob was carrying alone.
|
|
147
|
+
- **On a draft, `ofw_get_message` returns `from: null`** — a draft is unsent, so
|
|
148
|
+
there is no sender, and `""` would read as one we failed to find.
|
|
149
|
+
|
|
150
|
+
Pass `view: "full"` when you need a field compact dropped — `listData` and
|
|
151
|
+
everything in it. There is deliberately **no `raw` rung**: a message here is
|
|
152
|
+
assembled from a list item, a detail GET and derived fields, so there is no
|
|
153
|
+
single upstream payload to hand back, and a rung that skipped normalisation
|
|
154
|
+
would put naive local times back beside UTC ones on exactly the rung you reach
|
|
155
|
+
for when something already looks wrong.
|
|
156
|
+
|
|
157
|
+
`ofw_get_unread_sent` takes no `view`: it emits a verdict list
|
|
158
|
+
(`{id, subject, sentAt, unreadBy}`) that is already narrower than the
|
|
159
|
+
projection.
|
|
160
|
+
|
|
128
161
|
## Freshness, completeness, and lifecycle
|
|
129
162
|
|
|
130
163
|
Message and draft reads come from a local cache, so **a result can be stale without looking stale**. Three separate questions, three separate signals — do not substitute one for another:
|