ofw-mcp 2.19.3 → 2.19.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/config.js CHANGED
@@ -33,6 +33,10 @@ export function getAttachmentsDir() {
33
33
  // Claude Desktop, so files written there are unreadable to the model that
34
34
  // just downloaded them. Downloads is the standard "user-accessible files"
35
35
  // location across macOS/Linux/Windows.
36
+ return getDefaultAttachmentsDir();
37
+ }
38
+ /** The dedicated default attachments directory, `~/Downloads/ofw-mcp`. */
39
+ export function getDefaultAttachmentsDir() {
36
40
  return join(homedir(), 'Downloads', 'ofw-mcp');
37
41
  }
38
42
  /**
package/dist/index.js CHANGED
@@ -36,7 +36,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
36
36
  // always succeeds before any credential check runs.
37
37
  await runMcp({
38
38
  name: 'ofw',
39
- version: '2.19.3', // x-release-please-version
39
+ version: '2.19.5', // x-release-please-version
40
40
  deps: client,
41
41
  tools: [
42
42
  registerHealthcheckTools,
@@ -0,0 +1,49 @@
1
+ import { confirmationFromEnv, confirmTokenParam, requireConfirmationWithFallback } from '@chrischall/mcp-utils';
2
+ import { fnv1a64 } from './draft-freshness.js';
3
+ export { confirmTokenParam };
4
+ /** The sentence every confirm-gated tool's description ends with. */
5
+ export const CONFIRM_NOTE = 'Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call '
6
+ + 'performs NO write and returns a preview of exactly what would happen plus a confirmToken, and only a repeat '
7
+ + 'call with that token proceeds (see MCP_CONFIRM_MODE).';
8
+ /**
9
+ * Confirm-gate for a write that reaches the co-parent or the court-visible
10
+ * record. A client that can show a confirmation prompt is asked; one that
11
+ * cannot (claude.ai, Claude Desktop — measured in mcp-utils
12
+ * docs/CLIENT-BEHAVIOUR.md) gets the two-phase token flow governed by
13
+ * `MCP_CONFIRM_MODE`: phase 1 performs no write and returns the preview plus a
14
+ * `confirmToken`; only a repeat call with that token proceeds. The token is
15
+ * bound to this tool, the target, its revision and the exact payload, is
16
+ * single-use and expires (`MCP_CONFIRM_TTL_SECONDS`).
17
+ *
18
+ * Call it on EVERY invocation with the freshly-built payload and a freshly
19
+ * read revision — the caller's own re-read is what makes a stale token fail.
20
+ * `OFW_WRITE_MODE` stays the structural layer underneath: a tool this gate
21
+ * protects is still not registered at all below its write mode.
22
+ *
23
+ * Returns `undefined` to proceed with the write, otherwise the result to
24
+ * return unchanged.
25
+ */
26
+ export function confirmWrite(ctx, opts) {
27
+ return requireConfirmationWithFallback(ctx, confirmationFromEnv({
28
+ action: opts.action,
29
+ message: opts.message,
30
+ details: opts.preview,
31
+ unsupportedNote: 'Complete this action on ourfamilywizard.com instead.',
32
+ tool: opts.tool,
33
+ confirmToken: opts.confirmToken,
34
+ subject: () => ({
35
+ target: opts.target,
36
+ ...(opts.revision !== undefined ? { revision: opts.revision } : {}),
37
+ payload: opts.payload,
38
+ preview: opts.preview,
39
+ }),
40
+ }));
41
+ }
42
+ /**
43
+ * A revision string for a target as just read from OFW (an event detail), for
44
+ * `ConfirmWriteOptions.revision`. Any change to the value rotates it, so a
45
+ * token minted against one state cannot act on another.
46
+ */
47
+ export function stateRevision(value) {
48
+ return `s1:${fnv1a64(JSON.stringify(value))}`;
49
+ }
@@ -269,6 +269,36 @@ function isDefinitiveRejection(e) {
269
269
  const m = /OFW API error: (4\d\d)\b/.exec(e.message);
270
270
  return m !== null && m[1] !== '408';
271
271
  }
