ofw-mcp 2.10.1 → 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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "OurFamilyWizard tools for Claude Code",
9
- "version": "2.10.1"
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.10.1",
17
+ "version": "2.11.0",
18
18
  "author": {
19
19
  "name": "Chris Chall"
20
20
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ofw",
3
3
  "displayName": "OurFamilyWizard",
4
- "version": "2.10.1",
4
+ "version": "2.11.0",
5
5
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
6
6
  "author": {
7
7
  "name": "Chris Chall"
package/README.md CHANGED
@@ -124,17 +124,6 @@ Environment variables always take priority over the `.env` file. You can also pa
124
124
  OFW_USERNAME=you@example.com OFW_PASSWORD=yourpass node dist/index.js
125
125
  ```
126
126
 
127
- ## Hosted connector (Cloudflare Worker)
128
-
129
- Instead of running `ofw-mcp` locally, you can add it to [claude.ai](https://claude.ai) as a **remote MCP connector** — a hosted Cloudflare Worker you reach from Settings → Connectors on Claude web, desktop, or mobile (connectors sync across all three). The same tool registrars back both targets, so the tools and behaviour are identical to the local stdio install; the Worker just wraps them with [`@chrischall/mcp-connector`](https://www.npmjs.com/package/@chrischall/mcp-connector) (the shared OAuth + streamable-HTTP harness) and a per-user [Durable Object](src/cache/durable.ts) cache in place of the local SQLite file.
130
-
131
- - **How you connect.** Each person you share the connector URL with logs in through the connector's own OAuth page with their **own** OurFamilyWizard email and password. Those credentials are stored (encrypted at rest) per user because OFW bearer tokens expire after ~6h with no refresh token, so the connector must be able to re-login on its own. One user can never see another's account or cache.
132
- - **Attachments are inline-only.** The Worker has no local filesystem, so `ofw_download_attachment` always returns content as MCP content blocks (`OFW_INLINE_ATTACHMENTS=true`) rather than writing to disk. Spreadsheets, PDFs and Office documents come back as extracted text/CSV, so they are readable even though the host cannot render the file itself.
133
- - **Write mode defaults to `all`.** The hosted connector registers every tool by default, configurable per deployment via `OFW_WRITE_MODE` / `OFW_CALENDAR_WRITES` in `wrangler.jsonc` — see [Write protection](#write-protection-ofw_write_mode).
134
- - **Message sync is bounded and resumable.** To stay under Cloudflare's per-request subrequest cap, `ofw_sync_messages` on the hosted connector caps how many OFW requests one call makes (`OFW_SYNC_MAX_REQUESTS` in `wrangler.jsonc`, default `40`) and resumes across calls, so a large mailbox backfills over multiple `ofw_sync_messages` calls rather than one; the local stdio server is unbounded. See [`docs/DEPLOY-CONNECTOR.md`](docs/DEPLOY-CONNECTOR.md#sync--the-subrequest-limit).
135
-
136
- Standing this up requires a Cloudflare account and is a one-time setup for whoever hosts it; after that the `deploy-connector` job in `release-please.yml` deploys each release automatically (and **Actions → deploy-connector → Run workflow** deploys any ref on demand) — see [`docs/DEPLOY-CONNECTOR.md`](docs/DEPLOY-CONNECTOR.md) for the full runbook. `wrangler.jsonc` serves the Worker at a custom domain (`https://connector.ofw.nullnet.app/mcp`) plus the account's `*.workers.dev` URL; whoever hosts it uses their own domain. The local stdio / `.mcpb` install above remains the desktop-only alternative if you'd rather run it against just your own account.
137
-
138
127
  ## Available tools
139
128
 
140
129
  Read-only tools run automatically. Write tools ask for your confirmation first. The *Write mode* column shows the minimum `OFW_WRITE_MODE` a tool needs to be available at all — see [Write protection](#write-protection-ofw_write_mode) below.
@@ -47,7 +47,7 @@ export async function loginWithPassword(username, password) {
47
47
  // OFW rejects bad credentials by re-serving its HTML login page (Spring
48
48
  // Security re-renders the form rather than returning 401/JSON). Surface a
49
49
  // clean, actionable message instead of dumping the HTML page — this is what
50
- // the hosted connector's login page shows the user on a failed sign-in.
50
+ // a hosted deployment's login page shows the user on a failed sign-in.
51
51
  if (contentType.includes('text/html')) {
52
52
  throw new Error('OFW login failed — your OurFamilyWizard email or password was not accepted. Check them and try again.');
53
53
  }
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.10.1",
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)",
@@ -39103,10 +39114,7 @@ var package_default = {
39103
39114
  dev: "node --env-file=.env dist/index.js",
39104
39115
  test: "vitest run",
39105
39116
  "test:coverage": "vitest run --coverage",
39106
- "test:watch": "vitest",
39107
- "worker:dev": "wrangler dev",
39108
- "worker:deploy": "wrangler deploy",
39109
- "worker:test": "vitest run --config vitest.workers.config.ts"
39117
+ "test:watch": "vitest"
39110
39118
  },
39111
39119
  dependencies: {
39112
39120
  "@chrischall/mcp-utils": "^0.14.0",
@@ -39116,17 +39124,11 @@ var package_default = {
39116
39124
  zod: "^4.4.3"
39117
39125
  },
39118
39126
  devDependencies: {
39119
- "@chrischall/mcp-connector": "^1.1.1",
39120
- "@cloudflare/vitest-pool-workers": "^0.19.1",
39121
- "@cloudflare/workers-oauth-provider": "^0.8.1",
39122
- "@cloudflare/workers-types": "^5.20260708.1",
39123
39127
  "@types/node": "^26.0.0",
39124
39128
  "@vitest/coverage-v8": "^4.1.7",
39125
- agents: "^0.19.0",
39126
39129
  esbuild: "^0.28.0",
39127
39130
  typescript: "^7.0.2",
39128
- vitest: "^4.1.7",
39129
- wrangler: "^4.110.0"
39131
+ vitest: "^4.1.7"
39130
39132
  }
39131
39133
  };
39132
39134
 
@@ -39232,7 +39234,7 @@ var OFWClient = class {
39232
39234
  // Optional injected auth resolver. When set, the refresh callback uses it
39233
39235
  // instead of the module-level global `resolveAuth` (env-var → fetchproxy
39234
39236
  // priority). A hosted per-user deployment injects its own resolver so each
39235
- // request carries that user's credentials — see the Cloudflare Worker
39237
+ // request carries that user's credentials — see the per-user
39236
39238
  // deployment. Left undefined by the stdio path, which falls back to the
39237
39239
  // global resolver, keeping that behaviour byte-for-byte identical.
39238
39240
  authResolver;
@@ -41615,6 +41617,53 @@ async function buildInlineDelivery(input) {
41615
41617
 
41616
41618
  // src/tools/messages.ts
41617
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
41618
41667
  var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
41619
41668
  var SentDetailSchema = external_exports.looseObject({
41620
41669
  subject: external_exports.string().optional(),
@@ -41750,7 +41799,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41750
41799
  return jsonResponse({ folders: data, freshness });
41751
41800
  });
41752
41801
  server.registerTool("ofw_list_messages", {
41753
- 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.',
41754
41803
  annotations: { readOnlyHint: false },
41755
41804
  inputSchema: {
41756
41805
  folderId: external_exports.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
@@ -41759,11 +41808,13 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41759
41808
  since: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at >= since (inclusive)").optional(),
41760
41809
  until: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at < until (exclusive)").optional(),
41761
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(),
41762
41812
  autoRefresh: external_exports.boolean().describe(AUTO_REFRESH_DESC).optional()
41763
41813
  }
41764
41814
  }, async (args) => {
41765
41815
  const page = args.page ?? 1;
41766
41816
  const size = args.size ?? 50;
41817
+ const sort = args.sort ?? "newest";
41767
41818
  const folderArg = args.folderId ?? "both";
41768
41819
  let folder;
41769
41820
  if (folderArg === "inbox") folder = "inbox";
@@ -41789,7 +41840,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41789
41840
  isEmpty: (v) => v.total === 0,
41790
41841
  read: async () => {
41791
41842
  const total2 = await cache.countMessages(filter);
41792
- 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));
41793
41844
  const freshness2 = await buildFreshness(cache, { source: "cache", folders });
41794
41845
  return { messages: messages2, total: total2, freshness: freshness2 };
41795
41846
  }
@@ -41806,7 +41857,19 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41806
41857
  const { messages, total, freshness } = value;
41807
41858
  const fullSlice = page === 1 && messages.length === total;
41808
41859
  const complete = fullSlice && freshness.staleness === "fresh" && freshness.historyComplete;
41809
- const payload = { messages, total, page, size, complete, freshness };
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
+ };
41810
41873
  if (!complete) {
41811
41874
  payload.completeNote = [
41812
41875
  !fullSlice ? `this page holds ${messages.length} of ${total} matching cached messages` : null,
@@ -41817,11 +41880,13 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41817
41880
  if (total === 0) {
41818
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.';
41819
41882
  } else if (page * size < total) {
41820
- payload.note = `Showing ${(page - 1) * size + 1}\u2013${(page - 1) * size + messages.length} of ${total}. Increase 'page' to see more, or narrow with since/until/q.`;
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.`;
41821
41884
  }
41822
41885
  if (refreshed) {
41823
41886
  payload.autoRefreshed = true;
41824
41887
  }
41888
+ payload.freshness = freshness;
41889
+ payload.messages = messages;
41825
41890
  return jsonResponse(payload);
41826
41891
  });
41827
41892
  server.registerTool("ofw_get_message", {
@@ -42268,7 +42333,16 @@ ${JSON.stringify(
42268
42333
  const { drafts, total, freshness, serverConfirmed } = value;
42269
42334
  const fullSlice = page === 1 && drafts.length === total;
42270
42335
  const complete = serverConfirmed && fullSlice;
42271
- const payload = { drafts, total, page, size, complete, freshness };
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
+ };
42272
42346
  if (!complete) {
42273
42347
  payload.completeNote = [
42274
42348
  !fullSlice ? `this page holds ${drafts.length} of ${total} cached drafts` : null,
@@ -42287,6 +42361,8 @@ ${JSON.stringify(
42287
42361
  if (verifyNote !== null) {
42288
42362
  payload.verifyNote = verifyNote;
42289
42363
  }
42364
+ payload.freshness = freshness;
42365
+ payload.drafts = drafts;
42290
42366
  return jsonResponse(payload);
42291
42367
  });
42292
42368
  if (allowDrafts) server.registerTool("ofw_save_draft", {
@@ -42518,7 +42594,16 @@ ${text}` : text);
42518
42594
  unread.push({ id: msg.id, subject: msg.subject, sentAt: msg.sentAt, unreadBy });
42519
42595
  }
42520
42596
  }
42521
- const payload = { unread, scanned: sent.length, total, complete, freshness };
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
+ };
42522
42607
  if (!complete) {
42523
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.`;
42524
42609
  }
@@ -42528,6 +42613,8 @@ ${text}` : text);
42528
42613
  if (refreshed) {
42529
42614
  payload.autoRefreshed = true;
42530
42615
  }
42616
+ payload.freshness = freshness;
42617
+ payload.unread = unread;
42531
42618
  return jsonResponse(payload);
42532
42619
  });
42533
42620
  if (allowDrafts) server.registerTool("ofw_upload_attachment", {
@@ -43046,17 +43133,27 @@ function registerExpenseTools(server, client2) {
43046
43133
  return jsonResponse(data);
43047
43134
  });
43048
43135
  server.registerTool("ofw_list_expenses", {
43049
- description: "List OurFamilyWizard expenses with pagination",
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.",
43050
43137
  annotations: { readOnlyHint: true },
43051
43138
  inputSchema: {
43052
- 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(),
43053
43140
  max: external_exports.number().int().min(1).describe("Max results (default 20)").optional()
43054
43141
  }
43055
43142
  }, async (args) => {
43056
43143
  const start = args.start ?? 0;
43057
43144
  const max = args.max ?? 20;
43058
43145
  const data = await client2.request("GET", `/pub/v2/expense/expenses?start=${start}&max=${max}`);
43059
- return jsonResponse(data);
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);
43060
43157
  });
43061
43158
  if (allowWrites) server.registerTool("ofw_create_expense", {
43062
43159
  description: "Log a new expense in OurFamilyWizard",
@@ -43075,17 +43172,27 @@ function registerExpenseTools(server, client2) {
43075
43172
  function registerJournalTools(server, client2) {
43076
43173
  const allowWrites = getWriteMode() === "all";
43077
43174
  server.registerTool("ofw_list_journal_entries", {
43078
- 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.",
43079
43176
  annotations: { readOnlyHint: true },
43080
43177
  inputSchema: {
43081
- 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(),
43082
43179
  max: external_exports.number().int().min(1).describe("Max results (default 10)").optional()
43083
43180
  }
43084
43181
  }, async (args) => {
43085
43182
  const start = args.start ?? 1;
43086
43183
  const max = args.max ?? 10;
43087
43184
  const data = await client2.request("GET", `/pub/v1/journals?start=${start}&max=${max}`);
43088
- return jsonResponse(data);
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);
43089
43196
  });
43090
43197
  if (allowWrites) server.registerTool("ofw_create_journal_entry", {
43091
43198
  description: "Create a new journal entry in OurFamilyWizard",
@@ -43302,7 +43409,7 @@ var OFWCacheCore = class {
43302
43409
  }
43303
43410
  /**
43304
43411
  * Batch upsert every row in a single transaction — one round-trip's worth of
43305
- * work (crucial on the Durable Object backend, where each RPC is a subrequest).
43412
+ * work (crucial where each round trip is a billed request).
43306
43413
  * Empty array is a no-op (no transaction opened).
43307
43414
  */
43308
43415
  upsertMessages(rows) {
@@ -43341,9 +43448,10 @@ var OFWCacheCore = class {
43341
43448
  listMessages(opts) {
43342
43449
  const { where, params } = buildMessageFilter(opts);
43343
43450
  const offset = (opts.page - 1) * opts.size;
43451
+ const dir = opts.sort === "oldest" ? "ASC" : "DESC";
43344
43452
  const rows = this.db.all(
43345
43453
  `SELECT * FROM messages ${where}
43346
- ORDER BY sent_at DESC, id DESC
43454
+ ORDER BY sent_at ${dir}, id ${dir}
43347
43455
  LIMIT ? OFFSET ?`,
43348
43456
  [...params, opts.size, offset]
43349
43457
  );
@@ -43445,8 +43553,8 @@ var OFWCacheCore = class {
43445
43553
  return r ? lineageFromDb(r) : null;
43446
43554
  }
43447
43555
  /**
43448
- * Batch read — one query for a whole page of drafts. On the Durable Object
43449
- * backend each cache call is a subrequest, so a per-draft lookup would spend
43556
+ * Batch read — one query for a whole page of drafts. Where the cache is
43557
+ * remote each cache call is a subrequest, so a per-draft lookup would spend
43450
43558
  * the caller's sync budget on bookkeeping.
43451
43559
  */
43452
43560
  getDraftLineageByIds(ids) {
@@ -43730,7 +43838,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
43730
43838
  var nodeAttachmentIO = new NodeAttachmentIO();
43731
43839
  await runMcp({
43732
43840
  name: "ofw",
43733
- version: "2.10.1",
43841
+ version: "2.11.0",
43734
43842
  // x-release-please-version
43735
43843
  deps: client,
43736
43844
  tools: [
@@ -5,8 +5,8 @@ import { OFWCacheCore, LocalCacheStore } from './store.js';
5
5
  // The `node:sqlite` backend for the OFW message cache — a local on-disk SQLite
6
6
  // file used by the stdio/desktop server. The query logic lives in OFWCacheCore
7
7
  // (src/cache/store.ts); this file only adapts `node:sqlite` to the SqlDriver
8
- // surface and manages the file handle + permissions. (The hosted Cloudflare
9
- // connector uses a Durable Object backend instead a later task.)
8
+ // surface and manages the file handle + permissions. Another deployment can
9
+ // adapt a different driver to the same surface.
10
10
  /** Adapts a `node:sqlite` DatabaseSync to the driver surface the core needs. */
11
11
  export class NodeSqlDriver {
12
12
  db;
@@ -3,9 +3,9 @@
3
3
  // All message reads (list/get/drafts/unread-sent) are served from this cache;
4
4
  // only ofw_sync_messages walks OFW for new content. The SQL lives here ONCE,
5
5
  // over a tiny synchronous {@link SqlDriver}, so the same schema/queries back
6
- // both engines: `node:sqlite` on the stdio/desktop server (src/cache/node.ts)
7
- // and a Durable Object's SQLite on the hosted Cloudflare connector (a later
8
- // task). This module imports nothing platform-specific.
6
+ // any engine: `node:sqlite` is the one that ships (src/cache/node.ts), and
7
+ // another deployment can adapt a different driver to the same surface. This
8
+ // module imports nothing platform-specific.
9
9
  function rowFromDb(r) {
10
10
  return {
11
11
  id: r.id,
@@ -131,7 +131,7 @@ export const SCHEMA_STATEMENTS = [
131
131
  * every open. SQLite has no `ADD COLUMN IF NOT EXISTS`, so each statement runs
132
132
  * inside a try/catch — re-running against an already-migrated DB throws
133
133
  * "duplicate column name", which is swallowed. Driver-agnostic: both
134
- * `node:sqlite` and the Durable Object's SQLite raise synchronously.
134
+ * `node:sqlite` raises synchronously, as any conforming driver must.
135
135
  */
136
136
  export const MIGRATIONS = [
137
137
  // Resumable deep-sync cursor. Absent/NULL → SyncState.resumePage null.
@@ -222,7 +222,7 @@ export class OFWCacheCore {
222
222
  }
223
223
  /**
224
224
  * Batch upsert every row in a single transaction — one round-trip's worth of
225
- * work (crucial on the Durable Object backend, where each RPC is a subrequest).
225
+ * work (crucial where each round trip is a billed request).
226
226
  * Empty array is a no-op (no transaction opened).
227
227
  */
228
228
  upsertMessages(rows) {
@@ -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 DESC, id DESC
270
+ ORDER BY sent_at ${dir}, id ${dir}
266
271
  LIMIT ? OFFSET ?`, [...params, opts.size, offset]);
267
272
  return rows.map(rowFromDb);
268
273
  }
@@ -351,8 +356,8 @@ export class OFWCacheCore {
351
356
  return r ? lineageFromDb(r) : null;
352
357
  }
353
358
  /**
354
- * Batch read — one query for a whole page of drafts. On the Durable Object
355
- * backend each cache call is a subrequest, so a per-draft lookup would spend
359
+ * Batch read — one query for a whole page of drafts. Where the cache is
360
+ * remote each cache call is a subrequest, so a per-draft lookup would spend
356
361
  * the caller's sync budget on bookkeeping.
357
362
  */
358
363
  getDraftLineageByIds(ids) {
@@ -463,7 +468,7 @@ export class OFWCacheCore {
463
468
  }
464
469
  /**
465
470
  * Adapts a synchronous {@link OFWCacheCore} to the async {@link CacheStore}
466
- * interface. Used by the in-process node backend; the Durable Object backend
471
+ * interface. Used by the in-process node backend; a remote backend
467
472
  * implements CacheStore over a real RPC boundary instead.
468
473
  */
469
474
  export class LocalCacheStore {
package/dist/client.js CHANGED
@@ -6,16 +6,16 @@ import { resolveAuth } from './auth.js';
6
6
  import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
7
7
  // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
8
8
  // bundle). loadDotenvSafely applies override:false + quiet:true and swallows a
9
- // missing dotenv module. The try/catch additionally guards the Cloudflare
10
- // Worker runtime, where `import.meta.url` is undefined and
11
- // `fileURLToPath(undefined)` would otherwise throw at module init (Worker
9
+ // missing dotenv module. The try/catch additionally guards a runtime where
10
+ // `import.meta.url` is undefined and `fileURLToPath(undefined)` would
11
+ // otherwise throw at module init (a failure at
12
12
  // startup validation) — there is no filesystem / .env to load there anyway.
13
13
  try {
14
14
  const dir = dirname(fileURLToPath(import.meta.url));
15
15
  await loadDotenvSafely({ path: join(dir, '..', '.env') });
16
16
  }
17
17
  catch {
18
- /* v8 ignore next -- only reached in a non-Node runtime (Workers): no .env to load */
18
+ /* v8 ignore next -- only reached in a non-Node runtime: no .env to load */
19
19
  }
20
20
  // Parse a Content-Disposition header for a filename. Prefers RFC 6266
21
21
  // `filename*=UTF-8''…` (percent-decoded) and falls back to `filename="…"`.
@@ -69,7 +69,7 @@ export class OFWClient {
69
69
  // Optional injected auth resolver. When set, the refresh callback uses it
70
70
  // instead of the module-level global `resolveAuth` (env-var → fetchproxy
71
71
  // priority). A hosted per-user deployment injects its own resolver so each
72
- // request carries that user's credentials — see the Cloudflare Worker
72
+ // request carries that user's credentials — see the per-user
73
73
  // deployment. Left undefined by the stdio path, which falls back to the
74
74
  // global resolver, keeping that behaviour byte-for-byte identical.
75
75
  authResolver;
package/dist/config.js CHANGED
@@ -144,16 +144,16 @@ export function getAutoRefreshStaleReads() {
144
144
  }
145
145
  // Default for ofw_download_attachment's `inline` arg when the caller doesn't
146
146
  // pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
147
- // MCP content blocks by default (skipping disk) — useful on sandboxed MCP
148
- // hosts where filesystem reads back to the model aren't available.
147
+ // MCP content blocks by default (skipping disk) — necessary wherever the
148
+ // caller cannot read the server's filesystem.
149
149
  export function getDefaultInlineAttachments() {
150
150
  return parseBoolEnv('OFW_INLINE_ATTACHMENTS');
151
151
  }
152
152
  /**
153
153
  * Per-invocation OFW-request budget for ofw_sync_messages.
154
154
  *
155
- * The hosted Cloudflare Worker connector enforces a subrequest cap per request
156
- * (every OFW API fetch and every Durable-Object cache RPC counts), so a deep
155
+ * A hosted deployment may enforce a request cap per call
156
+ * (every OFW API fetch and every cache round trip counts), so a deep
157
157
  * backfill must be bounded and resumable there. Set OFW_SYNC_MAX_REQUESTS to a
158
158
  * positive integer to cap the number of OFW requests one sync call may make
159
159
  * before pausing; the next call resumes the walk (deep or not) where it left off.
@@ -12,7 +12,7 @@
12
12
  // decompressed stream chunk by chunk, and abort the moment the running total
13
13
  // passes the limit. Peak memory is then bounded by the limit rather than by
14
14
  // whatever the file felt like claiming.
15
- /** 32 MiB. Sized to fit comfortably inside the Worker's memory budget. */
15
+ /** 32 MiB. Sized to fit comfortably inside a constrained memory budget. */
16
16
  export const MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
17
17
  /**
18
18
  * Thrown when decompression is aborted for exceeding its cap. Distinct from a
@@ -31,7 +31,7 @@ export class DecompressionLimitError extends Error {
31
31
  *
32
32
  * `deflate-raw` is the ZIP member format; `deflate` is the zlib-wrapped form a
33
33
  * PDF `/FlateDecode` stream uses. Both go through the WHATWG
34
- * `DecompressionStream` so this runs unchanged on Node and workerd.
34
+ * `DecompressionStream` so this runs unchanged wherever the standard exists.
35
35
  */
36
36
  export async function inflateBounded(data, format, limit, label) {
37
37
  const stream = new Blob([data]).stream()
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // The office formats are machine-generated XML with a known, flat shape per
4
4
  // part (rows of cells, runs of text), so a scanning reader beats pulling in a
5
- // DOM parser that has to run in both Node and workerd. The one rule this file
5
+ // DOM parser that has to run anywhere. The one rule this file
6
6
  // exists to enforce is that a tag match is anchored on the FULL tag name:
7
7
  // naively scanning for `<w:p` also matches `<w:pPr>`, which silently turns
8
8
  // paragraph properties into paragraphs.
@@ -4,9 +4,10 @@
4
4
  // reading one is the first step of every office-document extractor. This is
5
5
  // deliberately not a general ZIP library: it reads the central directory,
6
6
  // slices an entry's bytes, and inflates DEFLATE members via the WHATWG
7
- // `DecompressionStream` — which exists in BOTH Node ≥18 and workerd, so the
8
- // same code runs on the stdio server and the hosted connector. Using
9
- // `node:zlib` here would break the Worker build; adding a userland inflate
7
+ // `DecompressionStream` — a web standard available in Node ≥18 and in
8
+ // sandboxed runtimes alike, so the
9
+ // same code runs on the stdio server and a hosted deployment. Using
10
+ // `node:zlib` here would tie this to Node; adding a userland inflate
10
11
  // dependency would bloat it. Neither is necessary.
11
12
  import { inflateBounded, MAX_DECOMPRESSED_BYTES } from './inflate.js';
12
13
  const EOCD_SIG = 0x06054b50;
@@ -16,7 +17,7 @@ const ZIP64_SENTINEL = 0xffffffff;
16
17
  /**
17
18
  * Hard ceiling on a single decompressed member (32 MiB). An attachment is a
18
19
  * co-parent-supplied file, so a zip bomb is a real (if unlikely) input, and the
19
- * Worker's memory budget is what is being protected.
20
+ * memory budget of a constrained runtime is what is being protected.
20
21
  *
21
22
  * The cap is enforced on the bytes as they arrive ({@link inflateBounded}), NOT
22
23
  * on the size the archive declares for itself. The declared size is checked too
package/dist/index.js CHANGED
@@ -21,9 +21,9 @@ import { getCacheDbPath } from './config.js';
21
21
  import { NodeAttachmentIO } from './tools/attachments.js';
22
22
  // The stdio server backs the message cache with a local `node:sqlite` file,
23
23
  // opened lazily on first use (so the server still boots and answers the host's
24
- // install-time tools/list probe when no cache path is configured). The hosted
25
- // Cloudflare connector (a later task) injects a Durable-Object-backed
26
- // CacheStore + a filesystem-free AttachmentIO into the same registrar instead.
24
+ // install-time tools/list probe when no cache path is configured). Both the
25
+ // CacheStore and the AttachmentIO are injected, so a deployment with no usable
26
+ // disk supplies its own implementations to the same registrar instead.
27
27
  let nodeCache;
28
28
  const nodeCacheProvider = () => (nodeCache ??= OFWCache.open(getCacheDbPath()));
29
29
  const nodeAttachmentIO = new NodeAttachmentIO();
@@ -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.10.1', // x-release-please-version
38
+ version: '2.11.0', // x-release-please-version
39
39
  deps: client,
40
40
  tools: [
41
41
  registerUserTools,
package/dist/sync.js CHANGED
@@ -227,8 +227,8 @@ async function walkPages(client, folder, folderId, opts, store) {
227
227
  }
228
228
  }
229
229
  // Flush the page's rows in one transaction/RPC. Skipped entirely when the
230
- // page held nothing new: on the Worker this call is a Durable-Object RPC,
231
- // and a DO RPC counts against the same subrequest budget as an OFW fetch.
230
+ // page held nothing new: where the cache is remote this call is a round
231
+ // trip, counting against the same subrequest budget as an OFW fetch.
232
232
  // A deep re-walk crosses page after page of already-cached messages, so an
233
233
  // unconditional "no-op" write spends the caller's budget to store nothing.
234
234
  if (toUpsert.length > 0)
@@ -294,7 +294,7 @@ export async function syncMessageFolder(client, folder, folderId, opts, store) {
294
294
  // This was a real starvation bug. `fwd.nextPage` is just the start page
295
295
  // (1) when nothing was fetched, so the `Math.min` below would silently
296
296
  // reset a deep backfill — e.g. resumePage 87 → 1 — discarding 86 pages
297
- // of progress. On the hosted Worker (OFW_SYNC_MAX_REQUESTS=40) a user
297
+ // of progress. Under a request budget (OFW_SYNC_MAX_REQUESTS) a user
298
298
  // with enough drafts to consume the whole budget, with drafts running
299
299
  // first, hit this on EVERY call: inbox/sent never got budget, their
300
300
  // cursor was reset every time, and the backfill could never advance.
@@ -502,7 +502,7 @@ export async function syncAll(client, opts, store) {
502
502
  // Drafts go FIRST. They are the only folder a destructive tool
503
503
  // (ofw_save_draft / ofw_delete_draft) reads as its base, and they are cheap
504
504
  // and bounded — one list page plus one detail per draft. Running them last,
505
- // behind inbox and sent, meant a bounded call (the Worker's
505
+ // behind inbox and sent, meant a bounded call (the
506
506
  // OFW_SYNC_MAX_REQUESTS=40) spent its whole budget backfilling history and
507
507
  // deferred drafts on every single call, so server-side draft edits stayed
508
508
  // invisible indefinitely while the response reported `drafts: 0`.
@@ -4,8 +4,8 @@
4
4
  // writes downloaded bytes to disk (and reads them back for the inline-reuse
5
5
  // path). Those are the ONLY node:fs touch points in the message tools — they
6
6
  // live behind this {@link AttachmentIO} interface so the stdio server can use
7
- // the disk-backed {@link NodeAttachmentIO} while the hosted Cloudflare
8
- // connector (a later task) injects an inline, filesystem-free implementation.
7
+ // the disk-backed {@link NodeAttachmentIO} while a deployment with no usable
8
+ // disk injects an inline, filesystem-free implementation.
9
9
  // Keeping the interface here means src/tools/messages.ts imports nothing from
10
10
  // node:fs.
11
11
  import { readFileSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
@@ -7,7 +7,7 @@ export class DraftFreshnessError extends Error {
7
7
  // FNV-1a (64-bit) over a canonical encoding. Not cryptographic — this is a
8
8
  // change detector, and it is never the sole guard: an unsupplied token falls
9
9
  // back to a full field-by-field comparison against the cached base.
10
- // BigInt keeps it byte-identical on node and on the Workers runtime.
10
+ // BigInt keeps it byte-identical across runtimes.
11
11
  const FNV_OFFSET = 0xcbf29ce484222325n;
12
12
  const FNV_PRIME = 0x100000001b3n;
13
13
  const MASK64 = 0xffffffffffffffffn;
@@ -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 pagination',
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
- return jsonResponse(data);
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', {
@@ -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
- return jsonResponse(data);
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', {
@@ -164,10 +164,10 @@ function stateNote(state, cachedAsDraft, folderName) {
164
164
  * request for the map plus one per probed id.
165
165
  */
166
166
  export async function probeIds(client, store, ids, opts) {
167
- // THREE cache reads for the whole batch, not three per id. On the Durable
168
- // Object backend every cache call is a subrequest counting against the same
169
- // hosting cap as the OFW fetches, so a per-id lookup would spend the caller's
170
- // budget on bookkeeping before a single probe ran.
167
+ // THREE cache reads for the whole batch, not three per id. Where the cache
168
+ // is remote every call is a subrequest counting against the same hosting cap
169
+ // as the OFW fetches, so a per-id lookup would spend the caller's budget on
170
+ // bookkeeping before a single probe ran.
171
171
  const draftsById = new Map((await store.getDrafts(ids)).map((d) => [d.id, d]));
172
172
  const messagesById = new Map((await store.getMessages(ids)).map((m) => [m.id, m]));
173
173
  const prepared = ids.map((id) => {
@@ -294,9 +294,9 @@ export async function resolveDraftKey(store, draftKey) {
294
294
  return { currentId: chain[chain.length - 1].id, ids: chain.map((r) => r.id) };
295
295
  }
296
296
  /**
297
- * Mint a new stable draft identity. Uses the Web Crypto global, which both
298
- * Node ≥19 and the Workers runtime provide — a `node:crypto` import would not
299
- * bundle for the hosted connector.
297
+ * Mint a new stable draft identity. Uses the Web Crypto global, which Node ≥19
298
+ * provides natively — a `node:crypto` import would not bundle for a hosted
299
+ * deployment.
300
300
  */
301
301
  export function newDraftKey() {
302
302
  return `dk_${crypto.randomUUID()}`;
@@ -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() });
@@ -81,7 +82,7 @@ const FolderCountsSchema = z.looseObject({
81
82
  /**
82
83
  * Cap on per-id probes in one ofw_check_freshness call.
83
84
  *
84
- * Each id costs one OFW request, and on the hosted Worker every request counts
85
+ * Each id costs one OFW request, and under a request budget every one counts
85
86
  * against the subrequest cap (see OFW_SYNC_MAX_REQUESTS). The check has to stay
86
87
  * cheap enough that a caller reaches for it freely — that is the entire point
87
88
  * of it existing — so it truncates loudly rather than turning into a sync.
@@ -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 payload = { messages, total, page, size, complete, freshness };
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, or narrow with since/until/q.`;
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', {
@@ -885,7 +911,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
885
911
  const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
886
912
  const rows = await cache.listDrafts({ page, size });
887
913
  const total = await cache.countDrafts();
888
- // One batch lookup for the whole page — on the Durable Object backend a
914
+ // One batch lookup for the whole page — where the cache is remote a
889
915
  // per-draft lineage read would be a subrequest each.
890
916
  const keyById = new Map((await cache.getDraftLineageByIds(rows.map((d) => d.id))).map((l) => [l.id, l.draftKey]));
891
917
  // Every draft carries the concurrency token to echo back on a write, its
@@ -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 payload = { drafts, total, page, size, complete, freshness };
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 payload = { unread, scanned: sent.length, total, complete, freshness };
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)
@@ -1250,7 +1304,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1250
1304
  },
1251
1305
  }, async (args) => {
1252
1306
  // Resolve the upload source through the injected attachment-I/O boundary
1253
- // (disk read on node; an in-memory source on the hosted connector).
1307
+ // (disk read on node; an in-memory source on a hosted deployment).
1254
1308
  const { blob, fileName, mimeType: mime, sizeBytes } = await attachmentIO.resolveUpload(args.path);
1255
1309
  // Build the multipart payload matching the OFW web UI's request shape.
1256
1310
  const form = new FormData();
@@ -1298,7 +1352,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1298
1352
  const fileId = args.fileId;
1299
1353
  const cache = cacheProvider();
1300
1354
  const requestedInline = args.inline ?? getDefaultInlineAttachments();
1301
- // When the deployment has no filesystem (hosted connector), inline is the
1355
+ // When the deployment has no filesystem, inline is the
1302
1356
  // ONLY path to the bytes — force it rather than erroring on a disk write.
1303
1357
  // `forcedInline` records that we overrode an explicit `inline:false` so the
1304
1358
  // response is honest about it instead of silently ignoring the argument.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.10.1",
3
+ "version": "2.11.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)",
@@ -29,10 +29,7 @@
29
29
  "dev": "node --env-file=.env dist/index.js",
30
30
  "test": "vitest run",
31
31
  "test:coverage": "vitest run --coverage",
32
- "test:watch": "vitest",
33
- "worker:dev": "wrangler dev",
34
- "worker:deploy": "wrangler deploy",
35
- "worker:test": "vitest run --config vitest.workers.config.ts"
32
+ "test:watch": "vitest"
36
33
  },
37
34
  "dependencies": {
38
35
  "@chrischall/mcp-utils": "^0.14.0",
@@ -42,16 +39,10 @@
42
39
  "zod": "^4.4.3"
43
40
  },
44
41
  "devDependencies": {
45
- "@chrischall/mcp-connector": "^1.1.1",
46
- "@cloudflare/vitest-pool-workers": "^0.19.1",
47
- "@cloudflare/workers-oauth-provider": "^0.8.1",
48
- "@cloudflare/workers-types": "^5.20260708.1",
49
42
  "@types/node": "^26.0.0",
50
43
  "@vitest/coverage-v8": "^4.1.7",
51
- "agents": "^0.19.0",
52
44
  "esbuild": "^0.28.0",
53
45
  "typescript": "^7.0.2",
54
- "vitest": "^4.1.7",
55
- "wrangler": "^4.110.0"
46
+ "vitest": "^4.1.7"
56
47
  }
57
48
  }
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.10.1",
9
+ "version": "2.11.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.10.1",
14
+ "version": "2.11.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -10,7 +10,6 @@ MCP server for OurFamilyWizard — provides read/write access to messages, calen
10
10
  - **npm:** [npmjs.com/package/ofw-mcp](https://www.npmjs.com/package/ofw-mcp)
11
11
  - **Source:** [github.com/chrischall/ofw-mcp](https://github.com/chrischall/ofw-mcp)
12
12
 
13
- > These tools are also available via the hosted [claude.ai](https://claude.ai) remote connector (a Cloudflare Worker) — the tool set and behaviour are identical to the local stdio install. See the repo's `docs/DEPLOY-CONNECTOR.md`.
14
13
 
15
14
  ## Setup
16
15
 
@@ -93,11 +92,11 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
93
92
  |------|-------|
94
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). |
95
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. |
96
- | `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. An **empty** result from a non-fresh cache is refused (`UNVERIFIED_EMPTY`) — pass `autoRefresh:true` to sync and answer instead. |
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. |
97
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. |
98
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`. |
99
- | `ofw_get_unread_sent(page?, size?, autoRefresh?)` | Sent messages your co-parent hasn't read yet (from cache). Reports `scanned`/`total`/`complete`; an empty sent cache that is not fresh is refused rather than reported as "nothing sent". |
100
- | `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). |
101
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. |
102
101
  | `ofw_delete_draft(messageId)` | Delete a draft. |
103
102
  | `ofw_upload_attachment(path, shareClass?, label?, description?)` | Upload a local file to My Files; returns a fileId to pass into `myFileIDs`. |
@@ -134,6 +133,7 @@ Message and draft reads come from a local cache, so **a result can be stale with
134
133
  |---|---|
135
134
  | How old is this data? | `freshness` — `staleness` (`fresh`/`unverified`/`stale`), `asOf`, `ageSeconds`, a quotable `warning` |
136
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 |
137
137
  | Is this entity still what I think it is? | `state` from `ofw_status` / `ofw_check_freshness` |
138
138
 
139
139
  Rules:
package/dist/ofw-auth.js DELETED
@@ -1,26 +0,0 @@
1
- import { loginWithPassword } from './auth-password.js';
2
- /**
3
- * `ConnectorAuth` for the OurFamilyWizard remote connector: the login page
4
- * collects the user's own OFW email/username + password, verifies them via the
5
- * same Spring Security form login the stdio server uses (`loginWithPassword` in
6
- * `auth-password.js`), and stores `{ username, password }` as the OAuth props
7
- * that `worker.ts`'s `buildClient` turns into a per-user `OFWClient` capable of
8
- * re-authenticating when its 6h token expires.
9
- */
10
- export const ofwAuth = {
11
- service: 'OurFamilyWizard',
12
- accent: '#00A9A5',
13
- privacyNote: 'Your OFW email and password are stored encrypted and used only to sign in to OurFamilyWizard on your behalf ' +
14
- '(OFW sign-in tokens expire every few hours, so your password is needed to renew them).',
15
- fields: [
16
- { name: 'username', label: 'OFW email or username' },
17
- { name: 'password', label: 'OFW password', type: 'password' },
18
- ],
19
- async login(fields) {
20
- // Verify the credentials up front — a bad password throws here, which the
21
- // connector surfaces back on the login page. We deliberately discard the
22
- // returned token: the per-user client logs in again from the stored creds.
23
- await loginWithPassword(fields.username, fields.password);
24
- return { username: fields.username, password: fields.password };
25
- },
26
- };