ofw-mcp 2.19.4 → 2.20.0

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,81 +1,588 @@
1
1
  import { z } from 'zod';
2
- import { jsonResponse, requestWrite, UnconfirmedWriteError, unconfirmedWriteResponse } from './_shared.js';
3
- import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
4
- import { offsetState, readUpstreamPaging, withPaginationFirst } from './pagination.js';
5
- import { getWriteMode } from '../config.js';
6
- export function registerExpenseTools(server, client) {
2
+ import { MAX_UPLOAD_BYTES } from './attachments.js';
3
+ import { jsonErrorResponse, jsonResponse, requestWrite, UnconfirmedWriteError, unconfirmedWriteResponse } from './_shared.js';
4
+ import { CONFIRM_NOTE, confirmTokenParam, confirmWrite, stateRevision } from './_confirm.js';
5
+ import { readUpstreamPaging } from './pagination.js';
6
+ import { getExpenseUploadOnly, getWriteMode } from '../config.js';
7
+ import { parseLenient } from '@chrischall/mcp-utils';
8
+ const UploadedExpenseFileSchema = z.looseObject({
9
+ fileId: z.number(),
10
+ fileName: z.string().optional(),
11
+ label: z.string().optional(),
12
+ fileType: z.string().optional(),
13
+ sizeInBytes: z.number().optional(),
14
+ shareClass: z.string().optional(),
15
+ });
16
+ const PDF_MIME = 'application/pdf';
17
+ const MAX_REMOTE_PDF_BYTES = MAX_UPLOAD_BYTES;
18
+ const MAX_REMOTE_REDIRECTS = 5;
19
+ const REMOTE_FETCH_TIMEOUT_MS = 30_000;
20
+ /** `%PDF-` — the one check that holds whatever the name or Content-Type claim. */
21
+ function isPdf(bytes) {
22
+ return bytes.byteLength >= 5
23
+ && bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46 && bytes[4] === 0x2d;
24
+ }
25
+ /**
26
+ * The remote-receipt allowlist, applied to EVERY hop. Following redirects
27
+ * automatically would let the first (allowlisted) hop hand the request to any
28
+ * host at all — including one on the machine's own network — so each Location
29
+ * is re-checked before it is fetched.
30
+ */
31
+ function assertReceiptUrl(url) {
32
+ if (url.protocol !== 'https:') {
33
+ throw new Error('Remote expense receipt URLs must use HTTPS.');
34
+ }
35
+ if (!url.hostname.toLowerCase().endsWith('.oaiusercontent.com')) {
36
+ throw new Error('Remote expense receipt URLs must be signed oaiusercontent.com file URLs.');
37
+ }
38
+ }
39
+ /**
40
+ * Read a response body, refusing as soon as it passes `cap` — a missing or
41
+ * understated Content-Length must not let the whole body into memory before
42
+ * the size check runs.
43
+ */
44
+ async function readCapped(response, cap) {
45
+ const tooLarge = () => new Error(`Expense receipt exceeds ${cap} bytes.`);
46
+ const declared = Number(response.headers.get('content-length') ?? 0);
47
+ if (Number.isFinite(declared) && declared > cap)
48
+ throw tooLarge();
49
+ const chunks = [];
50
+ let total = 0;
51
+ if (response.body) {
52
+ const reader = response.body.getReader();
53
+ for (;;) {
54
+ const { done, value } = await reader.read();
55
+ if (done)
56
+ break;
57
+ total += value.byteLength;
58
+ if (total > cap) {
59
+ await reader.cancel();
60
+ throw tooLarge();
61
+ }
62
+ chunks.push(value);
63
+ }
64
+ }
65
+ const bytes = new Uint8Array(total);
66
+ let offset = 0;
67
+ for (const chunk of chunks) {
68
+ bytes.set(chunk, offset);
69
+ offset += chunk.byteLength;
70
+ }
71
+ return bytes;
72
+ }
73
+ async function resolveRemotePdf(urlValue, fileNameValue) {
74
+ const fileName = fileNameValue?.trim() || 'receipt.pdf';
75
+ if (!fileName.toLowerCase().endsWith('.pdf')) {
76
+ throw new Error(`Expense receipts must use a .pdf filename; received ${fileName}`);
77
+ }
78
+ let url = new URL(urlValue);
79
+ let response;
80
+ for (let hop = 0;; hop++) {
81
+ assertReceiptUrl(url);
82
+ response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS) });
83
+ const location = response.headers.get('location');
84
+ if (response.status < 300 || response.status >= 400 || location === null)
85
+ break;
86
+ if (hop >= MAX_REMOTE_REDIRECTS) {
87
+ throw new Error(`Remote expense receipt redirected more than ${MAX_REMOTE_REDIRECTS} times.`);
88
+ }
89
+ url = new URL(location, url);
90
+ }
91
+ if (!response.ok) {
92
+ throw new Error(`Unable to fetch remote expense receipt: HTTP ${response.status}`);
93
+ }
94
+ const bytes = await readCapped(response, MAX_REMOTE_PDF_BYTES);
95
+ if (!isPdf(bytes)) {
96
+ throw new Error('Remote expense receipt is not a valid PDF file.');
97
+ }
98
+ return {
99
+ blob: new Blob([bytes], { type: PDF_MIME }),
100
+ fileName,
101
+ mimeType: PDF_MIME,
102
+ sizeBytes: bytes.byteLength,
103
+ };
104
+ }
105
+ /**
106
+ * Who a claim is against and what it is for, for a confirm preview. A person
107
+ * approving a money claim has to see who it is billed to; these are OFW ids
108
+ * this server has no names for, so they are labelled as ids rather than
109
+ * dressed up as names.
110
+ */
111
+ function expenseParties(args) {
112
+ return {
113
+ payerUserId: args.payerId,
114
+ categoryId: args.categoryId,
115
+ childUserIds: args.children,
116
+ idsNote: 'payerUserId is the parent this claim is billed to; confirm it names the right parent before approving (ofw_get_profile, where registered, or the OFW web app). categoryId is from ofw_list_expense_categories.',
117
+ };
118
+ }
119
+ const EXPENSE_FIELDS = [
120
+ 'title', 'amount', 'purchaseDate', 'categoryId', 'payerId', 'children', 'isPrivate', 'description', 'fileIds',
121
+ ];
122
+ /** The input argument a caller passes to supply each field by hand. */
123
+ const ARG_FOR = {
124
+ title: 'title', amount: 'amount', purchaseDate: 'purchaseDate', categoryId: 'categoryId', payerId: 'payerId',
125
+ children: 'children', isPrivate: 'privateExpense', description: 'description', fileIds: 'receiptFileId',
126
+ };
127
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
128
+ const positiveInt = (v) => typeof v === 'number' && Number.isInteger(v) && v > 0 ? v : undefined;
129
+ /** A user/file id given as a bare number or as `{userId}` / `{fileId}` / `{id}`. */
130
+ const refId = (v, key) => positiveInt(v) ?? (isRecord(v) ? positiveInt(v[key]) ?? positiveInt(v.id) : undefined);
131
+ const idList = (v, key) => {
132
+ if (!Array.isArray(v))
133
+ return undefined;
134
+ const ids = v.map((x) => refId(x, key));
135
+ return ids.every((id) => id !== undefined) ? ids : undefined;
136
+ };
137
+ /**
138
+ * Read an expense as OFW returned it into the fields a PUT needs — every
139
+ * field it can read, and ONLY those. A field whose key is absent, or whose
140
+ * value is a shape this does not recognise, is left out: "unknown" is never
141
+ * rounded to "empty", because merging an unknown field as empty is exactly
142
+ * the silent wipe this read exists to prevent. A key that is present and
143
+ * null/empty IS read, as "the expense has none".
144
+ *
145
+ * The detail shape has not been verified live (no account with an expense
146
+ * was available), so the reader accepts both the flat write vocabulary
147
+ * (`categoryId`, `payerId`, `fileIds`) and the nested read shapes OFW uses on
148
+ * other endpoints (`category.id`, `payer.userId`, `files[].fileId`,
149
+ * `{dateTime}`); anything else lands in "unknown" and the tool refuses.
150
+ */
151
+ export function readExpenseState(raw) {
152
+ const d = isRecord(raw) && isRecord(raw.data) ? raw.data : raw;
153
+ if (!isRecord(d))
154
+ return {};
155
+ const out = {};
156
+ if (typeof d.title === 'string' && d.title.trim())
157
+ out.title = d.title;
158
+ const amount = typeof d.amount === 'string' ? Number(d.amount) : d.amount;
159
+ if (typeof amount === 'number' && Number.isFinite(amount) && amount > 0)
160
+ out.amount = amount;
161
+ const dateRaw = isRecord(d.purchaseDate) ? d.purchaseDate.dateTime : d.purchaseDate;
162
+ if (typeof dateRaw === 'string' && /^\d{4}-\d{2}-\d{2}/.test(dateRaw))
163
+ out.purchaseDate = dateRaw.slice(0, 10);
164
+ const categoryId = positiveInt(d.categoryId) ?? (isRecord(d.category) ? positiveInt(d.category.id) : undefined);
165
+ if (categoryId !== undefined)
166
+ out.categoryId = categoryId;
167
+ const payerId = positiveInt(d.payerId) ?? refId(d.payer, 'userId');
168
+ if (payerId !== undefined)
169
+ out.payerId = payerId;
170
+ const children = idList(d.children, 'userId');
171
+ if (children !== undefined && children.length > 0)
172
+ out.children = children;
173
+ const isPrivate = typeof d.isPrivate === 'boolean' ? d.isPrivate : d.private;
174
+ if (typeof isPrivate === 'boolean')
175
+ out.isPrivate = isPrivate;
176
+ if ('description' in d) {
177
+ if (d.description === null || d.description === '')
178
+ out.description = null;
179
+ else if (typeof d.description === 'string')
180
+ out.description = d.description;
181
+ }
182
+ for (const key of ['fileIds', 'files']) {
183
+ if (!(key in d))
184
+ continue;
185
+ const ids = d[key] === null ? [] : idList(d[key], 'fileId');
186
+ if (ids !== undefined)
187
+ out.fileIds = ids;
188
+ break;
189
+ }
190
+ return out;
191
+ }
192
+ export function registerExpenseTools(server, client, attachmentIO) {
7
193
  // Expense writes land on the court-visible record — OFW_WRITE_MODE 'all' only.
8
- const allowWrites = getWriteMode() === 'all';
9
- server.registerTool('ofw_get_expense_totals', {
10
- description: 'Get OurFamilyWizard expense summary totals (owed/paid)',
11
- annotations: { readOnlyHint: true },
12
- }, async () => {
13
- const data = await client.request('GET', '/pub/v2/expense/expenses/totals');
14
- return jsonResponse(data);
15
- });
16
- server.registerTool('ofw_list_expenses', {
17
- description: 'List OurFamilyWizard expenses. Offset-paged via start/max. 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 expense total or an absence from one page.',
18
- annotations: { readOnlyHint: true },
19
- inputSchema: z.object({
20
- start: z.number().int().min(0).describe('Start offset, 0-based (default 0). To continue a listing, pass the `nextStart` from the previous response.').optional(),
21
- max: z.number().int().min(1).describe('Max results (default 20)').optional(),
22
- }),
23
- }, async (args) => {
24
- const start = args.start ?? 0;
25
- const max = args.max ?? 20;
26
- const data = await client.request('GET', `/pub/v2/expense/expenses?start=${start}&max=${max}`);
27
- // Paging state FIRST, records after — a partial read of a spilled response
28
- // must reach "there are more" before it reaches the records. See
29
- // src/tools/pagination.ts for why the order is load-bearing.
30
- //
31
- // OFW wraps these listings as {data, metadata} and its metadata carries a
32
- // `last` boolean, so "is there another page" is answered by the server
33
- // rather than inferred from a full page (verified live).
34
- const { returned, total, last } = readUpstreamPaging(data);
35
- const wrapped = withPaginationFirst({
36
- state: offsetState({ start, max, returned, total, last, base: 0 }),
37
- start, max, returned, total,
38
- hint: `Re-call ofw_list_expenses with start:${start + max}.`,
39
- payload: data,
194
+ const writeMode = getWriteMode();
195
+ const uploadOnly = getExpenseUploadOnly();
196
+ // Where a caller checks whether an ambiguous write landed. Upload-only mode
197
+ // does not register ofw_list_expenses, and naming a tool the caller cannot
198
+ // call leaves it guessing — a guessed retry logs a duplicate money claim in
199
+ // front of the co-parent. So that mode names the web app instead.
200
+ const checkIn = uploadOnly
201
+ ? 'the Expenses log on ourfamilywizard.com (ofw_list_expenses is not available in this deployment)'
202
+ : 'ofw_list_expenses';
203
+ const allowWrites = writeMode === 'all';
204
+ // The receipt is uploaded SHARED (the only share class OFW accepts in an
205
+ // expense's fileIds), and a SHARED My Files entry is co-parent-visible at
206
+ // once — so, like ofw_upload_attachment's SHARED path, it needs mode 'all'.
207
+ const allowReceiptUploads = allowWrites && attachmentIO !== undefined;
208
+ if (!uploadOnly)
209
+ server.registerTool('ofw_get_expense_totals', {
210
+ description: 'Get OurFamilyWizard expense summary totals (owed/paid)',
211
+ annotations: { readOnlyHint: true },
212
+ }, async () => {
213
+ const data = await client.request('GET', '/pub/v2/expense/expenses/totals');
214
+ return jsonResponse(data);
215
+ });
216
+ if (!uploadOnly)
217
+ server.registerTool('ofw_list_expense_categories', {
218
+ description: 'List OurFamilyWizard expense categories, including preset and custom categories with their responsibility split metadata. Read-only.',
219
+ annotations: { readOnlyHint: true },
220
+ }, async () => {
221
+ const data = await client.request('GET', '/pub/v2/expense/categories');
222
+ return jsonResponse(data);
223
+ });
224
+ if (!uploadOnly)
225
+ server.registerTool('ofw_list_expenses', {
226
+ description: 'List OurFamilyWizard expenses. OFW pages this endpoint with 1-based page/size parameters; its older start/max parameters are ignored and repeatedly return page 1. The response leads with hasMore and nextPage (null when exhausted) before the records. Continue by passing nextPage.',
227
+ annotations: { readOnlyHint: true },
228
+ inputSchema: z.object({
229
+ page: z.number().int().min(1).describe('1-based page number (default 1). To continue, pass the nextPage returned by the previous response.').optional(),
230
+ size: z.number().int().min(1).max(100).describe('Requested page size (default 20). OFW may cap or normalize this value.').optional(),
231
+ }),
232
+ }, async (args) => {
233
+ const page = args.page ?? 1;
234
+ const size = args.size ?? 20;
235
+ const data = await client.request('GET', `/pub/v2/expense/expenses?page=${page}&size=${size}`);
236
+ const { returned, total, last } = readUpstreamPaging(data);
237
+ const body = typeof data === 'object' && data !== null && !Array.isArray(data)
238
+ ? data
239
+ : null;
240
+ if (body === null)
241
+ return jsonResponse(data);
242
+ const metadata = typeof body.metadata === 'object' && body.metadata !== null && !Array.isArray(body.metadata)
243
+ ? body.metadata
244
+ : null;
245
+ const upstreamPage = metadata !== null && typeof metadata.currentPage === 'number'
246
+ ? metadata.currentPage
247
+ : metadata !== null && typeof metadata.page === 'number'
248
+ ? metadata.page
249
+ : page;
250
+ const upstreamSize = metadata !== null && typeof metadata.perPage === 'number'
251
+ ? metadata.perPage
252
+ : size;
253
+ const hasMore = last !== null
254
+ ? !last
255
+ : total !== null
256
+ ? upstreamPage * upstreamSize < total
257
+ : returned >= upstreamSize;
258
+ const nextPage = hasMore ? upstreamPage + 1 : null;
259
+ const scope = total !== null ? ` of ${total}` : '';
260
+ const head = {
261
+ hasMore,
262
+ nextPage,
263
+ page: upstreamPage,
264
+ size: upstreamSize,
265
+ returned,
266
+ ...(total !== null ? { total } : {}),
267
+ paginationNote: hasMore
268
+ ? `PARTIAL: this response holds ${returned} record(s) on page ${upstreamPage}${scope}. Re-call ofw_list_expenses with page:${nextPage}. Do not state a total or an absence from this response alone.`
269
+ : `This response reaches the end of the expense list${scope === '' ? '' : ` (${total} record(s) in total)`}.`,
270
+ };
271
+ return jsonResponse({ ...head, ...body, ...head });
272
+ });
273
+ if (allowReceiptUploads)
274
+ server.registerTool('ofw_upload_expense_pdf', {
275
+ 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 },
277
+ inputSchema: z.object({
278
+ 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
+ url: z.string().describe('Signed HTTPS oaiusercontent.com URL for a PDF supplied by the ChatGPT host. Mutually exclusive with path.').optional(),
280
+ fileName: z.string().describe('Filename to use for a remote URL upload. Must end in .pdf. Defaults to receipt.pdf.').optional(),
281
+ label: z.string().describe('Display label for the file in OFW (default: filename)').optional(),
282
+ description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
283
+ confirmToken: confirmTokenParam,
284
+ }),
285
+ }, async (args, ctx) => {
286
+ const io = attachmentIO;
287
+ if ((args.path ? 1 : 0) + (args.url ? 1 : 0) !== 1) {
288
+ throw new Error('Pass exactly one of path or url to ofw_upload_expense_pdf.');
289
+ }
290
+ const { blob, fileName, mimeType, sizeBytes } = args.url
291
+ ? await resolveRemotePdf(args.url, args.fileName)
292
+ : await io.resolveUpload(args.path);
293
+ if (!fileName.toLowerCase().endsWith('.pdf') || mimeType !== PDF_MIME) {
294
+ throw new Error(`Expense receipts must be PDF files; received ${fileName} (${mimeType})`);
295
+ }
296
+ const bytes = new Uint8Array(await blob.arrayBuffer());
297
+ // The name and type say PDF; the bytes have to agree. The remote path
298
+ // already checked, but a local file is only as honest as its extension.
299
+ if (!isPdf(bytes)) {
300
+ throw new Error(`Expense receipts must be PDF files; ${fileName} does not start with a PDF header.`);
301
+ }
302
+ // The upload is SHARED — in front of the co-parent in My Files at once,
303
+ // with no later step to review it in — so it is confirmed first, exactly
304
+ // like ofw_upload_attachment's SHARED path. The token binds a SHA-256 of
305
+ // the bytes: a file (or signed URL) whose content changed between preview
306
+ // and approval is refused, not shared unseen.
307
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
308
+ const sha256 = Array.from(digest, (b) => b.toString(16).padStart(2, '0')).join('');
309
+ const label = args.label ?? fileName;
310
+ const description = args.description ?? fileName;
311
+ const gate = await confirmWrite(ctx, {
312
+ tool: 'ofw_upload_expense_pdf',
313
+ action: 'ofw.file.share',
314
+ 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.`,
315
+ target: `file:${fileName}`,
316
+ payload: { fileName, sizeBytes, sha256, label, description, shareClass: 'SHARED', source: 'expense' },
317
+ preview: {
318
+ action: 'Upload and SHARE an expense receipt on OurFamilyWizard',
319
+ fileName, sizeBytes, mimeType, label, description, shareClass: 'SHARED',
320
+ from: args.url ? `hosted file (${new URL(args.url).hostname})` : 'local file',
321
+ warning: 'Visible to the co-parent immediately in My Files, whatever the visibility of the expense it is attached to; the file becomes part of the court-visible record.',
322
+ },
323
+ confirmToken: args.confirmToken,
324
+ });
325
+ if (gate)
326
+ return gate;
327
+ const form = new FormData();
328
+ form.append('file', blob, fileName);
329
+ form.append('source', 'expense');
330
+ form.append('description', args.description ?? fileName);
331
+ form.append('label', args.label ?? fileName);
332
+ form.append('fileName', fileName);
333
+ // Match the OFW expense form upload contract. The attachment itself is
334
+ // uploaded as SHARED so it is eligible for fileIds on an expense. Expense
335
+ // visibility is controlled separately by the expense's isPrivate flag.
336
+ form.append('shared', 'true');
337
+ form.append('shareClass', 'SHARED');
338
+ const meta = parseLenient(UploadedExpenseFileSchema, await client.request('POST', '/pub/v3/myfiles/multipart', form), { label: 'ofw-mcp', context: 'POST /pub/v3/myfiles/multipart (ofw_upload_expense_pdf)', mode: 'strict' });
339
+ return jsonResponse({
340
+ fileId: meta.fileId,
341
+ fileName: meta.fileName ?? fileName,
342
+ mimeType: meta.fileType ?? mimeType,
343
+ sizeBytes: meta.sizeInBytes ?? sizeBytes,
344
+ shareClass: meta.shareClass ?? 'SHARED',
345
+ note: 'Pass fileId to ofw_create_expense as receiptFileId. Expense visibility is controlled separately by privateExpense.',
346
+ });
347
+ });
348
+ if (allowWrites)
349
+ server.registerTool('ofw_update_expense', {
350
+ description: `Change an existing OurFamilyWizard expense. Pass expenseId plus ONLY the fields you want to change; every field you omit keeps its current value. The tool reads the expense from OFW first and sends the merged result, because OFW's update replaces the whole expense and a field left out would otherwise be erased. If a field you did not pass cannot be read back from OFW, the update is refused as EXPENSE_FIELDS_UNREADABLE and names the field; pass it explicitly (description:null or receiptFileId:null to send none). receiptFileId REPLACES all current receipts; omit it to keep them. Set privateExpense=false to publish a private expense to the co-parent. The confirmation is bound to the expense exactly as read, so if it changes on OFW in between, the update is refused instead of overwriting that change. If the request fails without a definitive answer the result is EXPENSE_UNCONFIRMED: the update may already have been applied, so check ${checkIn} before retrying. ` + CONFIRM_NOTE,
351
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
352
+ inputSchema: z.object({
353
+ expenseId: z.number().int().positive().describe('Existing OFW expense entity id'),
354
+ title: z.string().trim().min(1).describe('New title (omit to keep)').optional(),
355
+ amount: z.number().positive().describe('New full expense amount (omit to keep)').optional(),
356
+ purchaseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('New expense date, YYYY-MM-DD (omit to keep)').optional(),
357
+ categoryId: z.number().int().positive().describe('New OFW expense category id (omit to keep)').optional(),
358
+ payerId: z.number().int().positive().describe('New payer: the userId of the parent who owes (omit to keep)').optional(),
359
+ children: z.array(z.number().int().positive()).min(1).describe('New child userIds, replacing the current list (omit to keep)').optional(),
360
+ description: z.string().trim().min(1).nullable().describe('New description; null removes it (omit to keep)').optional(),
361
+ privateExpense: z.boolean().describe('true = visible only to you; false = shared with the co-parent (omit to keep)').optional(),
362
+ receiptFileId: z.number().int().positive().nullable().describe('Receipt fileId that REPLACES all current receipts; null removes them (omit to keep)').optional(),
363
+ confirmToken: confirmTokenParam,
364
+ }),
365
+ }, async (args, ctx) => {
366
+ const { expenseId, confirmToken } = args;
367
+ const path = `/pub/v2/expense/expenses/${expenseId}`;
368
+ // What the caller asked to change, in OFW's vocabulary. `null` is a
369
+ // deliberate "send none"; `undefined` is "keep what is there".
370
+ const supplied = {};
371
+ if (args.title !== undefined)
372
+ supplied.title = args.title;
373
+ if (args.amount !== undefined)
374
+ supplied.amount = args.amount;
375
+ if (args.purchaseDate !== undefined)
376
+ supplied.purchaseDate = args.purchaseDate;
377
+ if (args.categoryId !== undefined)
378
+ supplied.categoryId = args.categoryId;
379
+ if (args.payerId !== undefined)
380
+ supplied.payerId = args.payerId;
381
+ if (args.children !== undefined)
382
+ supplied.children = args.children;
383
+ if (args.privateExpense !== undefined)
384
+ supplied.isPrivate = args.privateExpense;
385
+ if (args.description !== undefined)
386
+ supplied.description = args.description;
387
+ if (args.receiptFileId !== undefined)
388
+ supplied.fileIds = args.receiptFileId === null ? [] : [args.receiptFileId];
389
+ if (Object.keys(supplied).length === 0) {
390
+ return jsonErrorResponse({
391
+ result: 'NO_CHANGES',
392
+ expenseId,
393
+ remedy: 'Pass the fields to change alongside expenseId (for example privateExpense:false to publish it). Nothing was sent.',
394
+ });
395
+ }
396
+ // Re-read on EVERY call (phase 1 and phase 2 alike): the merge base and
397
+ // the confirmation's revision both come from this read. A failed read is
398
+ // not a reason to guess — it leaves every omitted field unknown, and the
399
+ // refusal below names them.
400
+ let current = {};
401
+ let readError;
402
+ try {
403
+ const raw = await client.request('GET', path);
404
+ current = readExpenseState(parseLenient(z.looseObject({}), raw, { label: 'ofw-mcp', context: `GET ${path}` }));
405
+ }
406
+ catch (e) {
407
+ readError = e instanceof Error ? e.message : String(e);
408
+ }
409
+ const merged = { ...current, ...supplied };
410
+ const missing = EXPENSE_FIELDS.filter((f) => merged[f] === undefined);
411
+ if (missing.length > 0) {
412
+ return jsonErrorResponse({
413
+ result: 'EXPENSE_FIELDS_UNREADABLE',
414
+ expenseId,
415
+ missing: missing.map((f) => ARG_FOR[f]),
416
+ reason: readError !== undefined
417
+ ? `Could not read expense ${expenseId} from OFW (${readError}), so the fields you did not pass have no known value.`
418
+ : `OFW's copy of expense ${expenseId} did not include a readable value for these fields.`,
419
+ remedy: `OFW replaces the whole expense on update, so sending without these would erase them. Pass them explicitly (description:null or receiptFileId:null if the expense should have none). Nothing was sent.`,
420
+ });
421
+ }
422
+ const next = merged;
423
+ const payload = {
424
+ title: next.title,
425
+ amount: next.amount,
426
+ purchaseDate: next.purchaseDate,
427
+ categoryId: next.categoryId,
428
+ payerId: next.payerId,
429
+ children: next.children,
430
+ isPrivate: next.isPrivate,
431
+ };
432
+ // Same convention as create: no description / no receipt is an omitted
433
+ // key, the shape OFW's form is known to accept.
434
+ if (next.description !== null)
435
+ payload.description = next.description;
436
+ if (next.fileIds.length > 0)
437
+ payload.fileIds = next.fileIds;
438
+ const changes = {};
439
+ for (const f of Object.keys(supplied)) {
440
+ const from = current[f];
441
+ if (JSON.stringify(from) !== JSON.stringify(next[f])) {
442
+ changes[ARG_FOR[f]] = { from: from === undefined ? 'unknown (not readable from OFW)' : from, to: next[f] };
443
+ }
444
+ }
445
+ const baseRead = readError === undefined;
446
+ // Every supplied field already matches OFW: a full-replace PUT would only
447
+ // re-send what is there, through a confirmation that approves nothing.
448
+ // (With the read failed, every supplied field is a change from "unknown",
449
+ // so this never swallows a call it cannot compare.)
450
+ if (baseRead && Object.keys(changes).length === 0) {
451
+ return jsonErrorResponse({
452
+ result: 'NO_CHANGES',
453
+ expenseId,
454
+ remedy: 'Every field you passed already has that value on OFW, so there is nothing to update. Nothing was sent.',
455
+ });
456
+ }
457
+ const gate = await confirmWrite(ctx, {
458
+ tool: 'ofw_update_expense',
459
+ action: 'ofw.expense.update',
460
+ message: next.isPrivate
461
+ ? `Review and confirm this update to the private OurFamilyWizard expense "${next.title}".`
462
+ : `Review and confirm this update to the OurFamilyWizard expense "${next.title}". The expense will be SHARED: it appears in the ledger the co-parent sees immediately, and cannot be deleted through this server.`,
463
+ target: `expense:${expenseId}`,
464
+ ...(baseRead ? { revision: stateRevision(current) } : {}),
465
+ payload,
466
+ preview: {
467
+ action: next.isPrivate ? 'Update private OurFamilyWizard expense' : 'Update shared OurFamilyWizard expense',
468
+ expenseId,
469
+ changes,
470
+ after: {
471
+ title: next.title,
472
+ amount: next.amount,
473
+ purchaseDate: next.purchaseDate,
474
+ ...expenseParties(next),
475
+ description: next.description,
476
+ visibility: next.isPrivate ? 'private (only you)' : 'shared with the co-parent',
477
+ receiptFileIds: next.fileIds,
478
+ },
479
+ ...(baseRead ? {} : { baseNote: `The current expense could not be read from OFW (${readError}); every field comes from this call, and nothing is carried over.` }),
480
+ ...(next.isPrivate ? {} : { warning: 'Visible to the co-parent immediately as a claim in the shared expense ledger; part of the court-visible record.' }),
481
+ },
482
+ confirmToken,
483
+ });
484
+ if (gate)
485
+ return gate;
486
+ let data;
487
+ try {
488
+ data = await requestWrite(client, 'PUT', path, payload);
489
+ }
490
+ catch (e) {
491
+ if (!(e instanceof UnconfirmedWriteError))
492
+ throw e;
493
+ return unconfirmedWriteResponse(e, {
494
+ result: 'EXPENSE_UNCONFIRMED',
495
+ what: `update expense ${expenseId}`,
496
+ checkWith: `${checkIn} (compare this expense against the values you sent)`,
497
+ });
498
+ }
499
+ // Read it back: the PUT response is not the record. Any field OFW now
500
+ // reports differently from what was sent is surfaced, never assumed.
501
+ const warnings = [];
502
+ try {
503
+ const after = readExpenseState(await client.request('GET', path));
504
+ for (const f of EXPENSE_FIELDS) {
505
+ if (after[f] !== undefined && JSON.stringify(after[f]) !== JSON.stringify(next[f])) {
506
+ warnings.push(`OFW reports ${ARG_FOR[f]} as ${JSON.stringify(after[f])} after the update, not the ${JSON.stringify(next[f])} that was sent.`);
507
+ }
508
+ }
509
+ }
510
+ catch (e) {
511
+ warnings.push(`The update was accepted but could not be read back to verify it (${e instanceof Error ? e.message : String(e)}); check ${checkIn}.`);
512
+ }
513
+ return jsonResponse({
514
+ result: 'EXPENSE_UPDATED',
515
+ expenseId,
516
+ changed: Object.keys(changes),
517
+ kept: EXPENSE_FIELDS.filter((f) => supplied[f] === undefined).map((f) => ARG_FOR[f]),
518
+ ...(warnings.length > 0 ? { warnings } : {}),
519
+ response: data,
520
+ });
40
521
  });
41
- // A payload that is not a plain object cannot carry the paging keys at all.
42
- // Pass it through untouched rather than relocating it — an added field is
43
- // never worth changing a response's top-level shape.
44
- return jsonResponse(wrapped ?? data);
45
- });
46
522
  if (allowWrites)
47
523
  server.registerTool('ofw_create_expense', {
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
524
+ description: `Log a new expense in OurFamilyWizard using the current web-app expense contract. Required fields are title, amount, purchaseDate, categoryId, payerId (the parent who owes), and at least one child user id. Supports one previously-uploaded receipt PDF and private entries. privateExpense=true creates an expense visible only to you; false/default creates the normal shared expense. receiptFileId should come from ofw_upload_expense_pdf. A shared expense is a money claim that appears in the 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 ${checkIn} shows it did not land. ` + CONFIRM_NOTE,
525
+ // Not a harmless local write: a shared claim is co-parent-visible at once
526
+ // and this server has no way to take it back. destructiveHint keeps a host
51
527
  // that auto-approves "non-destructive" tools from running it silently.
52
528
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
53
529
  inputSchema: z.object({
54
- amount: z.number().describe('Expense amount'),
55
- description: z.string().describe('Expense description'),
530
+ title: z.string().trim().min(1).describe('Expense title/name shown in the OFW expense log'),
531
+ amount: z.number().positive().describe('Full expense amount before OFW applies the category split'),
532
+ purchaseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Date the expense was incurred, YYYY-MM-DD'),
533
+ categoryId: z.number().int().positive().describe('OFW expense category id (for example General is commonly id 1; use the id from OFW, not the category display name)'),
534
+ payerId: z.number().int().positive().describe('OFW userId of the parent who owes/reimburses this expense'),
535
+ children: z.array(z.number().int().positive()).min(1).describe('One or more OFW child userIds associated with the expense'),
536
+ description: z.string().trim().min(1).describe('Optional supporting description/details for the expense').optional(),
537
+ privateExpense: z.boolean().describe('true = visible only to you; false/default = shared with co-parent').optional(),
538
+ receiptFileId: z.number().int().positive().describe('Single OFW My Files fileId to attach as the receipt, normally returned by ofw_upload_expense_pdf').optional(),
56
539
  confirmToken: confirmTokenParam,
57
540
  }),
58
541
  }, async (args, ctx) => {
59
- const { confirmToken, ...payload } = args;
542
+ const payload = {
543
+ title: args.title,
544
+ amount: args.amount,
545
+ purchaseDate: args.purchaseDate,
546
+ categoryId: args.categoryId,
547
+ payerId: args.payerId,
548
+ children: args.children,
549
+ };
550
+ if (args.description !== undefined)
551
+ payload.description = args.description;
552
+ // OFW's web app sends isPrivate directly.
553
+ if (args.privateExpense !== undefined)
554
+ payload.isPrivate = args.privateExpense;
555
+ // OFW's web app sends attachments as a fileIds array. Keep the MCP-facing
556
+ // argument singular so callers still attach at most one canonical receipt.
557
+ if (args.receiptFileId !== undefined)
558
+ payload.fileIds = [args.receiptFileId];
559
+ const isPrivate = args.privateExpense === true;
60
560
  const gate = await confirmWrite(ctx, {
61
561
  tool: 'ofw_create_expense',
62
562
  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.',
563
+ message: isPrivate
564
+ ? 'Review and confirm this private OurFamilyWizard expense (visible only to you).'
565
+ : '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
566
  target: 'expense:new',
65
567
  payload,
66
568
  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.',
569
+ action: isPrivate ? 'Log private OurFamilyWizard expense' : 'Log OurFamilyWizard expense',
570
+ title: args.title,
571
+ amount: args.amount,
572
+ purchaseDate: args.purchaseDate,
573
+ ...expenseParties(args),
574
+ ...(args.description !== undefined ? { description: args.description } : {}),
575
+ visibility: isPrivate ? 'private (only you)' : 'shared with the co-parent',
576
+ ...(args.receiptFileId !== undefined ? { receiptFileId: args.receiptFileId } : {}),
577
+ ...(isPrivate ? {} : { warning: 'Visible to the co-parent immediately as a claim in the shared expense ledger; part of the court-visible record.' }),
71
578
  },
72
- confirmToken,
579
+ confirmToken: args.confirmToken,
73
580
  });
74
581
  if (gate)
75
582
  return gate;
76
583
  let data;
77
584
  try {
78
- data = await requestWrite(client, 'POST', '/pub/v2/expense/expenses', payload);
585
+ data = await requestWrite(client, 'POST', '/pub/v2/expense', payload);
79
586
  }
80
587
  catch (e) {
81
588
  if (!(e instanceof UnconfirmedWriteError))
@@ -83,7 +590,7 @@ export function registerExpenseTools(server, client) {
83
590
  return unconfirmedWriteResponse(e, {
84
591
  result: 'EXPENSE_UNCONFIRMED',
85
592
  what: 'log this expense',
86
- checkWith: 'ofw_list_expenses (look for this amount and description among the newest expenses)',
593
+ checkWith: `${checkIn} (look for this title and amount among the newest expenses)`,
87
594
  });
88
595
  }
89
596
  return jsonResponse(data);