ofw-mcp 2.10.2 → 2.11.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/bundle.js +133 -16
- package/dist/cache/store.js +6 -1
- package/dist/index.js +1 -1
- package/dist/tools/expenses.js +21 -3
- package/dist/tools/journal.js +21 -3
- package/dist/tools/messages.js +60 -6
- package/dist/tools/pagination.js +120 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +4 -3
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "OurFamilyWizard tools for Claude Code",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.11.0"
|
|
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.
|
|
17
|
+
"version": "2.11.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Chris Chall"
|
|
20
20
|
},
|
package/dist/bundle.js
CHANGED
|
@@ -36996,6 +36996,17 @@ function classifyBridgeError(err) {
|
|
|
36996
36996
|
}
|
|
36997
36997
|
|
|
36998
36998
|
// node_modules/@fetchproxy/server/dist/ws-server.js
|
|
36999
|
+
function envWsPort() {
|
|
37000
|
+
const raw = process.env.FETCHPROXY_WS_PORT;
|
|
37001
|
+
if (raw === void 0 || raw.trim() === "")
|
|
37002
|
+
return void 0;
|
|
37003
|
+
if (!/^\d+$/.test(raw.trim()))
|
|
37004
|
+
return void 0;
|
|
37005
|
+
const port = Number(raw.trim());
|
|
37006
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
37007
|
+
return void 0;
|
|
37008
|
+
return port;
|
|
37009
|
+
}
|
|
36999
37010
|
var FetchproxyProtocolError = class extends Error {
|
|
37000
37011
|
constructor(message) {
|
|
37001
37012
|
super(message);
|
|
@@ -37243,7 +37254,7 @@ var FetchproxyServer = class {
|
|
|
37243
37254
|
}
|
|
37244
37255
|
}
|
|
37245
37256
|
this.opts = {
|
|
37246
|
-
port: opts.port ?? 37149,
|
|
37257
|
+
port: opts.port ?? envWsPort() ?? 37149,
|
|
37247
37258
|
host: opts.host ?? "127.0.0.1",
|
|
37248
37259
|
serverName: opts.serverName,
|
|
37249
37260
|
version: opts.version,
|
|
@@ -39074,7 +39085,7 @@ async function loginWithPassword(username, password) {
|
|
|
39074
39085
|
// package.json
|
|
39075
39086
|
var package_default = {
|
|
39076
39087
|
name: "ofw-mcp",
|
|
39077
|
-
version: "2.
|
|
39088
|
+
version: "2.11.0",
|
|
39078
39089
|
license: "MIT",
|
|
39079
39090
|
mcpName: "io.github.chrischall/ofw-mcp",
|
|
39080
39091
|
description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
|
|
@@ -41606,6 +41617,53 @@ async function buildInlineDelivery(input) {
|
|
|
41606
41617
|
|
|
41607
41618
|
// src/tools/messages.ts
|
|
41608
41619
|
import { basename as basename2, join as join6 } from "node:path";
|
|
41620
|
+
|
|
41621
|
+
// src/tools/pagination.ts
|
|
41622
|
+
function pageState(input) {
|
|
41623
|
+
const hasMore = input.page * input.size < input.total;
|
|
41624
|
+
return { hasMore, nextPage: hasMore ? input.page + 1 : null };
|
|
41625
|
+
}
|
|
41626
|
+
function asRecord(value) {
|
|
41627
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
41628
|
+
}
|
|
41629
|
+
function recordArray(root) {
|
|
41630
|
+
if (Array.isArray(root.data)) return root.data;
|
|
41631
|
+
for (const value of Object.values(root)) {
|
|
41632
|
+
if (Array.isArray(value)) return value;
|
|
41633
|
+
}
|
|
41634
|
+
return null;
|
|
41635
|
+
}
|
|
41636
|
+
function readUpstreamPaging(payload) {
|
|
41637
|
+
const root = asRecord(payload);
|
|
41638
|
+
const rows = root === null ? null : recordArray(root);
|
|
41639
|
+
const meta3 = root === null ? null : asRecord(root.metadata);
|
|
41640
|
+
const total = meta3 !== null && typeof meta3.totalElements === "number" && Number.isFinite(meta3.totalElements) ? meta3.totalElements : null;
|
|
41641
|
+
const last = meta3 !== null && typeof meta3.last === "boolean" ? meta3.last : null;
|
|
41642
|
+
return { returned: rows?.length ?? 0, total, last };
|
|
41643
|
+
}
|
|
41644
|
+
function offsetState(input) {
|
|
41645
|
+
const last = input.last ?? null;
|
|
41646
|
+
const consumed = input.start - input.base + input.returned;
|
|
41647
|
+
const hasMore = last !== null ? !last : input.total !== null ? consumed < input.total : input.returned >= input.max;
|
|
41648
|
+
return { hasMore, nextStart: hasMore ? input.start + input.max : null };
|
|
41649
|
+
}
|
|
41650
|
+
function withPaginationFirst(input) {
|
|
41651
|
+
const body = asRecord(input.payload);
|
|
41652
|
+
if (body === null) return null;
|
|
41653
|
+
const scope = input.total !== null ? ` of ${input.total}` : "";
|
|
41654
|
+
const head = {
|
|
41655
|
+
hasMore: input.state.hasMore,
|
|
41656
|
+
nextStart: input.state.nextStart,
|
|
41657
|
+
start: input.start,
|
|
41658
|
+
max: input.max,
|
|
41659
|
+
returned: input.returned,
|
|
41660
|
+
...input.total !== null ? { total: input.total } : {},
|
|
41661
|
+
paginationNote: input.state.hasMore ? `PARTIAL: this response holds ${input.returned} record(s) starting at ${input.start}${scope}. ${input.hint} Do not state a total or an absence from this response alone.` : `This response reaches the end of the list${scope === "" ? "" : ` (${input.total} record(s) in total)`}.`
|
|
41662
|
+
};
|
|
41663
|
+
return { ...head, ...body, ...head };
|
|
41664
|
+
}
|
|
41665
|
+
|
|
41666
|
+
// src/tools/messages.ts
|
|
41609
41667
|
var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
|
|
41610
41668
|
var SentDetailSchema = external_exports.looseObject({
|
|
41611
41669
|
subject: external_exports.string().optional(),
|
|
@@ -41741,7 +41799,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
41741
41799
|
return jsonResponse({ folders: data, freshness });
|
|
41742
41800
|
});
|
|
41743
41801
|
server.registerTool("ofw_list_messages", {
|
|
41744
|
-
description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages \u2014 the cache may have 1000+ messages. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" \u2014 check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
|
|
41802
|
+
description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based (1-based `page`) but if you know what you want (a date range, a topic), prefer the filters over walking pages \u2014 the cache may have 1000+ messages. Results are newest-first by default; `sort:"oldest"` starts at the old end of a range instead of paging to it. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" \u2014 check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
|
|
41745
41803
|
annotations: { readOnlyHint: false },
|
|
41746
41804
|
inputSchema: {
|
|
41747
41805
|
folderId: external_exports.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
|
|
@@ -41750,11 +41808,13 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
41750
41808
|
since: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at >= since (inclusive)").optional(),
|
|
41751
41809
|
until: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at < until (exclusive)").optional(),
|
|
41752
41810
|
q: external_exports.string().describe("Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.").optional(),
|
|
41811
|
+
sort: external_exports.enum(["newest", "oldest"]).describe('Result order: "newest" (default, newest first) or "oldest" (oldest first). This decides which end a truncated page keeps \u2014 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(),
|
|
41753
41812
|
autoRefresh: external_exports.boolean().describe(AUTO_REFRESH_DESC).optional()
|
|
41754
41813
|
}
|
|
41755
41814
|
}, async (args) => {
|
|
41756
41815
|
const page = args.page ?? 1;
|
|
41757
41816
|
const size = args.size ?? 50;
|
|
41817
|
+
const sort = args.sort ?? "newest";
|
|
41758
41818
|
const folderArg = args.folderId ?? "both";
|
|
41759
41819
|
let folder;
|
|
41760
41820
|
if (folderArg === "inbox") folder = "inbox";
|
|
@@ -41780,7 +41840,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
41780
41840
|
isEmpty: (v) => v.total === 0,
|
|
41781
41841
|
read: async () => {
|
|
41782
41842
|
const total2 = await cache.countMessages(filter);
|
|
41783
|
-
const messages2 = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
|
|
41843
|
+
const messages2 = (await cache.listMessages({ ...filter, page, size, sort })).map((m) => withReadState(m));
|
|
41784
41844
|
const freshness2 = await buildFreshness(cache, { source: "cache", folders });
|
|
41785
41845
|
return { messages: messages2, total: total2, freshness: freshness2 };
|
|
41786
41846
|
}
|
|
@@ -41797,7 +41857,19 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
41797
41857
|
const { messages, total, freshness } = value;
|
|
41798
41858
|
const fullSlice = page === 1 && messages.length === total;
|
|
41799
41859
|
const complete = fullSlice && freshness.staleness === "fresh" && freshness.historyComplete;
|
|
41800
|
-
const
|
|
41860
|
+
const { hasMore, nextPage } = pageState({ page, size, total });
|
|
41861
|
+
const payload = {
|
|
41862
|
+
complete,
|
|
41863
|
+
hasMore,
|
|
41864
|
+
nextPage,
|
|
41865
|
+
// The honest record count, as a scalar and ahead of the array — a
|
|
41866
|
+
// consumer never has to reach `messages` to learn how many came back.
|
|
41867
|
+
returned: messages.length,
|
|
41868
|
+
total,
|
|
41869
|
+
page,
|
|
41870
|
+
size,
|
|
41871
|
+
sort
|
|
41872
|
+
};
|
|
41801
41873
|
if (!complete) {
|
|
41802
41874
|
payload.completeNote = [
|
|
41803
41875
|
!fullSlice ? `this page holds ${messages.length} of ${total} matching cached messages` : null,
|
|
@@ -41808,11 +41880,13 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
41808
41880
|
if (total === 0) {
|
|
41809
41881
|
payload.note = 'No messages match these filters, and the cache IS verified-fresh for these folders \u2014 so this is a real "nothing matched", not a stale-cache artefact. If you expected results, relax the filters.';
|
|
41810
41882
|
} else if (page * size < total) {
|
|
41811
|
-
payload.note = `Showing ${(page - 1) * size + 1}\u2013${(page - 1) * size + messages.length} of ${total}. Increase 'page' to see more,
|
|
41883
|
+
payload.note = `Showing ${(page - 1) * size + 1}\u2013${(page - 1) * size + messages.length} of ${total}, ${sort} first. Increase 'page' to see more, narrow with since/until/q, or set sort:"${sort === "newest" ? "oldest" : "newest"}" to start from the other end.`;
|
|
41812
41884
|
}
|
|
41813
41885
|
if (refreshed) {
|
|
41814
41886
|
payload.autoRefreshed = true;
|
|
41815
41887
|
}
|
|
41888
|
+
payload.freshness = freshness;
|
|
41889
|
+
payload.messages = messages;
|
|
41816
41890
|
return jsonResponse(payload);
|
|
41817
41891
|
});
|
|
41818
41892
|
server.registerTool("ofw_get_message", {
|
|
@@ -42259,7 +42333,16 @@ ${JSON.stringify(
|
|
|
42259
42333
|
const { drafts, total, freshness, serverConfirmed } = value;
|
|
42260
42334
|
const fullSlice = page === 1 && drafts.length === total;
|
|
42261
42335
|
const complete = serverConfirmed && fullSlice;
|
|
42262
|
-
const
|
|
42336
|
+
const { hasMore, nextPage } = pageState({ page, size, total });
|
|
42337
|
+
const payload = {
|
|
42338
|
+
complete,
|
|
42339
|
+
hasMore,
|
|
42340
|
+
nextPage,
|
|
42341
|
+
returned: drafts.length,
|
|
42342
|
+
total,
|
|
42343
|
+
page,
|
|
42344
|
+
size
|
|
42345
|
+
};
|
|
42263
42346
|
if (!complete) {
|
|
42264
42347
|
payload.completeNote = [
|
|
42265
42348
|
!fullSlice ? `this page holds ${drafts.length} of ${total} cached drafts` : null,
|
|
@@ -42278,6 +42361,8 @@ ${JSON.stringify(
|
|
|
42278
42361
|
if (verifyNote !== null) {
|
|
42279
42362
|
payload.verifyNote = verifyNote;
|
|
42280
42363
|
}
|
|
42364
|
+
payload.freshness = freshness;
|
|
42365
|
+
payload.drafts = drafts;
|
|
42281
42366
|
return jsonResponse(payload);
|
|
42282
42367
|
});
|
|
42283
42368
|
if (allowDrafts) server.registerTool("ofw_save_draft", {
|
|
@@ -42509,7 +42594,16 @@ ${text}` : text);
|
|
|
42509
42594
|
unread.push({ id: msg.id, subject: msg.subject, sentAt: msg.sentAt, unreadBy });
|
|
42510
42595
|
}
|
|
42511
42596
|
}
|
|
42512
|
-
const
|
|
42597
|
+
const { hasMore, nextPage } = pageState({ page, size, total });
|
|
42598
|
+
const payload = {
|
|
42599
|
+
complete,
|
|
42600
|
+
hasMore,
|
|
42601
|
+
nextPage,
|
|
42602
|
+
total,
|
|
42603
|
+
scanned: sent.length,
|
|
42604
|
+
page,
|
|
42605
|
+
size
|
|
42606
|
+
};
|
|
42513
42607
|
if (!complete) {
|
|
42514
42608
|
payload.completeNote = `This verdict covers the ${sent.length} of ${total} cached sent messages on this page${freshness.staleness === "fresh" ? "" : `, from a cache that is "${freshness.staleness}"`}. It is not a statement about every message you have sent.`;
|
|
42515
42609
|
}
|
|
@@ -42519,6 +42613,8 @@ ${text}` : text);
|
|
|
42519
42613
|
if (refreshed) {
|
|
42520
42614
|
payload.autoRefreshed = true;
|
|
42521
42615
|
}
|
|
42616
|
+
payload.freshness = freshness;
|
|
42617
|
+
payload.unread = unread;
|
|
42522
42618
|
return jsonResponse(payload);
|
|
42523
42619
|
});
|
|
42524
42620
|
if (allowDrafts) server.registerTool("ofw_upload_attachment", {
|
|
@@ -43037,17 +43133,27 @@ function registerExpenseTools(server, client2) {
|
|
|
43037
43133
|
return jsonResponse(data);
|
|
43038
43134
|
});
|
|
43039
43135
|
server.registerTool("ofw_list_expenses", {
|
|
43040
|
-
description: "List OurFamilyWizard expenses with
|
|
43136
|
+
description: "List OurFamilyWizard expenses. Offset-paged via start/max. The response leads with its paging state \u2014 `hasMore` and `nextStart` (null when the list is exhausted) \u2014 BEFORE the records, so a truncated or partially-read response still says whether more remain. Never state an expense total or an absence from one page.",
|
|
43041
43137
|
annotations: { readOnlyHint: true },
|
|
43042
43138
|
inputSchema: {
|
|
43043
|
-
start: external_exports.number().int().min(0).describe("Start offset (default 0)").optional(),
|
|
43139
|
+
start: external_exports.number().int().min(0).describe("Start offset, 0-based (default 0). To continue a listing, pass the `nextStart` from the previous response.").optional(),
|
|
43044
43140
|
max: external_exports.number().int().min(1).describe("Max results (default 20)").optional()
|
|
43045
43141
|
}
|
|
43046
43142
|
}, async (args) => {
|
|
43047
43143
|
const start = args.start ?? 0;
|
|
43048
43144
|
const max = args.max ?? 20;
|
|
43049
43145
|
const data = await client2.request("GET", `/pub/v2/expense/expenses?start=${start}&max=${max}`);
|
|
43050
|
-
|
|
43146
|
+
const { returned, total, last } = readUpstreamPaging(data);
|
|
43147
|
+
const wrapped = withPaginationFirst({
|
|
43148
|
+
state: offsetState({ start, max, returned, total, last, base: 0 }),
|
|
43149
|
+
start,
|
|
43150
|
+
max,
|
|
43151
|
+
returned,
|
|
43152
|
+
total,
|
|
43153
|
+
hint: `Re-call ofw_list_expenses with start:${start + max}.`,
|
|
43154
|
+
payload: data
|
|
43155
|
+
});
|
|
43156
|
+
return jsonResponse(wrapped ?? data);
|
|
43051
43157
|
});
|
|
43052
43158
|
if (allowWrites) server.registerTool("ofw_create_expense", {
|
|
43053
43159
|
description: "Log a new expense in OurFamilyWizard",
|
|
@@ -43066,17 +43172,27 @@ function registerExpenseTools(server, client2) {
|
|
|
43066
43172
|
function registerJournalTools(server, client2) {
|
|
43067
43173
|
const allowWrites = getWriteMode() === "all";
|
|
43068
43174
|
server.registerTool("ofw_list_journal_entries", {
|
|
43069
|
-
description: "List OurFamilyWizard journal entries",
|
|
43175
|
+
description: "List OurFamilyWizard journal entries. Offset-paged via start/max (1-based). The response leads with its paging state \u2014 `hasMore` and `nextStart` (null when the list is exhausted) \u2014 BEFORE the records, so a truncated or partially-read response still says whether more remain. Never state an entry count or an absence from one page.",
|
|
43070
43176
|
annotations: { readOnlyHint: true },
|
|
43071
43177
|
inputSchema: {
|
|
43072
|
-
start: external_exports.number().int().min(1).describe("Start offset (default 1)").optional(),
|
|
43178
|
+
start: external_exports.number().int().min(1).describe("Start offset, 1-based (default 1). To continue a listing, pass the `nextStart` from the previous response.").optional(),
|
|
43073
43179
|
max: external_exports.number().int().min(1).describe("Max results (default 10)").optional()
|
|
43074
43180
|
}
|
|
43075
43181
|
}, async (args) => {
|
|
43076
43182
|
const start = args.start ?? 1;
|
|
43077
43183
|
const max = args.max ?? 10;
|
|
43078
43184
|
const data = await client2.request("GET", `/pub/v1/journals?start=${start}&max=${max}`);
|
|
43079
|
-
|
|
43185
|
+
const { returned, total, last } = readUpstreamPaging(data);
|
|
43186
|
+
const wrapped = withPaginationFirst({
|
|
43187
|
+
state: offsetState({ start, max, returned, total, last, base: 1 }),
|
|
43188
|
+
start,
|
|
43189
|
+
max,
|
|
43190
|
+
returned,
|
|
43191
|
+
total,
|
|
43192
|
+
hint: `Re-call ofw_list_journal_entries with start:${start + max}.`,
|
|
43193
|
+
payload: data
|
|
43194
|
+
});
|
|
43195
|
+
return jsonResponse(wrapped ?? data);
|
|
43080
43196
|
});
|
|
43081
43197
|
if (allowWrites) server.registerTool("ofw_create_journal_entry", {
|
|
43082
43198
|
description: "Create a new journal entry in OurFamilyWizard",
|
|
@@ -43332,9 +43448,10 @@ var OFWCacheCore = class {
|
|
|
43332
43448
|
listMessages(opts) {
|
|
43333
43449
|
const { where, params } = buildMessageFilter(opts);
|
|
43334
43450
|
const offset = (opts.page - 1) * opts.size;
|
|
43451
|
+
const dir = opts.sort === "oldest" ? "ASC" : "DESC";
|
|
43335
43452
|
const rows = this.db.all(
|
|
43336
43453
|
`SELECT * FROM messages ${where}
|
|
43337
|
-
ORDER BY sent_at
|
|
43454
|
+
ORDER BY sent_at ${dir}, id ${dir}
|
|
43338
43455
|
LIMIT ? OFFSET ?`,
|
|
43339
43456
|
[...params, opts.size, offset]
|
|
43340
43457
|
);
|
|
@@ -43721,7 +43838,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
|
|
|
43721
43838
|
var nodeAttachmentIO = new NodeAttachmentIO();
|
|
43722
43839
|
await runMcp({
|
|
43723
43840
|
name: "ofw",
|
|
43724
|
-
version: "2.
|
|
43841
|
+
version: "2.11.0",
|
|
43725
43842
|
// x-release-please-version
|
|
43726
43843
|
deps: client,
|
|
43727
43844
|
tools: [
|
package/dist/cache/store.js
CHANGED
|
@@ -261,8 +261,13 @@ export class OFWCacheCore {
|
|
|
261
261
|
listMessages(opts) {
|
|
262
262
|
const { where, params } = buildMessageFilter(opts);
|
|
263
263
|
const offset = (opts.page - 1) * opts.size;
|
|
264
|
+
// Direction comes from a closed set of literals, never from caller input.
|
|
265
|
+
// `id` tiebreaks in the SAME direction as `sent_at` so paging stays a total
|
|
266
|
+
// order: rows sharing a timestamp keep a stable relative position, and none
|
|
267
|
+
// is skipped or repeated at a page boundary.
|
|
268
|
+
const dir = opts.sort === 'oldest' ? 'ASC' : 'DESC';
|
|
264
269
|
const rows = this.db.all(`SELECT * FROM messages ${where}
|
|
265
|
-
ORDER BY sent_at
|
|
270
|
+
ORDER BY sent_at ${dir}, id ${dir}
|
|
266
271
|
LIMIT ? OFFSET ?`, [...params, opts.size, offset]);
|
|
267
272
|
return rows.map(rowFromDb);
|
|
268
273
|
}
|
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.
|
|
38
|
+
version: '2.11.0', // x-release-please-version
|
|
39
39
|
deps: client,
|
|
40
40
|
tools: [
|
|
41
41
|
registerUserTools,
|
package/dist/tools/expenses.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { jsonResponse } from './_shared.js';
|
|
3
|
+
import { offsetState, readUpstreamPaging, withPaginationFirst } from './pagination.js';
|
|
3
4
|
import { getWriteMode } from '../config.js';
|
|
4
5
|
export function registerExpenseTools(server, client) {
|
|
5
6
|
// Expense writes land on the court-visible record — OFW_WRITE_MODE 'all' only.
|
|
@@ -12,17 +13,34 @@ export function registerExpenseTools(server, client) {
|
|
|
12
13
|
return jsonResponse(data);
|
|
13
14
|
});
|
|
14
15
|
server.registerTool('ofw_list_expenses', {
|
|
15
|
-
description: 'List OurFamilyWizard expenses with
|
|
16
|
+
description: 'List OurFamilyWizard expenses. Offset-paged via start/max. The response leads with its paging state — `hasMore` and `nextStart` (null when the list is exhausted) — BEFORE the records, so a truncated or partially-read response still says whether more remain. Never state an expense total or an absence from one page.',
|
|
16
17
|
annotations: { readOnlyHint: true },
|
|
17
18
|
inputSchema: {
|
|
18
|
-
start: z.number().int().min(0).describe('Start offset (default 0)').optional(),
|
|
19
|
+
start: z.number().int().min(0).describe('Start offset, 0-based (default 0). To continue a listing, pass the `nextStart` from the previous response.').optional(),
|
|
19
20
|
max: z.number().int().min(1).describe('Max results (default 20)').optional(),
|
|
20
21
|
},
|
|
21
22
|
}, async (args) => {
|
|
22
23
|
const start = args.start ?? 0;
|
|
23
24
|
const max = args.max ?? 20;
|
|
24
25
|
const data = await client.request('GET', `/pub/v2/expense/expenses?start=${start}&max=${max}`);
|
|
25
|
-
|
|
26
|
+
// Paging state FIRST, records after — a partial read of a spilled response
|
|
27
|
+
// must reach "there are more" before it reaches the records. See
|
|
28
|
+
// src/tools/pagination.ts for why the order is load-bearing.
|
|
29
|
+
//
|
|
30
|
+
// OFW wraps these listings as {data, metadata} and its metadata carries a
|
|
31
|
+
// `last` boolean, so "is there another page" is answered by the server
|
|
32
|
+
// rather than inferred from a full page (verified live).
|
|
33
|
+
const { returned, total, last } = readUpstreamPaging(data);
|
|
34
|
+
const wrapped = withPaginationFirst({
|
|
35
|
+
state: offsetState({ start, max, returned, total, last, base: 0 }),
|
|
36
|
+
start, max, returned, total,
|
|
37
|
+
hint: `Re-call ofw_list_expenses with start:${start + max}.`,
|
|
38
|
+
payload: data,
|
|
39
|
+
});
|
|
40
|
+
// A payload that is not a plain object cannot carry the paging keys at all.
|
|
41
|
+
// Pass it through untouched rather than relocating it — an added field is
|
|
42
|
+
// never worth changing a response's top-level shape.
|
|
43
|
+
return jsonResponse(wrapped ?? data);
|
|
26
44
|
});
|
|
27
45
|
if (allowWrites)
|
|
28
46
|
server.registerTool('ofw_create_expense', {
|
package/dist/tools/journal.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { jsonResponse } from './_shared.js';
|
|
3
|
+
import { offsetState, readUpstreamPaging, withPaginationFirst } from './pagination.js';
|
|
3
4
|
import { getWriteMode } from '../config.js';
|
|
4
5
|
export function registerJournalTools(server, client) {
|
|
5
6
|
// Journal writes land on the court-visible record — OFW_WRITE_MODE 'all' only.
|
|
6
7
|
const allowWrites = getWriteMode() === 'all';
|
|
7
8
|
server.registerTool('ofw_list_journal_entries', {
|
|
8
|
-
description: 'List OurFamilyWizard journal entries',
|
|
9
|
+
description: 'List OurFamilyWizard journal entries. Offset-paged via start/max (1-based). The response leads with its paging state — `hasMore` and `nextStart` (null when the list is exhausted) — BEFORE the records, so a truncated or partially-read response still says whether more remain. Never state an entry count or an absence from one page.',
|
|
9
10
|
annotations: { readOnlyHint: true },
|
|
10
11
|
inputSchema: {
|
|
11
|
-
start: z.number().int().min(1).describe('Start offset (default 1)').optional(),
|
|
12
|
+
start: z.number().int().min(1).describe('Start offset, 1-based (default 1). To continue a listing, pass the `nextStart` from the previous response.').optional(),
|
|
12
13
|
max: z.number().int().min(1).describe('Max results (default 10)').optional(),
|
|
13
14
|
},
|
|
14
15
|
}, async (args) => {
|
|
@@ -16,7 +17,24 @@ export function registerJournalTools(server, client) {
|
|
|
16
17
|
const start = args.start ?? 1;
|
|
17
18
|
const max = args.max ?? 10;
|
|
18
19
|
const data = await client.request('GET', `/pub/v1/journals?start=${start}&max=${max}`);
|
|
19
|
-
|
|
20
|
+
// Paging state FIRST, records after — a partial read of a spilled response
|
|
21
|
+
// must reach "there are more" before it reaches the records. See
|
|
22
|
+
// src/tools/pagination.ts for why the order is load-bearing.
|
|
23
|
+
//
|
|
24
|
+
// OFW wraps these listings as {data, metadata} and its metadata carries a
|
|
25
|
+
// `last` boolean, so "is there another page" is answered by the server
|
|
26
|
+
// rather than inferred from a full page (verified live).
|
|
27
|
+
const { returned, total, last } = readUpstreamPaging(data);
|
|
28
|
+
const wrapped = withPaginationFirst({
|
|
29
|
+
state: offsetState({ start, max, returned, total, last, base: 1 }),
|
|
30
|
+
start, max, returned, total,
|
|
31
|
+
hint: `Re-call ofw_list_journal_entries with start:${start + max}.`,
|
|
32
|
+
payload: data,
|
|
33
|
+
});
|
|
34
|
+
// A payload that is not a plain object cannot carry the paging keys at all.
|
|
35
|
+
// Pass it through untouched rather than relocating it — an added field is
|
|
36
|
+
// never worth changing a response's top-level shape.
|
|
37
|
+
return jsonResponse(wrapped ?? data);
|
|
20
38
|
});
|
|
21
39
|
if (allowWrites)
|
|
22
40
|
server.registerTool('ofw_create_journal_entry', {
|
package/dist/tools/messages.js
CHANGED
|
@@ -10,6 +10,7 @@ import { getAllowMarkRead, getAttachmentsDir, getAutoRefreshStaleReads, getDefau
|
|
|
10
10
|
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
|
+
import { pageState } from './pagination.js';
|
|
13
14
|
// Schemas for the load-bearing fields of each /pub/v3 response this file
|
|
14
15
|
// reads (issue #83). Loose: unknown keys pass through into cached listData.
|
|
15
16
|
const DateSchema = z.looseObject({ dateTime: z.string() });
|
|
@@ -254,7 +255,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
254
255
|
return jsonResponse({ folders: data, freshness });
|
|
255
256
|
});
|
|
256
257
|
server.registerTool('ofw_list_messages', {
|
|
257
|
-
description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" — check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
|
|
258
|
+
description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based (1-based `page`) but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages. Results are newest-first by default; `sort:"oldest"` starts at the old end of a range instead of paging to it. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" — check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
|
|
258
259
|
annotations: { readOnlyHint: false },
|
|
259
260
|
inputSchema: {
|
|
260
261
|
folderId: z.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
|
|
@@ -263,11 +264,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
263
264
|
since: z.string().describe('ISO date or datetime — only messages with sent_at >= since (inclusive)').optional(),
|
|
264
265
|
until: z.string().describe('ISO date or datetime — only messages with sent_at < until (exclusive)').optional(),
|
|
265
266
|
q: z.string().describe('Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.').optional(),
|
|
267
|
+
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(),
|
|
266
268
|
autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
|
|
267
269
|
},
|
|
268
270
|
}, async (args) => {
|
|
269
271
|
const page = args.page ?? 1;
|
|
270
272
|
const size = args.size ?? 50;
|
|
273
|
+
const sort = args.sort ?? 'newest';
|
|
271
274
|
const folderArg = args.folderId ?? 'both';
|
|
272
275
|
let folder;
|
|
273
276
|
if (folderArg === 'inbox')
|
|
@@ -303,7 +306,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
303
306
|
// can be stale (a message read after it was first scraped), so `read`
|
|
304
307
|
// is derived from the record's own `viewedAt`/`fetchedBodyAt` and
|
|
305
308
|
// `listData` is forced to agree — see withReadState.
|
|
306
|
-
const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
|
|
309
|
+
const messages = (await cache.listMessages({ ...filter, page, size, sort })).map((m) => withReadState(m));
|
|
307
310
|
// Served from the local cache, so the result must say how old it is and
|
|
308
311
|
// whether anything vouches for it — a caller cannot state current state
|
|
309
312
|
// from this payload without either re-reading or surfacing the caveat.
|
|
@@ -328,7 +331,28 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
328
331
|
// needs one boolean to check before saying "you have N messages".
|
|
329
332
|
const fullSlice = page === 1 && messages.length === total;
|
|
330
333
|
const complete = fullSlice && freshness.staleness === 'fresh' && freshness.historyComplete;
|
|
331
|
-
const
|
|
334
|
+
const { hasMore, nextPage } = pageState({ page, size, total });
|
|
335
|
+
// Key ORDER is load-bearing, not cosmetic. These responses are large enough
|
|
336
|
+
// that a client may spill them to a file and a script may pull out only the
|
|
337
|
+
// fields it thought to name — which is how a correct `complete:false` next
|
|
338
|
+
// to a correct `completeNote` was dropped, and a 60-of-391 slice was
|
|
339
|
+
// reported as "October 1-28 is unreachable". Paging state and freshness are
|
|
340
|
+
// emitted BEFORE `messages` so a head, a preview, or a truncated read hits
|
|
341
|
+
// "this is a slice, fetch page 2" before it hits the first message body.
|
|
342
|
+
// The bulk goes last. See tests asserting this order — a refactor that
|
|
343
|
+
// rebuilds this literal silently undoes it.
|
|
344
|
+
const payload = {
|
|
345
|
+
complete,
|
|
346
|
+
hasMore,
|
|
347
|
+
nextPage,
|
|
348
|
+
// The honest record count, as a scalar and ahead of the array — a
|
|
349
|
+
// consumer never has to reach `messages` to learn how many came back.
|
|
350
|
+
returned: messages.length,
|
|
351
|
+
total,
|
|
352
|
+
page,
|
|
353
|
+
size,
|
|
354
|
+
sort,
|
|
355
|
+
};
|
|
332
356
|
if (!complete) {
|
|
333
357
|
payload.completeNote = [
|
|
334
358
|
!fullSlice ? `this page holds ${messages.length} of ${total} matching cached messages` : null,
|
|
@@ -342,11 +366,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
342
366
|
payload.note = 'No messages match these filters, and the cache IS verified-fresh for these folders — so this is a real "nothing matched", not a stale-cache artefact. If you expected results, relax the filters.';
|
|
343
367
|
}
|
|
344
368
|
else if (page * size < total) {
|
|
345
|
-
payload.note = `Showing ${(page - 1) * size + 1}–${(page - 1) * size + messages.length} of ${total}. Increase 'page' to see more,
|
|
369
|
+
payload.note = `Showing ${(page - 1) * size + 1}–${(page - 1) * size + messages.length} of ${total}, ${sort} first. Increase 'page' to see more, narrow with since/until/q, or set sort:"${sort === 'newest' ? 'oldest' : 'newest'}" to start from the other end.`;
|
|
346
370
|
}
|
|
347
371
|
if (refreshed) {
|
|
348
372
|
payload.autoRefreshed = true;
|
|
349
373
|
}
|
|
374
|
+
payload.freshness = freshness;
|
|
375
|
+
payload.messages = messages;
|
|
350
376
|
return jsonResponse(payload);
|
|
351
377
|
});
|
|
352
378
|
server.registerTool('ofw_get_message', {
|
|
@@ -916,7 +942,18 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
916
942
|
// against OFW inside the freshness window AND not a slice of a larger list.
|
|
917
943
|
const fullSlice = page === 1 && drafts.length === total;
|
|
918
944
|
const complete = serverConfirmed && fullSlice;
|
|
919
|
-
const
|
|
945
|
+
const { hasMore, nextPage } = pageState({ page, size, total });
|
|
946
|
+
// Paging state and freshness ahead of the bulk — see the note in
|
|
947
|
+
// ofw_list_messages.
|
|
948
|
+
const payload = {
|
|
949
|
+
complete,
|
|
950
|
+
hasMore,
|
|
951
|
+
nextPage,
|
|
952
|
+
returned: drafts.length,
|
|
953
|
+
total,
|
|
954
|
+
page,
|
|
955
|
+
size,
|
|
956
|
+
};
|
|
920
957
|
if (!complete) {
|
|
921
958
|
payload.completeNote = [
|
|
922
959
|
!fullSlice ? `this page holds ${drafts.length} of ${total} cached drafts` : null,
|
|
@@ -937,6 +974,8 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
937
974
|
if (verifyNote !== null) {
|
|
938
975
|
payload.verifyNote = verifyNote;
|
|
939
976
|
}
|
|
977
|
+
payload.freshness = freshness;
|
|
978
|
+
payload.drafts = drafts;
|
|
940
979
|
return jsonResponse(payload);
|
|
941
980
|
});
|
|
942
981
|
if (allowDrafts)
|
|
@@ -1226,7 +1265,20 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1226
1265
|
unread.push({ id: msg.id, subject: msg.subject, sentAt: msg.sentAt, unreadBy });
|
|
1227
1266
|
}
|
|
1228
1267
|
}
|
|
1229
|
-
const
|
|
1268
|
+
const { hasMore, nextPage } = pageState({ page, size, total });
|
|
1269
|
+
// Paging state ahead of the bulk — see the note in ofw_list_messages. Note
|
|
1270
|
+
// `unread` is a VERDICT list, not the scanned set: it is routinely empty
|
|
1271
|
+
// while the scan itself is truncated, which is what `scanned`/`total`/
|
|
1272
|
+
// `complete` are for.
|
|
1273
|
+
const payload = {
|
|
1274
|
+
complete,
|
|
1275
|
+
hasMore,
|
|
1276
|
+
nextPage,
|
|
1277
|
+
total,
|
|
1278
|
+
scanned: sent.length,
|
|
1279
|
+
page,
|
|
1280
|
+
size,
|
|
1281
|
+
};
|
|
1230
1282
|
if (!complete) {
|
|
1231
1283
|
payload.completeNote = `This verdict covers the ${sent.length} of ${total} cached sent messages on this page${freshness.staleness === 'fresh' ? '' : `, from a cache that is "${freshness.staleness}"`}. It is not a statement about every message you have sent.`;
|
|
1232
1284
|
}
|
|
@@ -1236,6 +1288,8 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1236
1288
|
if (refreshed) {
|
|
1237
1289
|
payload.autoRefreshed = true;
|
|
1238
1290
|
}
|
|
1291
|
+
payload.freshness = freshness;
|
|
1292
|
+
payload.unread = unread;
|
|
1239
1293
|
return jsonResponse(payload);
|
|
1240
1294
|
});
|
|
1241
1295
|
if (allowDrafts)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pagination state that survives lossy consumption.
|
|
3
|
+
*
|
|
4
|
+
* The failure this module exists to prevent: a correct response was ignored.
|
|
5
|
+
* Three wide date-range reads each returned `complete:false` alongside a `note`
|
|
6
|
+
* and a `completeNote` spelling out that the page held 60 of 391 and how to get
|
|
7
|
+
* the rest. The responses were large, so the client spilled them to a JSON file
|
|
8
|
+
* and a script pulled out `total` and `messages` — dropping every field that
|
|
9
|
+
* said "this is a slice". The caller then reported October 1-28 as unreachable.
|
|
10
|
+
*
|
|
11
|
+
* The tool cannot fix a cherry-picking script. It can stop being easy to
|
|
12
|
+
* cherry-pick wrongly, which is what these helpers encode:
|
|
13
|
+
*
|
|
14
|
+
* - **State before bulk.** Callers place the paging/freshness keys ahead of the
|
|
15
|
+
* data array, so a `head`, a truncated preview, or a partial read reaches
|
|
16
|
+
* "this is 60 of 391" before it reaches the first message body. Key order in
|
|
17
|
+
* JSON is free — an object literal's insertion order is what JSON.stringify
|
|
18
|
+
* emits — so this costs nothing and is pure upside.
|
|
19
|
+
* - **The remedy is a value, not an inference.** `complete:false` tells a
|
|
20
|
+
* reader something is wrong; `nextPage: 2` tells it what to do. A reader that
|
|
21
|
+
* understands neither prose nor booleans can still act on an integer.
|
|
22
|
+
* - **The honest count is a scalar, not the array's length.** `returned` sits
|
|
23
|
+
* in the header beside `total`, so a consumer never has to reach the array
|
|
24
|
+
* to learn how many records came back.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Compute paging state for a 1-based `page`/`size` read.
|
|
28
|
+
*
|
|
29
|
+
* `hasMore` is decided from the OFFSET, not from `returned < total`: on a page
|
|
30
|
+
* past the end of the result set, `returned` is 0 while `total` is large, and
|
|
31
|
+
* comparing those two would advertise a `nextPage` that returns nothing forever.
|
|
32
|
+
*/
|
|
33
|
+
export function pageState(input) {
|
|
34
|
+
const hasMore = input.page * input.size < input.total;
|
|
35
|
+
return { hasMore, nextPage: hasMore ? input.page + 1 : null };
|
|
36
|
+
}
|
|
37
|
+
function asRecord(value) {
|
|
38
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
39
|
+
? value
|
|
40
|
+
: null;
|
|
41
|
+
}
|
|
42
|
+
/** Read the paging facts out of an OFW `{data, metadata}` list envelope. */
|
|
43
|
+
function recordArray(root) {
|
|
44
|
+
// `data` is the envelope both endpoints actually use, so it wins outright.
|
|
45
|
+
// Falling back to the first array-valued key matters because the alternative
|
|
46
|
+
// is silently publishing `returned: 0` and a "reaches the end of the list"
|
|
47
|
+
// note ALONGSIDE real records — the precise shape of lie this file exists to
|
|
48
|
+
// prevent, reintroduced by a backend rename.
|
|
49
|
+
if (Array.isArray(root.data))
|
|
50
|
+
return root.data;
|
|
51
|
+
for (const value of Object.values(root)) {
|
|
52
|
+
if (Array.isArray(value))
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
export function readUpstreamPaging(payload) {
|
|
58
|
+
const root = asRecord(payload);
|
|
59
|
+
const rows = root === null ? null : recordArray(root);
|
|
60
|
+
const meta = root === null ? null : asRecord(root.metadata);
|
|
61
|
+
const total = meta !== null && typeof meta.totalElements === 'number' && Number.isFinite(meta.totalElements)
|
|
62
|
+
? meta.totalElements
|
|
63
|
+
: null;
|
|
64
|
+
const last = meta !== null && typeof meta.last === 'boolean' ? meta.last : null;
|
|
65
|
+
return { returned: rows?.length ?? 0, total, last };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Compute paging state for a `start`/`max` read, best evidence first.
|
|
69
|
+
*
|
|
70
|
+
* OFW's own `metadata.last` is authoritative and used whenever present. Failing
|
|
71
|
+
* that, a reported total settles it. Failing both, a FULL page is treated as
|
|
72
|
+
* probably-more: the bias is one-directional on purpose, because a `nextStart`
|
|
73
|
+
* that returns an empty page costs one wasted call, while a null that hides a
|
|
74
|
+
* further page reproduces the bug this file exists for — a slice narrated as
|
|
75
|
+
* the whole.
|
|
76
|
+
*/
|
|
77
|
+
export function offsetState(input) {
|
|
78
|
+
const last = input.last ?? null;
|
|
79
|
+
const consumed = input.start - input.base + input.returned;
|
|
80
|
+
const hasMore = last !== null
|
|
81
|
+
? !last
|
|
82
|
+
: input.total !== null
|
|
83
|
+
? consumed < input.total
|
|
84
|
+
: input.returned >= input.max;
|
|
85
|
+
return { hasMore, nextStart: hasMore ? input.start + input.max : null };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Prepend paging state to an OFW list envelope, renaming and dropping nothing.
|
|
89
|
+
*
|
|
90
|
+
* The upstream object is spread in BEHIND the paging keys, so every key it had
|
|
91
|
+
* stays exactly where a caller expects to find it — only the order changes.
|
|
92
|
+
* The head is then spread a SECOND time: a plain `{...head, ...body}` lets an
|
|
93
|
+
* upstream `hasMore`/`nextStart`/`paginationNote` overwrite the computed value
|
|
94
|
+
* while keeping the head's key position, which is worse than either — a paging
|
|
95
|
+
* field sitting in the paging slot, reading as ours, sourced from elsewhere.
|
|
96
|
+
* Re-spreading restores our values; keys keep their first-insertion position,
|
|
97
|
+
* so the order is unchanged.
|
|
98
|
+
* Returns null when the payload is not a plain object and therefore cannot
|
|
99
|
+
* carry sibling keys at all; the caller then passes it through untouched rather
|
|
100
|
+
* than relocating it, so this can never change a response's top-level shape.
|
|
101
|
+
*/
|
|
102
|
+
export function withPaginationFirst(input) {
|
|
103
|
+
const body = asRecord(input.payload);
|
|
104
|
+
if (body === null)
|
|
105
|
+
return null;
|
|
106
|
+
const scope = input.total !== null ? ` of ${input.total}` : '';
|
|
107
|
+
const head = {
|
|
108
|
+
hasMore: input.state.hasMore,
|
|
109
|
+
nextStart: input.state.nextStart,
|
|
110
|
+
start: input.start,
|
|
111
|
+
max: input.max,
|
|
112
|
+
returned: input.returned,
|
|
113
|
+
...(input.total !== null ? { total: input.total } : {}),
|
|
114
|
+
paginationNote: input.state.hasMore
|
|
115
|
+
? `PARTIAL: this response holds ${input.returned} record(s) starting at ${input.start}${scope}. `
|
|
116
|
+
+ `${input.hint} Do not state a total or an absence from this response alone.`
|
|
117
|
+
: `This response reaches the end of the list${scope === '' ? '' : ` (${input.total} record(s) in total)`}.`,
|
|
118
|
+
};
|
|
119
|
+
return { ...head, ...body, ...head };
|
|
120
|
+
}
|
package/package.json
CHANGED
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.11.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.11.0",
|
|
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?, page?, size?, autoRefresh?)` | Cache-backed list. Supports folder ("inbox"/"sent"/"both"), date range, and substring search. Returns `complete` for the RESULT SET
|
|
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
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. |
|
|
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
|
-
| `ofw_get_unread_sent(page?, size?, autoRefresh?)` | Sent messages your co-parent hasn't read yet (from cache).
|
|
99
|
-
| `ofw_list_drafts(page?, size?, verify?, autoRefresh?)` | 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). |
|
|
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). |
|
|
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`. |
|
|
@@ -133,6 +133,7 @@ Message and draft reads come from a local cache, so **a result can be stale with
|
|
|
133
133
|
|---|---|
|
|
134
134
|
| How old is this data? | `freshness` — `staleness` (`fresh`/`unverified`/`stale`), `asOf`, `ageSeconds`, a quotable `warning` |
|
|
135
135
|
| Is this the WHOLE answer? | `complete` on `ofw_list_messages` / `ofw_list_drafts` / `ofw_get_unread_sent` / `ofw_status` |
|
|
136
|
+
| If not, how do I get the rest? | `nextPage` (message tools) or `nextStart` (`ofw_list_expenses` / `ofw_list_journal_entries`) — null means there is no more |
|
|
136
137
|
| Is this entity still what I think it is? | `state` from `ofw_status` / `ofw_check_freshness` |
|
|
137
138
|
|
|
138
139
|
Rules:
|