ofw-mcp 2.20.3 → 2.20.5

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
@@ -19,11 +19,20 @@ catch {
19
19
  /* v8 ignore next -- only reached in a non-Node runtime: no .env to load */
20
20
  }
21
21
  // Set OFW_DEBUG_LOG=1 (or true/yes/on) to log every OFW request/response to
22
- // stderr. Authorization is redacted. Bodies are logged in full — set this
23
- // only when debugging, never in normal use.
22
+ // stderr. Authorization is redacted. Locally, bodies are logged in full — set
23
+ // this only when debugging, never in normal use.
24
24
  function debugLogEnabled() {
25
25
  return parseBoolEnv('OFW_DEBUG_LOG');
26
26
  }
27
+ // On a hosted server (mcp-host injects MCP_DATA_DIR) stderr is captured by the
28
+ // host's log pipeline, which the operator can read — so request and response
29
+ // bodies (message text, drafts, expense ledgers) are replaced by their size.
30
+ // Locally stderr is the user's own terminal and bodies stay visible.
31
+ function debugBody(text) {
32
+ if (process.env.MCP_DATA_DIR)
33
+ return `<${text.length} chars, elided on a hosted server>`;
34
+ return text;
35
+ }
27
36
  // Per-request timeout. Overridable via OFW_REQUEST_TIMEOUT_MS. The default
28
37
  // (30s) is comfortably above OFW's typical p99 but low enough that a stuck
29
38
  // upstream fails fast instead of burning the MCP client-side budget — which
