ofw-mcp 2.10.2 → 2.12.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/README.md +1 -1
- package/dist/auth.js +86 -56
- package/dist/bundle.js +740 -228
- package/dist/cache/store.js +6 -1
- package/dist/client.js +24 -12
- package/dist/index.js +1 -1
- package/dist/session-cache.js +78 -0
- 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/mint.yaml +143 -0
- package/package.json +8 -6
- package/server.json +14 -2
- package/skills/ofw/SKILL.md +5 -4
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/client.js
CHANGED
|
@@ -3,6 +3,7 @@ import { TokenManager } from '@chrischall/mcp-utils/session';
|
|
|
3
3
|
import { dirname, join } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { resolveAuth } from './auth.js';
|
|
6
|
+
import { createSessionCache, reportCacheWriteFailure } from './session-cache.js';
|
|
6
7
|
import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
|
|
7
8
|
// Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
|
|
8
9
|
// bundle). loadDotenvSafely applies override:false + quiet:true and swallows a
|
|
@@ -62,9 +63,9 @@ export class OFWClient {
|
|
|
62
63
|
// Bearer-token lifecycle is delegated to the shared, race-safe TokenManager
|
|
63
64
|
// (proactive refresh inside the skew window, single-flight refresh so a burst
|
|
64
65
|
// of concurrent callers coalesces onto ONE `resolveAuth()`, and a 401-replay
|
|
65
|
-
// guarded against double-refresh). It is created lazily,
|
|
66
|
-
//
|
|
67
|
-
//
|
|
66
|
+
// guarded against double-refresh). It is created lazily, and mints on first
|
|
67
|
+
// use through the function form of `initial` — see `mint` below for why that
|
|
68
|
+
// form rather than a seeded placeholder.
|
|
68
69
|
tokenManager;
|
|
69
70
|
// Optional injected auth resolver. When set, the refresh callback uses it
|
|
70
71
|
// instead of the module-level global `resolveAuth` (env-var → fetchproxy
|
|
@@ -78,22 +79,33 @@ export class OFWClient {
|
|
|
78
79
|
}
|
|
79
80
|
getTokenManager() {
|
|
80
81
|
if (!this.tokenManager) {
|
|
82
|
+
// Minting and renewing are the SAME operation here — OFW has no refresh
|
|
83
|
+
// grant — so one function serves as both `initial` and `refresh`. It has
|
|
84
|
+
// to be the function form: the eager object form skips persistence, so
|
|
85
|
+
// the expired placeholder that used to sit here would have meant the
|
|
86
|
+
// cache was written but never read.
|
|
87
|
+
const mint = async () => {
|
|
88
|
+
const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
|
|
89
|
+
return {
|
|
90
|
+
accessToken: token,
|
|
91
|
+
refreshToken: OFW_REFRESH_SENTINEL,
|
|
92
|
+
expiresAt: (expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS)).getTime(),
|
|
93
|
+
};
|
|
94
|
+
};
|
|
81
95
|
this.tokenManager = new TokenManager({
|
|
82
|
-
initial:
|
|
96
|
+
initial: mint,
|
|
97
|
+
persistence: createSessionCache({ injectedResolver: this.authResolver !== undefined }) ?? undefined,
|
|
98
|
+
onPersistError: reportCacheWriteFailure,
|
|
99
|
+
// A failed renewal IS a failed login here, so the library's
|
|
100
|
+
// re-mint-on-revoked recovery would just repeat the call that failed.
|
|
101
|
+
isRefreshRevoked: () => false,
|
|
83
102
|
skewMs: OFW_TOKEN_EXPIRY_SKEW_MS,
|
|
84
103
|
// Map OFW's mint/refresh onto the refresh callback. `resolveAuth()`
|
|
85
104
|
// returns a token and a best-effort expiry; when the fetchproxy path
|
|
86
105
|
// can't supply one we fall back to the same 6h estimate the password
|
|
87
106
|
// path uses (the 401-replay covers a wrong guess). We re-arm the
|
|
88
107
|
// sentinel so the manager can refresh again later.
|
|
89
|
-
refresh:
|
|
90
|
-
const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
|
|
91
|
-
return {
|
|
92
|
-
accessToken: token,
|
|
93
|
-
refreshToken: OFW_REFRESH_SENTINEL,
|
|
94
|
-
expiresAt: (expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS)).getTime(),
|
|
95
|
-
};
|
|
96
|
-
},
|
|
108
|
+
refresh: mint,
|
|
97
109
|
});
|
|
98
110
|
}
|
|
99
111
|
return this.tokenManager;
|
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.12.0', // x-release-please-version
|
|
39
39
|
deps: client,
|
|
40
40
|
tools: [
|
|
41
41
|
registerUserTools,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createFileStatePersistence, resolveStateFile, } from '@chrischall/mcp-utils/session';
|
|
2
|
+
import { readEnvVar, parseBoolEnv } from '@chrischall/mcp-utils';
|
|
3
|
+
/**
|
|
4
|
+
* Where the OFW session token is cached between runs.
|
|
5
|
+
*
|
|
6
|
+
* OFW has no OAuth refresh token — every renewal re-runs the full
|
|
7
|
+
* `resolveAuth()` (a password POST, or a fetchproxy snapshot). The token itself
|
|
8
|
+
* is good for six hours (`OFW_TOKEN_TTL_MS`), so on a scale-to-zero host, where
|
|
9
|
+
* children idle out after ten minutes and every start is a cold one, the same
|
|
10
|
+
* six-hour token was being re-minted many times over. Caching it turns those
|
|
11
|
+
* restarts into zero-cost ones; an expired token still costs exactly what it did
|
|
12
|
+
* before.
|
|
13
|
+
*/
|
|
14
|
+
export function sessionCachePath(env = process.env) {
|
|
15
|
+
return resolveStateFile({
|
|
16
|
+
env,
|
|
17
|
+
envVar: 'OFW_SESSION_FILE',
|
|
18
|
+
subdir: '.ofw-mcp',
|
|
19
|
+
fileName: 'session.json',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** Only a token pair is ever stored — never the username or password. */
|
|
23
|
+
function isTokens(raw) {
|
|
24
|
+
if (raw === null || typeof raw !== 'object')
|
|
25
|
+
return false;
|
|
26
|
+
const t = raw;
|
|
27
|
+
return (typeof t.accessToken === 'string' &&
|
|
28
|
+
t.accessToken !== '' &&
|
|
29
|
+
typeof t.expiresAt === 'number' &&
|
|
30
|
+
(t.refreshToken === undefined || typeof t.refreshToken === 'string'));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The session cache, or `null` when it must not be used.
|
|
34
|
+
*
|
|
35
|
+
* Three ways it comes back `null`, each deliberate:
|
|
36
|
+
*
|
|
37
|
+
* - `OFW_SESSION_CACHE=false` — the operator opted out.
|
|
38
|
+
* - **A caller-injected auth resolver.** That is the per-user hosted path, and
|
|
39
|
+
* this registration declares no `identity.perUserChild`, so one process can
|
|
40
|
+
* serve several people. A single cache file would hand one user's session to
|
|
41
|
+
* the next; until the child is per-user, that path simply does not cache.
|
|
42
|
+
* - **No env credentials.** The fetchproxy path authenticates from a signed-in
|
|
43
|
+
* browser tab rather than a stored secret, so there is nothing stable to bind
|
|
44
|
+
* a cached token to — and it is local-only, where cold starts are rare.
|
|
45
|
+
*
|
|
46
|
+
* When it is used, the record is bound to the credentials that minted it, so
|
|
47
|
+
* rotating either discards it. Only a salted digest is written.
|
|
48
|
+
*/
|
|
49
|
+
export function createSessionCache(opts = {}) {
|
|
50
|
+
const env = opts.env ?? process.env;
|
|
51
|
+
if (opts.injectedResolver === true)
|
|
52
|
+
return null;
|
|
53
|
+
if (!parseBoolEnv('OFW_SESSION_CACHE', { env, default: true }))
|
|
54
|
+
return null;
|
|
55
|
+
const username = readEnvVar('OFW_USERNAME', { env });
|
|
56
|
+
const password = readEnvVar('OFW_PASSWORD', { env });
|
|
57
|
+
if (username === undefined || password === undefined)
|
|
58
|
+
return null;
|
|
59
|
+
return createFileStatePersistence({
|
|
60
|
+
filePath: sessionCachePath(env),
|
|
61
|
+
// Joined on a NUL, written as an escape rather than a literal byte: a
|
|
62
|
+
// password may contain spaces, so a space-joined pair could collide with
|
|
63
|
+
// a different pair by shifting the boundary between the two halves.
|
|
64
|
+
boundTo: [username.trim().toLowerCase(), password].join('\u0000'),
|
|
65
|
+
validate: (raw) => (isTokens(raw) ? raw : null),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Report a cache write that failed. Not fatal: OFW tokens are re-mintable from
|
|
70
|
+
* the credentials in the environment, so a lost write costs the next start a
|
|
71
|
+
* login rather than access. Still worth saying — a read-only data dir otherwise
|
|
72
|
+
* looks exactly like a server that never caches. stderr only; stdout is JSON-RPC.
|
|
73
|
+
*/
|
|
74
|
+
export function reportCacheWriteFailure(err) {
|
|
75
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
76
|
+
console.error(`[ofw-mcp] could not cache the session token (${detail}); continuing without the ` +
|
|
77
|
+
'cache — every restart will re-authenticate until this is fixed.');
|
|
78
|
+
}
|
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
|
+
}
|