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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/auth.js +34 -4
- package/dist/bundle.js +800 -148
- package/dist/index.js +3 -1
- package/dist/tools/_shared.js +16 -6
- package/dist/tools/healthcheck.js +81 -0
- 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/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.
|
|
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,
|
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;
|
|
@@ -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
|
+
}
|
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.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.
|
|
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.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.15.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|