ofw-mcp 2.19.2 → 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.
@@ -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
  }