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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -4
- package/dist/auth.js +4 -4
- package/dist/bundle.js +1199 -180
- package/dist/client.js +4 -1
- package/dist/config.js +37 -0
- package/dist/index.js +13 -9
- package/dist/tool-surface.js +14 -0
- package/dist/tools/_shared.js +5 -1
- package/dist/tools/delivery.js +42 -2
- package/dist/tools/expenses.js +563 -56
- package/dist/tools/healthcheck.js +2 -2
- package/dist/tools/messages.js +2 -1
- package/package.json +3 -3
- package/server.json +14 -2
- package/skills/ofw/SKILL.md +11 -4
- package/skills/ofw-fpx/SKILL.md +5 -2
- package/skills/ofw-fpx/references/requests.md +58 -4
package/dist/tools/expenses.js
CHANGED
|
@@ -1,81 +1,588 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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:
|
|
49
|
-
// Not a harmless local write:
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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:
|
|
593
|
+
checkWith: `${checkIn} (look for this title and amount among the newest expenses)`,
|
|
87
594
|
});
|
|
88
595
|
}
|
|
89
596
|
return jsonResponse(data);
|