272
+ /**
273
+ * Issue a write that lands on the shared, court-visible record (an expense,
274
+ * a calendar change, a journal entry) and classify how it failed. A
275
+ * definitive rejection (4xx, repeated 429) is rethrown as-is: nothing landed
276
+ * and a retry is safe. Anything else — a timeout, a dropped connection, a
277
+ * 5xx, a cancellation — is an UnconfirmedWriteError, because OFW may already
278
+ * have accepted the write, and a model that reads a plain "request timed out"
279
+ * retries it and puts a duplicate in front of the co-parent.
280
+ */
281
+ export async function requestWrite(client, method, path, body) {
282
+ try {
283
+ return await client.request(method, path, body);
284
+ }
285
+ catch (e) {
286
+ throw isDefinitiveRejection(e) ? e : new UnconfirmedWriteError(null, e);
287
+ }
288
+ }
289
+ /**
290
+ * The `<X>_UNCONFIRMED` result for a write whose outcome is unknown: an
291
+ * error result (so it is not read as success) that says in words the write
292
+ * may have landed and names how to check before any retry.
293
+ */
294
+ export function unconfirmedWriteResponse(e, opts) {
295
+ return jsonErrorResponse({
296
+ result: opts.result,
297
+ mayHaveLanded: true,
298
+ reason: `The request to ${opts.what} failed without a definitive answer from OFW: ${e.message}. It MAY HAVE BEEN APPLIED on OurFamilyWizard.`,
299
+ remedy: `Do NOT retry blindly — a second attempt can put a duplicate on the co-parent-visible record. First check with ${opts.checkWith} (or on ourfamilywizard.com), and retry only once you have confirmed it did not land.`,
300
+ });
301
+ }
272
302
  export async function postMessageAndRefetch(client, payload, detailSchema, ctx) {
273
303
  let posted;
274
304
  try {
@@ -8,9 +8,9 @@
8
8
  // disk injects an inline, filesystem-free implementation.
9
9
  // Keeping the interface here means src/tools/messages.ts imports nothing from
10
10
  // node:fs.
11
- import { existsSync, readFileSync, realpathSync, statSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
11
+ import { chmodSync, existsSync, readFileSync, realpathSync, statSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
12
12
  import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
13
- import { getUploadDir } from '../config.js';
13
+ import { getDefaultAttachmentsDir, getUploadDir } from '../config.js';
14
14
  import { fileBlob, expandPath } from '@chrischall/mcp-utils';
15
15
  /**
16
16
  * True when `candidate` is strictly inside `root` (both absolute). Lexical:
@@ -147,7 +147,9 @@ export class NodeAttachmentIO {
147
147
  const fileName = basename(abs);
148
148
  const mimeType = mimeFromName(fileName);
149
149
  // fileBlob streams the file off disk (a file-backed Blob) instead of buffering it.
150
- const blob = await fileBlob(real, { type: mimeType });
150
+ // allowedRoots makes it re-check confinement (through symlinks) at open time,
151
+ // closing the window between the checks above and the open.
152
+ const blob = await fileBlob(real, { type: mimeType, allowedRoots: [realRoot] });
151
153
  return { blob, fileName, mimeType, sizeBytes: stat.size };
152
154
  }
153
155
  readDownloaded(path) {
@@ -163,14 +165,23 @@ export class NodeAttachmentIO {
163
165
  // boundary. Check the REAL path of the nearest existing ancestor before
164
166
  // creating anything, so a symlinked directory inside the root cannot carry
165
167
  // the write (or even the mkdir) somewhere else.
166
- mkdirSync(root, { recursive: true });
168
+ //
169
+ // Directories are created 0700: the listing itself is sensitive (entries
170
+ // are `<fileId>-<filename>`, with names the co-parent chose), so 0600
171
+ // bytes in a world-listable directory would still leak them. The
172
+ // DEDICATED default dir is also tightened if it already exists (an older
173
+ // version created it 0755); a directory the user configured themselves
174
+ // keeps its mode, since it may be shared on purpose.
175
+ mkdirSync(root, { recursive: true, mode: 0o700 });
176
+ if (resolve(root) === resolve(getDefaultAttachmentsDir()))
177
+ chmodSync(root, 0o700);
167
178
  const realRoot = realpathSync(root);
168
179
  const parent = dirname(dest);
169
180
  const anchor = realpathSync(deepestExisting(parent));
170
181
  if (anchor !== realRoot && !isWithin(realRoot, anchor)) {
171
182
  throw new Error(`Refusing to write ${dest}: it resolves outside the attachments directory (${root}).`);
172
183
  }
173
- mkdirSync(parent, { recursive: true });
184
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
174
185
  if (overwrite) {
175
186
  // unlink removes a symlink itself, never its target.
176
187
  try {
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { jsonResponse, textResponse } from './_shared.js';
2
+ import { jsonResponse, requestWrite, textResponse, UnconfirmedWriteError, unconfirmedWriteResponse } from './_shared.js';
3
+ import { CONFIRM_NOTE, confirmTokenParam, confirmWrite, stateRevision } from './_confirm.js';
3
4
  import { getCalendarWritesAllowed } from '../config.js';
4
5
  import { parseLenient } from '@chrischall/mcp-utils';
5
6
  // OFW's real event-write API (reverse-engineered from the web app bundle):
@@ -104,6 +105,47 @@ function detailToWriteArgs(d) {
104
105
  pickUpParentId: d.pickUpParent?.userId,
105
106
  };
106
107
  }
108
+ const SHARED = 'shared with co-parent';
109
+ const PRIVATE = 'private (only you)';
110
+ /**
111
+ * The human-readable form of an event for a confirmation preview: the fields
112
+ * a person checks (title, when, where, who sees it), with tagged users named
113
+ * wherever OFW's own event detail named them. An id OFW gave no name for is
114
+ * reported with `name: null` — never an invented one.
115
+ */
116
+ function describeEvent(a, names = new Map()) {
117
+ const who = (id) => ({ userId: id, name: names.get(id) ?? null });
118
+ const out = {
119
+ title: a.title,
120
+ startDate: a.startDate,
121
+ endDate: a.endDate ?? a.startDate,
122
+ ...(a.allDay ? { allDay: true } : { startTime: a.startTime, endTime: a.endTime }),
123
+ visibility: a.privateEvent ? PRIVATE : SHARED,
124
+ };
125
+ if (a.location)
126
+ out.location = a.location;
127
+ if (a.notes)
128
+ out.notes = a.notes;
129
+ if (a.children !== undefined)
130
+ out.children = a.children.map((id) => who(id));
131
+ if (a.eventParentId !== undefined)
132
+ out.eventParent = who(a.eventParentId);
133
+ if (a.dropOffParentId !== undefined)
134
+ out.dropOffParent = who(a.dropOffParentId);
135
+ if (a.pickUpParentId !== undefined)
136
+ out.pickUpParent = who(a.pickUpParentId);
137
+ return out;
138
+ }
139
+ /** userId → name for every tagged person the event detail names. */
140
+ function namesIn(d) {
141
+ const names = new Map();
142
+ for (const ref of [...(d.children ?? []), d.eventParent, d.dropOffParent, d.pickUpParent]) {
143
+ const name = ref?.name;
144
+ if (ref && typeof name === 'string' && name)
145
+ names.set(ref.userId, name);
146
+ }
147
+ return names;
148
+ }
107
149
  export function registerCalendarTools(server, client) {
108
150
  // Calendar writes land on the court-visible record with no draft stage, but
109
151
  // events are reversible — 'all' mode, or 'drafts' + OFW_CALENDAR_WRITES=true.
@@ -123,14 +165,48 @@ export function registerCalendarTools(server, client) {
123
165
  });
124
166
  if (allowWrites)
125
167
  server.registerTool('ofw_create_event', {
126
- 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.',
127
- annotations: { destructiveHint: false },
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 },
128
172
  inputSchema: z.object({
129
173
  title: z.string(),
130
174
  ...eventWriteFields,
175
+ confirmToken: confirmTokenParam,
131
176
  }),
132
- }, async (args) => {
133
- const raw = await client.request('POST', '/pub/v3/events', buildEventPayload(args));
177
+ }, async (args, ctx) => {
178
+ const { confirmToken, ...fields } = args;
179
+ const payload = buildEventPayload(fields);
180
+ if (!fields.privateEvent) {
181
+ const gate = await confirmWrite(ctx, {
182
+ tool: 'ofw_create_event',
183
+ action: 'ofw.event.create',
184
+ message: 'Review and confirm this OurFamilyWizard calendar event. It is shared with the co-parent and appears on their calendar immediately.',
185
+ target: 'event:new',
186
+ payload,
187
+ preview: {
188
+ action: 'Create shared OurFamilyWizard calendar event',
189
+ event: describeEvent(fields),
190
+ warning: 'Visible to the co-parent immediately; part of the court-visible record.',
191
+ },
192
+ confirmToken,
193
+ });
194
+ if (gate)
195
+ return gate;
196
+ }
197
+ let raw;
198
+ try {
199
+ raw = await requestWrite(client, 'POST', '/pub/v3/events', payload);
200
+ }
201
+ catch (e) {
202
+ if (!(e instanceof UnconfirmedWriteError))
203
+ throw e;
204
+ return unconfirmedWriteResponse(e, {
205
+ result: 'EVENT_UNCONFIRMED',
206
+ what: 'create this event',
207
+ checkWith: `ofw_list_events for ${fields.startDate} (look for "${fields.title}")`,
208
+ });
209
+ }
134
210
  const event = parseLenient(eventDetailSchema, raw, { label: 'ofw-mcp', context: 'POST /pub/v3/events', mode: 'strict' });
135
211
  return jsonResponse({
136
212
  note: `Event created. Use eventRecurrenceId ${event.eventRecurrenceId} as eventId for ofw_update_event/ofw_delete_event.`,
@@ -139,7 +215,7 @@ export function registerCalendarTools(server, client) {
139
215
  });
140
216
  if (allowWrites)
141
217
  server.registerTool('ofw_update_event', {
142
- 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).',
218
+ 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. ' + CONFIRM_NOTE,
143
219
  annotations: { destructiveHint: true },
144
220
  inputSchema: z.object({
145
221
  eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
@@ -157,15 +233,41 @@ export function registerCalendarTools(server, client) {
157
233
  eventParentId: eventWriteFields.eventParentId,
158
234
  dropOffParentId: eventWriteFields.dropOffParentId,
159
235
  pickUpParentId: eventWriteFields.pickUpParentId,
236
+ confirmToken: confirmTokenParam,
160
237
  }),
161
- }, async (args) => {
162
- const { eventId, ...changes } = args;
238
+ }, async (args, ctx) => {
239
+ const { eventId, confirmToken, ...changes } = args;
163
240
  const id = encodeURIComponent(eventId);
241
+ // Re-read on EVERY call (phase 1 and phase 2 alike): the merge base and
242
+ // the confirmation's revision both come from this read.
164
243
  const rawDetail = await client.request('GET', `/pub/v3/events/${id}`);
165
244
  const current = parseLenient(eventDetailSchema, rawDetail, { label: 'ofw-mcp', context: `GET /pub/v3/events/${eventId}`, mode: 'strict' });
166
245
  const defined = Object.fromEntries(Object.entries(changes).filter(([, v]) => v !== undefined));
167
- const merged = { ...detailToWriteArgs(current), ...defined };
168
- await client.request('PUT', `/pub/v3/events/${id}`, buildEventPayload(merged));
246
+ const base = detailToWriteArgs(current);
247
+ const merged = { ...base, ...defined };
248
+ const payload = buildEventPayload(merged);
249
+ if (current.publicFlag || !merged.privateEvent) {
250
+ const names = namesIn(current);
251
+ const gate = await confirmWrite(ctx, {
252
+ tool: 'ofw_update_event',
253
+ action: 'ofw.event.update',
254
+ message: `Review and confirm this change to the OurFamilyWizard event "${current.title}". The co-parent sees the updated event on their calendar.`,
255
+ target: `event:${eventId}`,
256
+ revision: stateRevision(base),
257
+ payload,
258
+ preview: {
259
+ action: 'Update OurFamilyWizard calendar event',
260
+ eventId,
261
+ before: describeEvent(base, names),
262
+ after: describeEvent(merged, names),
263
+ warning: 'The co-parent sees this change; part of the court-visible record.',
264
+ },
265
+ confirmToken,
266
+ });
267
+ if (gate)
268
+ return gate;
269
+ }
270
+ await client.request('PUT', `/pub/v3/events/${id}`, payload);
169
271
  // PUT responses aren't documented — re-fetch the detail as authoritative state.
170
272
  const rawAfter = await client.request('GET', `/pub/v3/events/${id}`);
171
273
  const event = parseLenient(eventDetailSchema, rawAfter, { label: 'ofw-mcp', context: `GET /pub/v3/events/${eventId} (post-update)`, mode: 'strict' });
@@ -173,15 +275,41 @@ export function registerCalendarTools(server, client) {
173
275
  });
174
276
  if (allowWrites)
175
277
  server.registerTool('ofw_delete_event', {
176
- description: 'Delete an OurFamilyWizard calendar event',
177
- annotations: { destructiveHint: true },
278
+ description: 'Delete an OurFamilyWizard calendar event. Reads the event first; deleting one the co-parent can see is confirmed first, with a preview of exactly which event (title, date, time) is removed, and is refused if the event changed on OFW after that preview. ' + CONFIRM_NOTE,
279
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
178
280
  inputSchema: z.object({
179
281
  eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
180
282
  includeFuture: z.boolean().describe('For repeating events: also delete future occurrences (default false)').optional(),
283
+ confirmToken: confirmTokenParam,
181
284
  }),
182
- }, async (args) => {
285
+ }, async (args, ctx) => {
183
286
  const includeFuture = args.includeFuture ?? false;
184
- await client.request('DELETE', `/pub/v3/events/${encodeURIComponent(args.eventId)}?includeFuture=${includeFuture}`);
185
- return textResponse(`Event ${args.eventId} deleted`);
287
+ const id = encodeURIComponent(args.eventId);
288
+ const rawDetail = await client.request('GET', `/pub/v3/events/${id}`);
289
+ const current = parseLenient(eventDetailSchema, rawDetail, { label: 'ofw-mcp', context: `GET /pub/v3/events/${args.eventId}`, mode: 'strict' });
290
+ if (current.publicFlag) {
291
+ const base = detailToWriteArgs(current);
292
+ const gate = await confirmWrite(ctx, {
293
+ tool: 'ofw_delete_event',
294
+ action: 'ofw.event.delete',
295
+ message: `Review and confirm deleting the OurFamilyWizard event "${current.title}". It disappears from the co-parent's calendar.`,
296
+ target: `event:${args.eventId}`,
297
+ revision: stateRevision(base),
298
+ payload: { eventId: args.eventId, includeFuture },
299
+ preview: {
300
+ action: 'Delete shared OurFamilyWizard calendar event',
301
+ eventId: args.eventId,
302
+ event: describeEvent(base, namesIn(current)),
303
+ includeFuture,
304
+ ...(includeFuture ? { note: 'Future occurrences of this repeating event are deleted too.' } : {}),
305
+ warning: 'Removed from the co-parent\'s calendar.',
306
+ },
307
+ confirmToken: args.confirmToken,
308
+ });
309
+ if (gate)
310
+ return gate;
311
+ }
312
+ await client.request('DELETE', `/pub/v3/events/${id}?includeFuture=${includeFuture}`);
313
+ return textResponse(`Event ${args.eventId} ("${current.title}") deleted`);
186
314
  });
187
315
  }
@@ -14,12 +14,29 @@
14
14
  // 2. extractable document → the FILE'S TEXT, as text (see src/extract)
15
15
  // 3. raw bytes → base64 EmbeddedResource, as before
16
16
  //
17
+ // Rungs 1 and 3 are bounded by MAX_INLINE_BYTES (10 MiB raw; see below).
17
18
  // Rung 3 never disappears, so nothing regresses; rung 2 is what makes a
18
19
  // spreadsheet, PDF, Word or PowerPoint attachment readable at all. When a rung
19
20
  // is skipped or fails, the response says so by name in `deliveryAttempts` —
20
21
  // a caller must never be left guessing why it got bytes instead of content.
21
22
  import { extractAttachment } from '../extract/index.js';
22
23
  import { isHostRenderableImage } from './attachments.js';
24
+ /**
25
+ * Upper bound on the RAW bytes this tool will return inline (as ImageContent
26
+ * or a base64 EmbeddedResource). Extracted text is not subject to it — that is
27
+ * already bounded by `maxChars`.
28
+ *
29
+ * Why 10 MiB: mcp-host, the hosted runtime that runs this MCP for claude.ai,
30
+ * caps any single child result at 14 MiB of serialized JSON-RPC
31
+ * (`CHILD_RESULT_MAX_BYTES`, chrischall/mcp-host#952) and replaces anything
32
+ * larger with a generic "result too large" tool error. 10 MiB raw is ~13.3 MiB
33
+ * as base64, which with the JSON-RPC envelope still fits under 14 MiB — so this
34
+ * bound fires first, with a message that says what to do instead. claude.ai
35
+ * itself accepts images up to 10 MB each (measured 2026-09-27), and before
36
+ * #952 anything over 10 MiB killed the child process outright. Real traffic
37
+ * for this tool has peaked at ~3 MB, so normal use never reaches it.
38
+ */
39
+ export const MAX_INLINE_BYTES = 10 * 1024 * 1024;
23
40
  /**
24
41
  * Attempt extraction, converting every failure into a REASON rather than an
25
42
  * exception: a format we cannot read must still be delivered as bytes, and the
@@ -42,6 +59,26 @@ export async function tryExtract(bytes, mimeType, fileName, opts) {
42
59
  return { reason: `extraction failed: ${err instanceof Error ? err.message : String(err)}` };
43
60
  }
44
61
  }
62
+ /**
63
+ * Refuse to inline more than {@link MAX_INLINE_BYTES}. This is a tool error
64
+ * rather than a fallback: the only non-inline channel is disk mode, which the
65
+ * hosted runtime (the one the cap protects) does not have, and silently
66
+ * writing to disk when the caller asked for content would not be honest.
67
+ */
68
+ function assertInlineSize(input, rawRequested) {
69
+ const { bytes, fileName, fileId, diskAvailable } = input;
70
+ if (bytes.length <= MAX_INLINE_BYTES)
71
+ return;
72
+ const hints = [];
73
+ if (rawRequested)
74
+ hints.push('omit extract:false to get the file\'s extracted text instead, if it is a readable document type');
75
+ if (diskAvailable)
76
+ hints.push('pass inline:false to save it to disk and get the path');
77
+ hints.push('or open it directly in OurFamilyWizard');
78
+ throw new Error(`Attachment ${fileId} (${fileName}) is ${bytes.length} bytes, over the ${MAX_INLINE_BYTES / (1024 * 1024)} MiB `
79
+ + `limit for returning a file inline (a larger result would exceed the host's response size limit). `
80
+ + `To get it: ${hints.join('; ')}.`);
81
+ }
45
82
  /**
46
83
  * Build the content blocks for an inline download by walking the ladder.
47
84
  * The first block is always a JSON meta block naming `deliveredVia`, so the
@@ -57,6 +94,7 @@ export async function buildInlineDelivery(input) {
57
94
  const block = () => ({ type: 'text', text: JSON.stringify(meta, null, 2) });
58
95
  // Rung 1 — the host renders these itself, and a picture beats a description.
59
96
  if (isHostRenderableImage(mimeType)) {
97
+ assertInlineSize(input, false);
60
98
  meta.deliveredVia = 'image';
61
99
  return { content: [block(), { type: 'image', data: bytes.toString('base64'), mimeType }] };
62
100
  }
@@ -80,8 +118,10 @@ export async function buildInlineDelivery(input) {
80
118
  /* v8 ignore next -- tryExtract always sets `reason` when it returns no extraction */
81
119
  attempts.push(outcome.reason ?? 'extraction produced no content');
82
120
  }
83
- // Rung 3 — the bytes themselves. Always available, so a fetch that succeeded
84
- // never ends with the caller holding nothing.
121
+ // Rung 3 — the bytes themselves. Always available up to MAX_INLINE_BYTES, so
122
+ // a fetch that succeeded never ends with the caller holding nothing; beyond
123
+ // it the caller gets a tool error that says how to reach the file instead.
124
+ assertInlineSize(input, options.extract === false);
85
125
  meta.deliveredVia = 'blob';
86
126
  meta.deliveryAttempts = attempts;
87
127
  meta.note = 'Returned as raw bytes. Some hosts cannot render an embedded resource of this type; '
@@ -11,7 +11,7 @@ export class DraftFreshnessError extends Error {
11
11
  const FNV_OFFSET = 0xcbf29ce484222325n;
12
12
  const FNV_PRIME = 0x100000001b3n;
13
13
  const MASK64 = 0xffffffffffffffffn;
14
- function fnv1a64(s) {
14
+ export function fnv1a64(s) {
15
15
  let h = FNV_OFFSET;
16
16
  for (let i = 0; i < s.length; i++) {
17
17
  h = (h ^ BigInt(s.charCodeAt(i))) * FNV_PRIME & MASK64;
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { jsonResponse } from './_shared.js';
2
+ import { jsonResponse, requestWrite, UnconfirmedWriteError, unconfirmedWriteResponse } from './_shared.js';
3
+ import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
3
4
  import { offsetState, readUpstreamPaging, withPaginationFirst } from './pagination.js';
4
5
  import { getWriteMode } from '../config.js';
5
6
  export function registerExpenseTools(server, client) {
@@ -44,14 +45,47 @@ export function registerExpenseTools(server, client) {
44
45
  });
45
46
  if (allowWrites)
46
47
  server.registerTool('ofw_create_expense', {
47
- description: 'Log a new expense in OurFamilyWizard',
48
- annotations: { destructiveHint: false },
48
+ description: 'Log a new expense in OurFamilyWizard. The expense is a money claim that appears in the shared ledger in front of the co-parent immediately, and this server cannot delete it. If the request fails without a definitive answer the result is EXPENSE_UNCONFIRMED: the expense may already exist, so do NOT retry until ofw_list_expenses shows it did not land. ' + CONFIRM_NOTE,
49
+ // Not a harmless local write: the claim is co-parent-visible at once and
50
+ // this server has no way to take it back. destructiveHint keeps a host
51
+ // that auto-approves "non-destructive" tools from running it silently.
52
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
49
53
  inputSchema: z.object({
50
54
  amount: z.number().describe('Expense amount'),
51
55
  description: z.string().describe('Expense description'),
56
+ confirmToken: confirmTokenParam,
52
57
  }),
53
- }, async (args) => {
54
- const data = await client.request('POST', '/pub/v2/expense/expenses', args);
58
+ }, async (args, ctx) => {
59
+ const { confirmToken, ...payload } = args;
60
+ const gate = await confirmWrite(ctx, {
61
+ tool: 'ofw_create_expense',
62
+ action: 'ofw.expense.create',
63
+ message: 'Review and confirm this OurFamilyWizard expense. It is logged in the shared ledger the co-parent sees immediately, and cannot be deleted through this server.',
64
+ target: 'expense:new',
65
+ payload,
66
+ preview: {
67
+ action: 'Log OurFamilyWizard expense',
68
+ amount: payload.amount,
69
+ description: payload.description,
70
+ warning: 'Visible to the co-parent immediately as a claim in the shared expense ledger; part of the court-visible record.',
71
+ },
72
+ confirmToken,
73
+ });
74
+ if (gate)
75
+ return gate;
76
+ let data;
77
+ try {
78
+ data = await requestWrite(client, 'POST', '/pub/v2/expense/expenses', payload);
79
+ }
80
+ catch (e) {
81
+ if (!(e instanceof UnconfirmedWriteError))
82
+ throw e;
83
+ return unconfirmedWriteResponse(e, {
84
+ result: 'EXPENSE_UNCONFIRMED',
85
+ what: 'log this expense',
86
+ checkWith: 'ofw_list_expenses (look for this amount and description among the newest expenses)',
87
+ });
88
+ }
55
89
  return jsonResponse(data);
56
90
  });
57
91
  }
@@ -62,7 +62,7 @@ resolve = resolveAuth) {
62
62
  kind: 'transport',
63
63
  // The upstream `.hint` rides along in `error.message` — it carries
64
64
  // the actionable "click the toolbar icon" copy this cannot know.
65
- hint: 'The fetchproxy bridge is down, so the browser path could not be tried. This is ' +
65
+ hint: 'ContextMint Bridge is down, so the browser path could not be tried. This is ' +
66
66
  'not a credential problem: OFW_USERNAME/OFW_PASSWORD, if set, were not reached ' +
67
67
  'either. See error.message for the extension-specific fix.',
68
68
  }
@@ -71,7 +71,7 @@ resolve = resolveAuth) {
71
71
  // Now means exactly what it says: nothing is set up. A configured path
72
72
  // that was tried and failed no longer lands here.
73
73
  no_credential: 'No OFW credential is configured. Either set OFW_USERNAME + OFW_PASSWORD, or install ' +
74
- 'the fetchproxy extension and sign in to ourfamilywizard.com in a tab (unsetting ' +
74
+ 'ContextMint Bridge and sign in to ourfamilywizard.com in a tab (unsetting ' +
75
75
  'OFW_DISABLE_FETCHPROXY if you set it).',
76
76
  credential_rejected: 'OurFamilyWizard rejected the credential. If it came from `env`, the password changed or ' +
77
77
  'the account is locked; if from `fetchproxy`, the browser session expired — sign in again ' +
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { jsonResponse } from './_shared.js';
2
+ import { jsonResponse, requestWrite, UnconfirmedWriteError, unconfirmedWriteResponse } from './_shared.js';
3
3
  import { offsetState, readUpstreamPaging, withPaginationFirst } from './pagination.js';
4
4
  import { getWriteMode } from '../config.js';
5
5
  export function registerJournalTools(server, client) {
@@ -45,7 +45,19 @@ export function registerJournalTools(server, client) {
45
45
  body: z.string().describe('Entry text content'),
46
46
  }),
47
47
  }, async (args) => {
48
- const data = await client.request('POST', '/pub/v1/journals', args);
48
+ let data;
49
+ try {
50
+ data = await requestWrite(client, 'POST', '/pub/v1/journals', args);
51
+ }
52
+ catch (e) {
53
+ if (!(e instanceof UnconfirmedWriteError))
54
+ throw e;
55
+ return unconfirmedWriteResponse(e, {
56
+ result: 'JOURNAL_UNCONFIRMED',
57
+ what: 'create this journal entry',
58
+ checkWith: 'ofw_list_journal_entries (look for this title among the newest entries)',
59
+ });
60
+ }
49
61
  return jsonResponse(data);
50
62
  });
51
63
  }