ofw-mcp 2.19.3 → 2.19.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/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.4', // 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
  }
@@ -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
  }
@@ -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
  }