@@ -100,6 +109,9 @@ export class OFWClient {
100
109
  // deployment. Left undefined by the stdio path, which falls back to the
101
110
  // global resolver, keeping that behaviour byte-for-byte identical.
102
111
  authResolver;
112
+ // Which path minted the token currently held. Unset while the token is one
113
+ // the TokenManager restored from the session cache — nothing was minted.
114
+ mintedBy;
103
115
  constructor(opts) {
104
116
  this.authResolver = opts?.resolveAuth;
105
117
  }
@@ -111,7 +123,8 @@ export class OFWClient {
111
123
  // the expired placeholder that used to sit here would have meant the
112
124
  // cache was written but never read.
113
125
  const mint = async () => {
114
- const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
126
+ const { token, expiresAt, source } = await (this.authResolver ?? resolveAuth)();
127
+ this.mintedBy = source;
115
128
  return {
116
129
  accessToken: token,
117
130
  refreshToken: OFW_REFRESH_SENTINEL,
@@ -136,11 +149,27 @@ export class OFWClient {
136
149
  }
137
150
  return this.tokenManager;
138
151
  }
152
+ /**
153
+ * The credential's source and expiry, answered from the TokenManager — the
154
+ * same token, cache and single-flight mint every tool uses.
155
+ *
156
+ * `ofw_healthcheck` used to call `resolveAuth()` directly, which is a full
157
+ * login (or a fresh browser-bridge spin-up) on every healthcheck, ignoring
158
+ * the six-hour token already held. Going through the manager means a held or
159
+ * cached token costs nothing, and a mint, when one is needed, is the one the
160
+ * next real tool call would have made anyway. A mint failure propagates
161
+ * unchanged so the healthcheck can classify it.
162
+ */
163
+ async credentialStatus() {
164
+ const tokens = this.getTokenManager();
165
+ await tokens.getAccessToken();
166
+ return { source: this.mintedBy ?? 'cache', expiresAt: new Date(tokens.getExpiresAt()) };
167
+ }
139
168
  async request(method, path, body) {
140
169
  return this.fetchAuthed(method, path, body, 'application/json', async (response) => {
141
170
  const text = await response.text();
142
171
  if (debugLogEnabled()) {
143
- console.error(`[ofw-debug] response body: ${text || '<empty>'}`);
172
+ console.error(`[ofw-debug] response body: ${text ? debugBody(text) : '<empty>'}`);
144
173
  }
145
174
  return (text ? JSON.parse(text) : null);
146
175
  });
@@ -223,7 +252,7 @@ export class OFWClient {
223
252
  ? '<none>'
224
253
  : isFormData
225
254
  ? `<FormData entries=${Array.from(body.keys()).join(',')}>`
226
- : JSON.stringify(body);
255
+ : debugBody(JSON.stringify(body));
227
256
  console.error(`[ofw-debug] → ${method} ${url}${isRetry ? ' (retry)' : ''}`);
228
257
  // redactSecrets scrubs the Bearer token (and any other secret shapes)
229
258
  // from the serialized header map — shared fleet redaction, never bespoke.
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.',
package/dist/protocol.js CHANGED
@@ -15,13 +15,23 @@ export const OFW_TOKEN_TTL_MS = 6 * 60 * 60 * 1000;
15
15
  // How early we treat a token as expiring. Re-auth before this skew so a
16
16
  // long-running request doesn't get a stale token mid-flight.
17
17
  export const OFW_TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000;
18
- // The only host this server is ever allowed to contact. Derived from BASE_URL
19
- // so the two cannot drift.
18
+ // The only host the authenticated API path is ever allowed to contact.
19
+ // Derived from BASE_URL so the two cannot drift.
20
20
  const ALLOWED_HOST = new URL(BASE_URL).host;
21
21
  /**
22
- * Enforced egress allowlist: every outbound request passes its
23
- * fully-constructed URL through here before `fetch` (API calls in client.ts,
24
- * the form login in auth-password.ts).
22
+ * The one OTHER host family this server fetches: signed `*.oaiusercontent.com`
23
+ * file URLs that `ofw_upload_expense_pdf` downloads a receipt from (its `url`
24
+ * mode). That fetch carries no OFW credential and is checked per redirect hop
25
+ * by `assertReceiptUrl` in tools/expenses.ts, not by `assertOfwUrl`. mint.yaml's
26
+ * hosted egress allowlist names exactly these two hosts — tests pin it.
27
+ */
28
+ export const RECEIPT_HOST_SUFFIX = '.oaiusercontent.com';
29
+ /**
30
+ * Enforced egress allowlist for every request that carries the OFW bearer
31
+ * token or a login: its fully-constructed URL passes through here before
32
+ * `fetch` (API calls in client.ts, the form login in auth-password.ts). The
33
+ * credential-free remote-receipt download is the one exception, held to
34
+ * `RECEIPT_HOST_SUFFIX` instead.
25
35
  *
26
36
  * Today this always passes, and that is the point. Every URL is
27
37
  * `${BASE_URL}${path}` with a path that begins with '/', so no id or query
@@ -29,9 +39,9 @@ const ALLOWED_HOST = new URL(BASE_URL).host;
29
39
  * the authority component when a '/' already precedes it. The check exists so
30
40
  * that stays true after a future refactor: a code change (or a compromised
31
41
  * dependency) that pointed `fetch` somewhere else would throw here instead of
32
- * carrying the bearer token or a message body off-host. It makes "this server
33
- * only ever talks to OFW" a structural invariant rather than a code-review
34
- * promise.
42
+ * carrying the bearer token or a message body off-host. It makes "the
43
+ * credential only ever goes to OFW" a structural invariant rather than a
44
+ * code-review promise.
35
45
  *
36
46
  * Scope, stated honestly: this is a PRE-FLIGHT check on the URL we construct.
37
47
  * It does not follow redirects, so it is not a complete egress control — an
@@ -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.`,
@@ -5,6 +5,7 @@ import { CONFIRM_NOTE, confirmTokenParam, confirmWrite, stateRevision } from './
5
5
  import { readUpstreamPaging } from './pagination.js';
6
6
  import { getExpenseUploadOnly, getWriteMode } from '../config.js';
7
7
  import { parseLenient } from '@chrischall/mcp-utils';
8
+ import { RECEIPT_HOST_SUFFIX } from '../protocol.js';
8
9
  const UploadedExpenseFileSchema = z.looseObject({
9
10
  fileId: z.number(),
10
11
  fileName: z.string().optional(),
@@ -32,7 +33,7 @@ function assertReceiptUrl(url) {
32
33
  if (url.protocol !== 'https:') {
33
34
  throw new Error('Remote expense receipt URLs must use HTTPS.');
34
35
  }
35
- if (!url.hostname.toLowerCase().endsWith('.oaiusercontent.com')) {
36
+ if (!url.hostname.toLowerCase().endsWith(RECEIPT_HOST_SUFFIX)) {
36
37
  throw new Error('Remote expense receipt URLs must be signed oaiusercontent.com file URLs.');
37
38
  }
38
39
  }
@@ -208,7 +209,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
208
209
  if (!uploadOnly)
209
210
  server.registerTool('ofw_get_expense_totals', {
210
211
  description: 'Get OurFamilyWizard expense summary totals (owed/paid)',
211
- annotations: { readOnlyHint: true },
212
+ annotations: { readOnlyHint: true, openWorldHint: true },
212
213
  }, async () => {
213
214
  const data = await client.request('GET', '/pub/v2/expense/expenses/totals');
214
215
  return jsonResponse(data);
@@ -216,7 +217,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
216
217
  if (!uploadOnly)
217
218
  server.registerTool('ofw_list_expense_categories', {
218
219
  description: 'List OurFamilyWizard expense categories, including preset and custom categories with their responsibility split metadata. Read-only.',
219
- annotations: { readOnlyHint: true },
220
+ annotations: { readOnlyHint: true, openWorldHint: true },
220
221
  }, async () => {
221
222
  const data = await client.request('GET', '/pub/v2/expense/categories');
222
223
  return jsonResponse(data);
@@ -224,7 +225,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
224
225
  if (!uploadOnly)
225
226
  server.registerTool('ofw_list_expenses', {
226
227
  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 },
228
+ annotations: { readOnlyHint: true, openWorldHint: true },
228
229
  inputSchema: z.object({
229
230
  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
231
  size: z.number().int().min(1).max(100).describe('Requested page size (default 20). OFW may cap or normalize this value.').optional(),
@@ -272,8 +273,10 @@ export function registerExpenseTools(server, client, attachmentIO) {
272
273
  });
273
274
  if (allowReceiptUploads)
274
275
  server.registerTool('ofw_upload_expense_pdf', {
276
+ // Destructive by the inverse test: always uploaded SHARED, so the
277
+ // co-parent sees it in My Files at once, and no tool here deletes it.
275
278
  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 },
279
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
277
280
  inputSchema: z.object({
278
281
  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
282
  url: z.string().describe('Signed HTTPS oaiusercontent.com URL for a PDF supplied by the ChatGPT host. Mutually exclusive with path.').optional(),
@@ -309,6 +312,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
309
312
  const label = args.label ?? fileName;
310
313
  const description = args.description ?? fileName;
311
314
  const gate = await confirmWrite(ctx, {
315
+ args,
312
316
  tool: 'ofw_upload_expense_pdf',
313
317
  action: 'ofw.file.share',
314
318
  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 +459,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
455
459
  });
456
460
  }
457
461
  const gate = await confirmWrite(ctx, {
462
+ args,
458
463
  tool: 'ofw_update_expense',
459
464
  action: 'ofw.expense.update',
460
465
  message: next.isPrivate
@@ -558,6 +563,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
558
563
  payload.fileIds = [args.receiptFileId];
559
564
  const isPrivate = args.privateExpense === true;
560
565
  const gate = await confirmWrite(ctx, {
566
+ args,
561
567
  tool: 'ofw_create_expense',
562
568
  action: 'ofw.expense.create',
563
569
  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'),