ofw-mcp 2.20.3 → 2.20.4

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/dist/client.js CHANGED
@@ -100,6 +100,9 @@ export class OFWClient {
100
100
  // deployment. Left undefined by the stdio path, which falls back to the
101
101
  // global resolver, keeping that behaviour byte-for-byte identical.
102
102
  authResolver;
103
+ // Which path minted the token currently held. Unset while the token is one
104
+ // the TokenManager restored from the session cache — nothing was minted.
105
+ mintedBy;
103
106
  constructor(opts) {
104
107
  this.authResolver = opts?.resolveAuth;
105
108
  }
@@ -111,7 +114,8 @@ export class OFWClient {
111
114
  // the expired placeholder that used to sit here would have meant the
112
115
  // cache was written but never read.
113
116
  const mint = async () => {
114
- const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
117
+ const { token, expiresAt, source } = await (this.authResolver ?? resolveAuth)();
118
+ this.mintedBy = source;
115
119
  return {
116
120
  accessToken: token,
117
121
  refreshToken: OFW_REFRESH_SENTINEL,
@@ -136,6 +140,22 @@ export class OFWClient {
136
140
  }
137
141
  return this.tokenManager;
138
142
  }
143
+ /**
144
+ * The credential's source and expiry, answered from the TokenManager — the
145
+ * same token, cache and single-flight mint every tool uses.
146
+ *
147
+ * `ofw_healthcheck` used to call `resolveAuth()` directly, which is a full
148
+ * login (or a fresh browser-bridge spin-up) on every healthcheck, ignoring
149
+ * the six-hour token already held. Going through the manager means a held or
150
+ * cached token costs nothing, and a mint, when one is needed, is the one the
151
+ * next real tool call would have made anyway. A mint failure propagates
152
+ * unchanged so the healthcheck can classify it.
153
+ */
154
+ async credentialStatus() {
155
+ const tokens = this.getTokenManager();
156
+ await tokens.getAccessToken();
157
+ return { source: this.mintedBy ?? 'cache', expiresAt: new Date(tokens.getExpiresAt()) };
158
+ }
139
159
  async request(method, path, body) {
140
160
  return this.fetchAuthed(method, path, body, 'application/json', async (response) => {
141
161
  const text = await response.text();
package/dist/config.js CHANGED
@@ -14,10 +14,9 @@ function readCacheIdentity() {
14
14
  return readEnvVar('OFW_CACHE_IDENTITY') ?? readEnvVar('OFW_USERNAME') ?? '_default';
15
15
  }
16
16
  export function getCacheDir() {
17
- const override = process.env.OFW_CACHE_DIR;
18
- if (override && override.trim().length > 0)
19
- return override.trim();
20
- return join(homedir(), '.cache', 'ofw-mcp');
17
+ // readEnvVar, not process.env: it also treats an unexpanded mcpb
18
+ // `${user_config.*}` placeholder as unset.
19
+ return readEnvVar('OFW_CACHE_DIR') ?? join(homedir(), '.cache', 'ofw-mcp');
21
20
  }
22
21
  export function getCacheDbPath() {
23
22
  const identity = readCacheIdentity();
@@ -14,6 +14,28 @@
14
14
  // whatever the file felt like claiming.
15
15
  /** 32 MiB. Sized to fit comfortably inside a constrained memory budget. */
16
16
  export const MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
17
+ /**
18
+ * 64 MiB: the most one DOCUMENT may decompress in total, across all its
19
+ * members or streams. The per-member cap alone does not bound memory: a
20
+ * workbook of 100 sheets that each inflate to just under 32 MiB, or a PDF
21
+ * whose every page references the same near-cap stream, would keep gigabytes
22
+ * of output alive from a few kilobytes of attachment.
23
+ */
24
+ export const MAX_TOTAL_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
25
+ /** A running total of decompressed bytes for one document, shared by every read. */
26
+ export class DecompressionBudget {
27
+ limit;
28
+ used = 0;
29
+ constructor(limit = MAX_TOTAL_DECOMPRESSED_BYTES) {
30
+ this.limit = limit;
31
+ }
32
+ /** Charge `bytes`; throws once the document's total passes the limit. */
33
+ charge(bytes, label) {
34
+ this.used += bytes;
35
+ if (this.used > this.limit)
36
+ throw new DecompressionLimitError(label, this.limit, 'total');
37
+ }
38
+ }
17
39
  /**
18
40
  * Thrown when decompression is aborted for exceeding its cap. Distinct from a
19
41
  * decode failure so callers can tell "this file is hostile or absurd" from
@@ -21,19 +43,22 @@ export const MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
21
43
  * routinely survivable.
22
44
  */
23
45
  export class DecompressionLimitError extends Error {
24
- constructor(label, limit) {
25
- super(`${label} expands past the ${limit}-byte decompression cap`);
46
+ constructor(label, limit, scope = 'member') {
47
+ super(scope === 'total'
48
+ ? `${label} pushes the document past its ${limit}-byte total decompression cap`
49
+ : `${label} expands past the ${limit}-byte decompression cap`);
26
50
  this.name = 'DecompressionLimitError';
27
51
  }
28
52
  }
29
53
  /**
30
- * Inflate `data`, aborting if the OUTPUT exceeds `limit` bytes.
54
+ * Inflate `data`, aborting if the OUTPUT exceeds `limit` bytes, or pushes the
55
+ * document's shared `budget` (when given) past its total.
31
56
  *
32
57
  * `deflate-raw` is the ZIP member format; `deflate` is the zlib-wrapped form a
33
58
  * PDF `/FlateDecode` stream uses. Both go through the WHATWG
34
59
  * `DecompressionStream` so this runs unchanged wherever the standard exists.
35
60
  */
36
- export async function inflateBounded(data, format, limit, label) {
61
+ export async function inflateBounded(data, format, limit, label, budget) {
37
62
  const stream = new Blob([data]).stream()
38
63
  .pipeThrough(new DecompressionStream(format));
39
64
  const reader = stream.getReader();
@@ -49,6 +74,13 @@ export async function inflateBounded(data, format, limit, label) {
49
74
  await reader.cancel();
50
75
  throw new DecompressionLimitError(label, limit);
51
76
  }
77
+ try {
78
+ budget?.charge(value.length, label);
79
+ }
80
+ catch (err) {
81
+ await reader.cancel();
82
+ throw err;
83
+ }
52
84
  chunks.push(value);
53
85
  }
54
86
  return Buffer.concat(chunks);
@@ -9,7 +9,7 @@
9
9
  // What it must never do is fail silently. A scanned PDF has no text layer at
10
10
  // all, and the honest answer for one is `textLayer: false` plus a note saying
11
11
  // it needs OCR — not an empty string that reads like an empty document.
12
- import { inflateBounded, DecompressionLimitError, MAX_DECOMPRESSED_BYTES, } from './inflate.js';
12
+ import { inflateBounded, DecompressionBudget, DecompressionLimitError, MAX_DECOMPRESSED_BYTES, } from './inflate.js';
13
13
  const OBJ_HEADER = /(\d+)\s+\d+\s+obj\b/g;
14
14
  function parseObjects(text) {
15
15
  const objects = new Map();
@@ -79,19 +79,21 @@ function streamBytes(bytes, obj) {
79
79
  return bytes.subarray(from, end === -1 ? undefined : end);
80
80
  }
81
81
  /** Decode a stream's bytes, honouring FlateDecode. Null for filters we can't read. */
82
- async function decodeStream(bytes, obj) {
82
+ async function decodeStream(bytes, obj, budget) {
83
83
  const raw = streamBytes(bytes, obj);
84
84
  if (!raw)
85
85
  return null;
86
86
  const filter = /\/Filter\s*(\/\w+|\[[^\]]*\])/.exec(obj.body)?.[1] ?? '';
87
- if (filter === '')
87
+ if (filter === '') {
88
+ budget.charge(raw.length, 'PDF stream');
88
89
  return raw;
90
+ }
89
91
  if (!filter.includes('FlateDecode'))
90
92
  return null; // LZW/DCT/JPX: not text anyway
91
93
  try {
92
94
  // PDF stream dictionaries say nothing about inflated length, so the cap is
93
95
  // the only thing standing between a crafted stream and the memory budget.
94
- return await inflateBounded(raw, 'deflate', MAX_DECOMPRESSED_BYTES, 'PDF stream');
96
+ return await inflateBounded(raw, 'deflate', MAX_DECOMPRESSED_BYTES, 'PDF stream', budget);
95
97
  }
96
98
  catch (err) {
97
99
  // A truncated or mis-bounded stream must not take the whole document down;
@@ -245,6 +247,7 @@ export async function extractPdf(bytes, opts = {}) {
245
247
  const pageObjects = orderedPages(objects);
246
248
  if (pageObjects.length === 0)
247
249
  throw new Error('no pages found in the PDF');
250
+ const budget = new DecompressionBudget(opts.maxTotalDecompressedBytes);
248
251
  const pages = [];
249
252
  const omitted = [];
250
253
  for (let i = 0; i < pageObjects.length; i++) {
@@ -259,7 +262,7 @@ export async function extractPdf(bytes, opts = {}) {
259
262
  const streamObj = objects.get(ref);
260
263
  if (!streamObj)
261
264
  continue;
262
- const decoded = await decodeStream(bytes, streamObj);
265
+ const decoded = await decodeStream(bytes, streamObj, budget);
263
266
  if (decoded)
264
267
  raw += `${decoded.toString('latin1')}\n`;
265
268
  }
@@ -9,7 +9,7 @@
9
9
  // same code runs on the stdio server and a hosted deployment. Using
10
10
  // `node:zlib` here would tie this to Node; adding a userland inflate
11
11
  // dependency would bloat it. Neither is necessary.
12
- import { inflateBounded, MAX_DECOMPRESSED_BYTES } from './inflate.js';
12
+ import { DecompressionBudget, inflateBounded, MAX_DECOMPRESSED_BYTES } from './inflate.js';
13
13
  const EOCD_SIG = 0x06054b50;
14
14
  const CENTRAL_SIG = 0x02014b50;
15
15
  const LOCAL_SIG = 0x04034b50;
@@ -64,7 +64,10 @@ export async function readZip(bytes, opts = {}) {
64
64
  });
65
65
  p += 46 + nameLen + extraLen + commentLen;
66
66
  }
67
+ // Memoized members are bounded by the archive budget: every member read
68
+ // (STORED ones too, since central entries may alias one payload) is charged.
67
69
  const cache = new Map();
70
+ const budget = new DecompressionBudget(opts.maxTotalUncompressedBytes);
68
71
  async function read(name) {
69
72
  const cached = cache.get(name);
70
73
  if (cached)
@@ -87,10 +90,12 @@ export async function readZip(bytes, opts = {}) {
87
90
  const start = lo + 30 + bytes.readUInt16LE(lo + 26) + bytes.readUInt16LE(lo + 28);
88
91
  const raw = bytes.subarray(start, start + entry.compressedSize);
89
92
  let out;
90
- if (entry.method === 0)
93
+ if (entry.method === 0) {
94
+ budget.charge(raw.length, `ZIP member ${name}`);
91
95
  out = Buffer.from(raw);
96
+ }
92
97
  else if (entry.method === 8)
93
- out = await inflateBounded(raw, 'deflate-raw', limit, `ZIP member ${name}`);
98
+ out = await inflateBounded(raw, 'deflate-raw', limit, `ZIP member ${name}`, budget);
94
99
  else
95
100
  throw new Error(`unsupported ZIP compression method ${entry.method} for ${name}`);
96
101
  cache.set(name, out);
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { OFWCache } from './cache/node.js';
21
21
  import { getCacheDbPath } from './config.js';
22
22
  import { selectToolRegistrars } from './tool-surface.js';
23
23
  import { NodeAttachmentIO } from './tools/attachments.js';
24
+ import { VERSION } from './version.js';
24
25
  // The stdio server backs the message cache with a local `node:sqlite` file,
25
26
  // opened lazily on first use (so the server still boots and answers the host's
26
27
  // install-time tools/list probe when no cache path is configured). Both the
@@ -47,7 +48,7 @@ const tools = selectToolRegistrars({
47
48
  });
48
49
  await runMcp({
49
50
  name: 'ofw',
50
- version: '2.20.3', // x-release-please-version
51
+ version: VERSION,
51
52
  deps: client,
52
53
  tools,
53
54
  banner: '[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
@@ -30,6 +30,10 @@ export function confirmWrite(ctx, opts) {
30
30
  details: opts.preview,
31
31
  unsupportedNote: 'Complete this action on ourfamilywizard.com instead.',
32
32
  tool: opts.tool,
33
+ // One OFW account per process (OFW_USERNAME / the captured session), so
34
+ // there is no principal to distinguish; mcp-utils 3 makes saying so explicit.
35
+ account: undefined,
36
+ args: opts.args,
33
37
  confirmToken: opts.confirmToken,
34
38
  subject: () => ({
35
39
  target: opts.target,
@@ -26,6 +26,20 @@ export function jsonResponse(data) {
26
26
  }
27
27
  // Raw-string tool result. Wrapper over @chrischall/mcp-utils' `rawTextResult`.
28
28
  export const textResponse = rawTextResult;
29
+ // The JSON half of a mixed text response, through the same seam as
30
+ // jsonResponse: timestamps normalized, no formatting whitespace.
31
+ export function jsonText(data) {
32
+ return JSON.stringify(normalizeTimestampsInValue(data));
33
+ }
34
+ // A write tool's result: its transparency NOTEs/WARNINGs (null entries are
35
+ // skipped) ahead of the structured payload, or `fallback` when OFW returned
36
+ // nothing to show. The payload goes through jsonText, so a write tool cannot
37
+ // hand back the naive-local `sentAt`/`modifiedAt` the read tools never do.
38
+ export function notedJsonResponse(notes, data, fallback) {
39
+ const text = data ? jsonText(data) : fallback;
40
+ const prefix = notes.filter((n) => n !== null).join('\n\n');
41
+ return textResponse(prefix ? `${prefix}\n\n${text}` : text);
42
+ }
29
43
  // A STRUCTURED failure: the machine-readable payload of `jsonResponse` plus
30
44
  // `isError`, so a refusal can carry recovery data (e.g. the server draft body
31
45
  // we declined to overwrite) without being mistaken for a successful write.
@@ -152,7 +152,7 @@ export function registerCalendarTools(server, client) {
152
152
  const allowWrites = getCalendarWritesAllowed();
153
153
  server.registerTool('ofw_list_events', {
154
154
  description: 'List OurFamilyWizard calendar events in a date range',
155
- annotations: { readOnlyHint: true },
155
+ annotations: { readOnlyHint: true, openWorldHint: true },
156
156
  inputSchema: z.object({
157
157
  startDate: z.string().describe('Start date YYYY-MM-DD'),
158
158
  endDate: z.string().describe('End date YYYY-MM-DD'),
@@ -166,9 +166,10 @@ export function registerCalendarTools(server, client) {
166
166
  if (allowWrites)
167
167
  server.registerTool('ofw_create_event', {
168
168
  description: 'Create a calendar event in OurFamilyWizard. Unless privateEvent is true, the event is immediately visible to the co-parent — there is no draft stage — so a shared event is confirmed first (a private one is not). If the request fails without a definitive answer the result is EVENT_UNCONFIRMED: the event may already exist, so do NOT retry until ofw_list_events shows it did not land. ' + CONFIRM_NOTE,
169
- // Additive, so not destructive — but it lands on a shared calendar
170
- // outside this machine, which openWorldHint says to the host.
171
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
169
+ // Destructive by the inverse test: a shared event is on the co-parent's
170
+ // calendar the moment it lands, and ofw_delete_event removes it but cannot
171
+ // un-show it. (A private one would be additive; the hint is per tool.)
172
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
172
173
  inputSchema: z.object({
173
174
  title: z.string(),
174
175
  ...eventWriteFields,
@@ -179,6 +180,7 @@ export function registerCalendarTools(server, client) {
179
180
  const payload = buildEventPayload(fields);
180
181
  if (!fields.privateEvent) {
181
182
  const gate = await confirmWrite(ctx, {
183
+ args,
182
184
  tool: 'ofw_create_event',
183
185
  action: 'ofw.event.create',
184
186
  message: 'Review and confirm this OurFamilyWizard calendar event. It is shared with the co-parent and appears on their calendar immediately.',
@@ -216,7 +218,7 @@ export function registerCalendarTools(server, client) {
216
218
  if (allowWrites)
217
219
  server.registerTool('ofw_update_event', {
218
220
  description: 'Update an existing OurFamilyWizard calendar event. Fetches the event, applies the given changes, and writes the merged result back (OFW has no partial update). A change to an event the co-parent can see (shared before or after the change) is confirmed first; the confirmation is bound to the event exactly as read, so if it changes on OFW in between (say the co-parent edited it) the update is refused instead of overwriting their edit. If the write fails without a definitive answer, or lands but cannot be re-read to confirm it, the result is EVENT_UNCONFIRMED: the change may already be on OFW, so do NOT retry until ofw_list_events shows whether it landed. ' + CONFIRM_NOTE,
219
- annotations: { destructiveHint: true },
221
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
220
222
  inputSchema: z.object({
221
223
  eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
222
224
  title: z.string().optional(),
@@ -249,6 +251,7 @@ export function registerCalendarTools(server, client) {
249
251
  if (current.publicFlag || !merged.privateEvent) {
250
252
  const names = namesIn(current);
251
253
  const gate = await confirmWrite(ctx, {
254
+ args,
252
255
  tool: 'ofw_update_event',
253
256
  action: 'ofw.event.update',
254
257
  message: `Review and confirm this change to the OurFamilyWizard event "${current.title}". The co-parent sees the updated event on their calendar.`,
@@ -312,6 +315,7 @@ export function registerCalendarTools(server, client) {
312
315
  if (current.publicFlag) {
313
316
  const base = detailToWriteArgs(current);
314
317
  const gate = await confirmWrite(ctx, {
318
+ args,
315
319
  tool: 'ofw_delete_event',
316
320
  action: 'ofw.event.delete',
317
321
  message: `Review and confirm deleting the OurFamilyWizard event "${current.title}". It disappears from the co-parent's calendar.`,
@@ -208,7 +208,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
208
208
  if (!uploadOnly)
209
209
  server.registerTool('ofw_get_expense_totals', {
210
210
  description: 'Get OurFamilyWizard expense summary totals (owed/paid)',
211
- annotations: { readOnlyHint: true },
211
+ annotations: { readOnlyHint: true, openWorldHint: true },
212
212
  }, async () => {
213
213
  const data = await client.request('GET', '/pub/v2/expense/expenses/totals');
214
214
  return jsonResponse(data);
@@ -216,7 +216,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
216
216
  if (!uploadOnly)
217
217
  server.registerTool('ofw_list_expense_categories', {
218
218
  description: 'List OurFamilyWizard expense categories, including preset and custom categories with their responsibility split metadata. Read-only.',
219
- annotations: { readOnlyHint: true },
219
+ annotations: { readOnlyHint: true, openWorldHint: true },
220
220
  }, async () => {
221
221
  const data = await client.request('GET', '/pub/v2/expense/categories');
222
222
  return jsonResponse(data);
@@ -224,7 +224,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
224
224
  if (!uploadOnly)
225
225
  server.registerTool('ofw_list_expenses', {
226
226
  description: 'List OurFamilyWizard expenses. OFW pages this endpoint with 1-based page/size parameters; its older start/max parameters are ignored and repeatedly return page 1. The response leads with hasMore and nextPage (null when exhausted) before the records. Continue by passing nextPage.',
227
- annotations: { readOnlyHint: true },
227
+ annotations: { readOnlyHint: true, openWorldHint: true },
228
228
  inputSchema: z.object({
229
229
  page: z.number().int().min(1).describe('1-based page number (default 1). To continue, pass the nextPage returned by the previous response.').optional(),
230
230
  size: z.number().int().min(1).max(100).describe('Requested page size (default 20). OFW may cap or normalize this value.').optional(),
@@ -272,8 +272,10 @@ export function registerExpenseTools(server, client, attachmentIO) {
272
272
  });
273
273
  if (allowReceiptUploads)
274
274
  server.registerTool('ofw_upload_expense_pdf', {
275
+ // Destructive by the inverse test: always uploaded SHARED, so the
276
+ // co-parent sees it in My Files at once, and no tool here deletes it.
275
277
  description: 'Upload a PDF to OurFamilyWizard My Files for later attachment to an expense. Accepts either a local path or a signed ChatGPT/oaiusercontent HTTPS URL plus fileName. Exactly one of path or url must be supplied. This tool accepts PDF files only and uploads them using the same SHARED file metadata as the OFW expense form so the returned fileId can be attached to an expense. A SHARED file is visible to the co-parent in My Files immediately, whatever the visibility of the expense it is later attached to (that is set separately by ofw_create_expense privateExpense). ' + CONFIRM_NOTE,
276
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
278
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
277
279
  inputSchema: z.object({
278
280
  path: z.string().describe('Path to a local PDF file inside the upload directory (OFW_UPLOAD_DIR). Tilde (~) is expanded by the configured attachment I/O implementation. Mutually exclusive with url.').optional(),
279
281
  url: z.string().describe('Signed HTTPS oaiusercontent.com URL for a PDF supplied by the ChatGPT host. Mutually exclusive with path.').optional(),
@@ -309,6 +311,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
309
311
  const label = args.label ?? fileName;
310
312
  const description = args.description ?? fileName;
311
313
  const gate = await confirmWrite(ctx, {
314
+ args,
312
315
  tool: 'ofw_upload_expense_pdf',
313
316
  action: 'ofw.file.share',
314
317
  message: `Review and confirm uploading the receipt "${fileName}" to OurFamilyWizard. It is uploaded SHARED, so the co-parent can see it in My Files immediately — even if the expense it is attached to is private.`,
@@ -455,6 +458,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
455
458
  });
456
459
  }
457
460
  const gate = await confirmWrite(ctx, {
461
+ args,
458
462
  tool: 'ofw_update_expense',
459
463
  action: 'ofw.expense.update',
460
464
  message: next.isPrivate
@@ -558,6 +562,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
558
562
  payload.fileIds = [args.receiptFileId];
559
563
  const isPrivate = args.privateExpense === true;
560
564
  const gate = await confirmWrite(ctx, {
565
+ args,
561
566
  tool: 'ofw_create_expense',
562
567
  action: 'ofw.expense.create',
563
568
  message: isPrivate
@@ -1,5 +1,5 @@
1
1
  import { registerCredentialHealthcheckTool } from '@chrischall/mcp-utils/healthcheck';
2
- import { resolveAuth, isNoAuthConfigured, isBridgeDown } from '../auth.js';
2
+ import { isNoAuthConfigured, isBridgeDown } from '../auth.js';
3
3
  /**
4
4
  * `ofw_healthcheck` — the one call that answers "is this connector working?".
5
5
  *
@@ -13,10 +13,17 @@ import { resolveAuth, isNoAuthConfigured, isBridgeDown } from '../auth.js';
13
13
  * fetchproxy, and "which of those actually supplied it" is the first thing
14
14
  * anyone needs when the connector misbehaves. That is why `source` is
15
15
  * reported.
16
+ *
17
+ * The credential comes from the CLIENT (`credentialStatus`), not from a direct
18
+ * `resolveAuth()` call. That used to be a full login — or a fresh browser-bridge
19
+ * spin-up — on every healthcheck, ignoring the six-hour token the client
20
+ * already held; with a bad password each check re-POSTed it against OFW's
21
+ * failed-attempt counter (chrischall/fleet-audit#881). Through the client's
22
+ * TokenManager a held or cached token costs nothing, a needed mint is the one
23
+ * the next tool call would make anyway, and the probe then exercises that very
24
+ * token — so a passing healthcheck means the tools' own token works.
16
25
  */
17
- export function registerHealthcheckTools(server, client,
18
- /** Seam: the auth resolver, injectable so tests need no network. */
19
- resolve = resolveAuth) {
26
+ export function registerHealthcheckTools(server, client) {
20
27
  registerCredentialHealthcheckTool({
21
28
  server,
22
29
  prefix: 'ofw',
@@ -27,12 +34,13 @@ resolve = resolveAuth) {
27
34
  probePath: '/pub/v2/profiles',
28
35
  resolveCredential: async () => {
29
36
  try {
30
- const auth = await resolve();
37
+ const { source, expiresAt } = await client.credentialStatus();
31
38
  return {
32
- source: auth.source,
39
+ source,
33
40
  // Never the token. Expiry is the fact that explains a connector
34
- // that worked an hour ago and does not now.
35
- detail: auth.expiresAt ? { expires_at: auth.expiresAt.toISOString() } : undefined,
41
+ // that worked an hour ago and does not now — and this is the one
42
+ // the client itself will re-authenticate at.
43
+ detail: { expires_at: expiresAt.toISOString() },
36
44
  };
37
45
  }
38
46
  catch (e) {
@@ -73,7 +81,7 @@ resolve = resolveAuth) {
73
81
  no_credential: 'No OFW credential is configured. Either set OFW_USERNAME + OFW_PASSWORD, or install ' +
74
82
  'ContextMint Bridge and sign in to ourfamilywizard.com in a tab (unsetting ' +
75
83
  'OFW_DISABLE_FETCHPROXY if you set it).',
76
- credential_rejected: 'OurFamilyWizard rejected the credential. If it came from `env`, the password changed or ' +
84
+ credential_rejected: 'OurFamilyWizard rejected the credential. If it came from `env` or `cache`, the password changed or ' +
77
85
  'the account is locked; if from `fetchproxy`, the browser session expired — sign in again ' +
78
86
  'in the tab. Retrying will not fix either.',
79
87
  },
@@ -7,7 +7,7 @@ export function registerJournalTools(server, client) {
7
7
  const allowWrites = getWriteMode() === 'all';
8
8
  server.registerTool('ofw_list_journal_entries', {
9
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.',
10
- annotations: { readOnlyHint: true },
10
+ annotations: { readOnlyHint: true, openWorldHint: true },
11
11
  inputSchema: z.object({
12
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(),
13
13
  max: z.number().int().min(1).describe('Max results (default 10)').optional(),
@@ -38,8 +38,10 @@ export function registerJournalTools(server, client) {
38
38
  });
39
39
  if (allowWrites)
40
40
  server.registerTool('ofw_create_journal_entry', {
41
+ // Destructive by the inverse test: the entry lands on the court-visible
42
+ // record and no tool here edits or deletes a journal entry.
41
43
  description: 'Create a new journal entry in OurFamilyWizard',
42
- annotations: { destructiveHint: false },
44
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
43
45
  inputSchema: z.object({
44
46
  title: z.string().describe('Entry title'),
45
47
  body: z.string().describe('Entry text content'),