ofw-mcp 2.19.5 → 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/dist/bundle.js CHANGED
@@ -25787,11 +25787,11 @@ var rev2025Codec = {
25787
25787
  },
25788
25788
  encodeResult(method, result) {
25789
25789
  if (method !== "tools/list") return result;
25790
- const tools = result.tools;
25791
- if (!Array.isArray(tools) || !tools.some((t) => toolNeedsLegacyWrap(t))) return result;
25790
+ const tools2 = result.tools;
25791
+ if (!Array.isArray(tools2) || !tools2.some((t) => toolNeedsLegacyWrap(t))) return result;
25792
25792
  return {
25793
25793
  ...result,
25794
- tools: tools.map((t) => toolNeedsLegacyWrap(t) ? {
25794
+ tools: tools2.map((t) => toolNeedsLegacyWrap(t) ? {
25795
25795
  ...t,
25796
25796
  outputSchema: wrapOutputSchemaForLegacy(t.outputSchema)
25797
25797
  } : t)
@@ -27019,8 +27019,8 @@ function enforceDeletedFields(method, result) {
27019
27019
  }
27020
27020
  return next;
27021
27021
  };
27022
- const tools = result.tools;
27023
- if (method === "tools/list" && Array.isArray(tools) && tools.some((tool) => isPlainObject$4(tool) && "execution" in tool)) copy().tools = tools.map((tool) => {
27022
+ const tools2 = result.tools;
27023
+ if (method === "tools/list" && Array.isArray(tools2) && tools2.some((tool) => isPlainObject$4(tool) && "execution" in tool)) copy().tools = tools2.map((tool) => {
27024
27024
  if (!isPlainObject$4(tool) || !("execution" in tool)) return tool;
27025
27025
  const rest = { ...tool };
27026
27026
  delete rest["execution"];
@@ -45679,7 +45679,7 @@ async function loginWithPassword(username, password) {
45679
45679
  // package.json
45680
45680
  var package_default = {
45681
45681
  name: "ofw-mcp",
45682
- version: "2.19.5",
45682
+ version: "2.20.0",
45683
45683
  license: "MIT",
45684
45684
  mcpName: "io.github.chrischall/ofw-mcp",
45685
45685
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -45958,7 +45958,11 @@ var OFWClient = class {
45958
45958
  if (response.status === 429) throw new Error("Rate limited by OFW API");
45959
45959
  }
45960
45960
  if (!response.ok) {
45961
- throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
45961
+ const errorBody = await response.text();
45962
+ const safeBody = redactSecrets(errorBody).replace(/\s+/g, " ").trim().slice(0, 4e3);
45963
+ throw new Error(
45964
+ `OFW API error: ${response.status} ${response.statusText} for ${method} ${path}` + (safeBody ? ` \u2014 ${safeBody}` : "")
45965
+ );
45962
45966
  }
45963
45967
  return response;
45964
45968
  }
@@ -46348,11 +46352,12 @@ async function requestWrite(client2, method, path, body) {
46348
46352
  }
46349
46353
  }
46350
46354
  function unconfirmedWriteResponse(e, opts) {
46355
+ const fallback = /ourfamilywizard\.com/i.test(opts.checkWith) ? "" : " (or on ourfamilywizard.com)";
46351
46356
  return jsonErrorResponse({
46352
46357
  result: opts.result,
46353
46358
  mayHaveLanded: true,
46354
46359
  reason: `The request to ${opts.what} failed without a definitive answer from OFW: ${e.message}. It MAY HAVE BEEN APPLIED on OurFamilyWizard.`,
46355
- remedy: `Do NOT retry blindly \u2014 a second attempt can put a duplicate on the co-parent-visible record. First check with ${opts.checkWith} (or on ourfamilywizard.com), and retry only once you have confirmed it did not land.`
46360
+ remedy: `Do NOT retry blindly \u2014 a second attempt can put a duplicate on the co-parent-visible record. First check with ${opts.checkWith}${fallback}, and retry only once you have confirmed it did not land.`
46356
46361
  });
46357
46362
  }
46358
46363
  async function postMessageAndRefetch(client2, payload, detailSchema, ctx) {
@@ -46894,6 +46899,23 @@ function getUploadDir() {
46894
46899
  if (override && override.trim().length > 0) return override.trim();
46895
46900
  return getAttachmentsDir();
46896
46901
  }
46902
+ function restrictiveBoolEnv(name) {
46903
+ const raw = process.env[name];
46904
+ if (typeof raw !== "string" || raw.trim().length === 0) return false;
46905
+ const value = raw.trim().toLowerCase();
46906
+ if (["1", "true", "yes", "on"].includes(value)) return true;
46907
+ if (["0", "false", "no", "off"].includes(value)) return false;
46908
+ console.error(
46909
+ `[ofw-mcp] Unrecognized ${name} "${raw.trim()}" \u2014 failing closed to "true" (restriction enabled). Valid values: true, false.`
46910
+ );
46911
+ return true;
46912
+ }
46913
+ function getExpenseUploadOnly() {
46914
+ return restrictiveBoolEnv("OFW_EXPENSE_UPLOAD_ONLY");
46915
+ }
46916
+ function getExpenseOnly() {
46917
+ return getExpenseUploadOnly() || restrictiveBoolEnv("OFW_EXPENSE_ONLY");
46918
+ }
46897
46919
  function getWriteMode() {
46898
46920
  const raw = process.env.OFW_WRITE_MODE;
46899
46921
  if (typeof raw !== "string" || raw.trim().length === 0) return "all";
@@ -50349,75 +50371,474 @@ function registerCalendarTools(server, client2) {
50349
50371
  }
50350
50372
 
50351
50373
  // src/tools/expenses.ts
50352
- function registerExpenseTools(server, client2) {
50353
- const allowWrites = getWriteMode() === "all";
50354
- server.registerTool("ofw_get_expense_totals", {
50374
+ var UploadedExpenseFileSchema = external_exports.looseObject({
50375
+ fileId: external_exports.number(),
50376
+ fileName: external_exports.string().optional(),
50377
+ label: external_exports.string().optional(),
50378
+ fileType: external_exports.string().optional(),
50379
+ sizeInBytes: external_exports.number().optional(),
50380
+ shareClass: external_exports.string().optional()
50381
+ });
50382
+ var PDF_MIME = "application/pdf";
50383
+ var MAX_REMOTE_PDF_BYTES = MAX_UPLOAD_BYTES;
50384
+ var MAX_REMOTE_REDIRECTS = 5;
50385
+ var REMOTE_FETCH_TIMEOUT_MS = 3e4;
50386
+ function isPdf(bytes) {
50387
+ return bytes.byteLength >= 5 && bytes[0] === 37 && bytes[1] === 80 && bytes[2] === 68 && bytes[3] === 70 && bytes[4] === 45;
50388
+ }
50389
+ function assertReceiptUrl(url2) {
50390
+ if (url2.protocol !== "https:") {
50391
+ throw new Error("Remote expense receipt URLs must use HTTPS.");
50392
+ }
50393
+ if (!url2.hostname.toLowerCase().endsWith(".oaiusercontent.com")) {
50394
+ throw new Error("Remote expense receipt URLs must be signed oaiusercontent.com file URLs.");
50395
+ }
50396
+ }
50397
+ async function readCapped(response, cap) {
50398
+ const tooLarge = () => new Error(`Expense receipt exceeds ${cap} bytes.`);
50399
+ const declared = Number(response.headers.get("content-length") ?? 0);
50400
+ if (Number.isFinite(declared) && declared > cap) throw tooLarge();
50401
+ const chunks = [];
50402
+ let total = 0;
50403
+ if (response.body) {
50404
+ const reader = response.body.getReader();
50405
+ for (; ; ) {
50406
+ const { done, value } = await reader.read();
50407
+ if (done) break;
50408
+ total += value.byteLength;
50409
+ if (total > cap) {
50410
+ await reader.cancel();
50411
+ throw tooLarge();
50412
+ }
50413
+ chunks.push(value);
50414
+ }
50415
+ }
50416
+ const bytes = new Uint8Array(total);
50417
+ let offset = 0;
50418
+ for (const chunk2 of chunks) {
50419
+ bytes.set(chunk2, offset);
50420
+ offset += chunk2.byteLength;
50421
+ }
50422
+ return bytes;
50423
+ }
50424
+ async function resolveRemotePdf(urlValue, fileNameValue) {
50425
+ const fileName = fileNameValue?.trim() || "receipt.pdf";
50426
+ if (!fileName.toLowerCase().endsWith(".pdf")) {
50427
+ throw new Error(`Expense receipts must use a .pdf filename; received ${fileName}`);
50428
+ }
50429
+ let url2 = new URL(urlValue);
50430
+ let response;
50431
+ for (let hop = 0; ; hop++) {
50432
+ assertReceiptUrl(url2);
50433
+ response = await fetch(url2, { redirect: "manual", signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS) });
50434
+ const location = response.headers.get("location");
50435
+ if (response.status < 300 || response.status >= 400 || location === null) break;
50436
+ if (hop >= MAX_REMOTE_REDIRECTS) {
50437
+ throw new Error(`Remote expense receipt redirected more than ${MAX_REMOTE_REDIRECTS} times.`);
50438
+ }
50439
+ url2 = new URL(location, url2);
50440
+ }
50441
+ if (!response.ok) {
50442
+ throw new Error(`Unable to fetch remote expense receipt: HTTP ${response.status}`);
50443
+ }
50444
+ const bytes = await readCapped(response, MAX_REMOTE_PDF_BYTES);
50445
+ if (!isPdf(bytes)) {
50446
+ throw new Error("Remote expense receipt is not a valid PDF file.");
50447
+ }
50448
+ return {
50449
+ blob: new Blob([bytes], { type: PDF_MIME }),
50450
+ fileName,
50451
+ mimeType: PDF_MIME,
50452
+ sizeBytes: bytes.byteLength
50453
+ };
50454
+ }
50455
+ function expenseParties(args) {
50456
+ return {
50457
+ payerUserId: args.payerId,
50458
+ categoryId: args.categoryId,
50459
+ childUserIds: args.children,
50460
+ 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."
50461
+ };
50462
+ }
50463
+ var EXPENSE_FIELDS = [
50464
+ "title",
50465
+ "amount",
50466
+ "purchaseDate",
50467
+ "categoryId",
50468
+ "payerId",
50469
+ "children",
50470
+ "isPrivate",
50471
+ "description",
50472
+ "fileIds"
50473
+ ];
50474
+ var ARG_FOR = {
50475
+ title: "title",
50476
+ amount: "amount",
50477
+ purchaseDate: "purchaseDate",
50478
+ categoryId: "categoryId",
50479
+ payerId: "payerId",
50480
+ children: "children",
50481
+ isPrivate: "privateExpense",
50482
+ description: "description",
50483
+ fileIds: "receiptFileId"
50484
+ };
50485
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
50486
+ var positiveInt = (v) => typeof v === "number" && Number.isInteger(v) && v > 0 ? v : void 0;
50487
+ var refId = (v, key) => positiveInt(v) ?? (isRecord2(v) ? positiveInt(v[key]) ?? positiveInt(v.id) : void 0);
50488
+ var idList = (v, key) => {
50489
+ if (!Array.isArray(v)) return void 0;
50490
+ const ids = v.map((x) => refId(x, key));
50491
+ return ids.every((id) => id !== void 0) ? ids : void 0;
50492
+ };
50493
+ function readExpenseState(raw) {
50494
+ const d = isRecord2(raw) && isRecord2(raw.data) ? raw.data : raw;
50495
+ if (!isRecord2(d)) return {};
50496
+ const out = {};
50497
+ if (typeof d.title === "string" && d.title.trim()) out.title = d.title;
50498
+ const amount = typeof d.amount === "string" ? Number(d.amount) : d.amount;
50499
+ if (typeof amount === "number" && Number.isFinite(amount) && amount > 0) out.amount = amount;
50500
+ const dateRaw = isRecord2(d.purchaseDate) ? d.purchaseDate.dateTime : d.purchaseDate;
50501
+ if (typeof dateRaw === "string" && /^\d{4}-\d{2}-\d{2}/.test(dateRaw)) out.purchaseDate = dateRaw.slice(0, 10);
50502
+ const categoryId = positiveInt(d.categoryId) ?? (isRecord2(d.category) ? positiveInt(d.category.id) : void 0);
50503
+ if (categoryId !== void 0) out.categoryId = categoryId;
50504
+ const payerId = positiveInt(d.payerId) ?? refId(d.payer, "userId");
50505
+ if (payerId !== void 0) out.payerId = payerId;
50506
+ const children = idList(d.children, "userId");
50507
+ if (children !== void 0 && children.length > 0) out.children = children;
50508
+ const isPrivate = typeof d.isPrivate === "boolean" ? d.isPrivate : d.private;
50509
+ if (typeof isPrivate === "boolean") out.isPrivate = isPrivate;
50510
+ if ("description" in d) {
50511
+ if (d.description === null || d.description === "") out.description = null;
50512
+ else if (typeof d.description === "string") out.description = d.description;
50513
+ }
50514
+ for (const key of ["fileIds", "files"]) {
50515
+ if (!(key in d)) continue;
50516
+ const ids = d[key] === null ? [] : idList(d[key], "fileId");
50517
+ if (ids !== void 0) out.fileIds = ids;
50518
+ break;
50519
+ }
50520
+ return out;
50521
+ }
50522
+ function registerExpenseTools(server, client2, attachmentIO) {
50523
+ const writeMode = getWriteMode();
50524
+ const uploadOnly = getExpenseUploadOnly();
50525
+ const checkIn = uploadOnly ? "the Expenses log on ourfamilywizard.com (ofw_list_expenses is not available in this deployment)" : "ofw_list_expenses";
50526
+ const allowWrites = writeMode === "all";
50527
+ const allowReceiptUploads = allowWrites && attachmentIO !== void 0;
50528
+ if (!uploadOnly) server.registerTool("ofw_get_expense_totals", {
50355
50529
  description: "Get OurFamilyWizard expense summary totals (owed/paid)",
50356
50530
  annotations: { readOnlyHint: true }
50357
50531
  }, async () => {
50358
50532
  const data = await client2.request("GET", "/pub/v2/expense/expenses/totals");
50359
50533
  return jsonResponse(data);
50360
50534
  });
50361
- server.registerTool("ofw_list_expenses", {
50362
- description: "List OurFamilyWizard expenses. Offset-paged via start/max. The response leads with its paging state \u2014 `hasMore` and `nextStart` (null when the list is exhausted) \u2014 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.",
50535
+ if (!uploadOnly) server.registerTool("ofw_list_expense_categories", {
50536
+ description: "List OurFamilyWizard expense categories, including preset and custom categories with their responsibility split metadata. Read-only.",
50537
+ annotations: { readOnlyHint: true }
50538
+ }, async () => {
50539
+ const data = await client2.request("GET", "/pub/v2/expense/categories");
50540
+ return jsonResponse(data);
50541
+ });
50542
+ if (!uploadOnly) server.registerTool("ofw_list_expenses", {
50543
+ 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.",
50363
50544
  annotations: { readOnlyHint: true },
50364
50545
  inputSchema: external_exports.object({
50365
- start: external_exports.number().int().min(0).describe("Start offset, 0-based (default 0). To continue a listing, pass the `nextStart` from the previous response.").optional(),
50366
- max: external_exports.number().int().min(1).describe("Max results (default 20)").optional()
50546
+ page: external_exports.number().int().min(1).describe("1-based page number (default 1). To continue, pass the nextPage returned by the previous response.").optional(),
50547
+ size: external_exports.number().int().min(1).max(100).describe("Requested page size (default 20). OFW may cap or normalize this value.").optional()
50367
50548
  })
50368
50549
  }, async (args) => {
50369
- const start = args.start ?? 0;
50370
- const max = args.max ?? 20;
50371
- const data = await client2.request("GET", `/pub/v2/expense/expenses?start=${start}&max=${max}`);
50550
+ const page = args.page ?? 1;
50551
+ const size = args.size ?? 20;
50552
+ const data = await client2.request("GET", `/pub/v2/expense/expenses?page=${page}&size=${size}`);
50372
50553
  const { returned, total, last } = readUpstreamPaging(data);
50373
- const wrapped = withPaginationFirst({
50374
- state: offsetState({ start, max, returned, total, last, base: 0 }),
50375
- start,
50376
- max,
50554
+ const body = typeof data === "object" && data !== null && !Array.isArray(data) ? data : null;
50555
+ if (body === null) return jsonResponse(data);
50556
+ const metadata = typeof body.metadata === "object" && body.metadata !== null && !Array.isArray(body.metadata) ? body.metadata : null;
50557
+ const upstreamPage = metadata !== null && typeof metadata.currentPage === "number" ? metadata.currentPage : metadata !== null && typeof metadata.page === "number" ? metadata.page : page;
50558
+ const upstreamSize = metadata !== null && typeof metadata.perPage === "number" ? metadata.perPage : size;
50559
+ const hasMore = last !== null ? !last : total !== null ? upstreamPage * upstreamSize < total : returned >= upstreamSize;
50560
+ const nextPage = hasMore ? upstreamPage + 1 : null;
50561
+ const scope = total !== null ? ` of ${total}` : "";
50562
+ const head = {
50563
+ hasMore,
50564
+ nextPage,
50565
+ page: upstreamPage,
50566
+ size: upstreamSize,
50377
50567
  returned,
50378
- total,
50379
- hint: `Re-call ofw_list_expenses with start:${start + max}.`,
50380
- payload: data
50568
+ ...total !== null ? { total } : {},
50569
+ paginationNote: hasMore ? `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.` : `This response reaches the end of the expense list${scope === "" ? "" : ` (${total} record(s) in total)`}.`
50570
+ };
50571
+ return jsonResponse({ ...head, ...body, ...head });
50572
+ });
50573
+ if (allowReceiptUploads) server.registerTool("ofw_upload_expense_pdf", {
50574
+ 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,
50575
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
50576
+ inputSchema: external_exports.object({
50577
+ path: external_exports.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(),
50578
+ url: external_exports.string().describe("Signed HTTPS oaiusercontent.com URL for a PDF supplied by the ChatGPT host. Mutually exclusive with path.").optional(),
50579
+ fileName: external_exports.string().describe("Filename to use for a remote URL upload. Must end in .pdf. Defaults to receipt.pdf.").optional(),
50580
+ label: external_exports.string().describe("Display label for the file in OFW (default: filename)").optional(),
50581
+ description: external_exports.string().describe("Description shown in OFW My Files (default: filename)").optional(),
50582
+ confirmToken: confirmTokenParam
50583
+ })
50584
+ }, async (args, ctx) => {
50585
+ const io = attachmentIO;
50586
+ if ((args.path ? 1 : 0) + (args.url ? 1 : 0) !== 1) {
50587
+ throw new Error("Pass exactly one of path or url to ofw_upload_expense_pdf.");
50588
+ }
50589
+ const { blob, fileName, mimeType, sizeBytes } = args.url ? await resolveRemotePdf(args.url, args.fileName) : await io.resolveUpload(args.path);
50590
+ if (!fileName.toLowerCase().endsWith(".pdf") || mimeType !== PDF_MIME) {
50591
+ throw new Error(`Expense receipts must be PDF files; received ${fileName} (${mimeType})`);
50592
+ }
50593
+ const bytes = new Uint8Array(await blob.arrayBuffer());
50594
+ if (!isPdf(bytes)) {
50595
+ throw new Error(`Expense receipts must be PDF files; ${fileName} does not start with a PDF header.`);
50596
+ }
50597
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
50598
+ const sha2562 = Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("");
50599
+ const label = args.label ?? fileName;
50600
+ const description = args.description ?? fileName;
50601
+ const gate = await confirmWrite(ctx, {
50602
+ tool: "ofw_upload_expense_pdf",
50603
+ action: "ofw.file.share",
50604
+ 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 \u2014 even if the expense it is attached to is private.`,
50605
+ target: `file:${fileName}`,
50606
+ payload: { fileName, sizeBytes, sha256: sha2562, label, description, shareClass: "SHARED", source: "expense" },
50607
+ preview: {
50608
+ action: "Upload and SHARE an expense receipt on OurFamilyWizard",
50609
+ fileName,
50610
+ sizeBytes,
50611
+ mimeType,
50612
+ label,
50613
+ description,
50614
+ shareClass: "SHARED",
50615
+ from: args.url ? `hosted file (${new URL(args.url).hostname})` : "local file",
50616
+ 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."
50617
+ },
50618
+ confirmToken: args.confirmToken
50619
+ });
50620
+ if (gate) return gate;
50621
+ const form = new FormData();
50622
+ form.append("file", blob, fileName);
50623
+ form.append("source", "expense");
50624
+ form.append("description", args.description ?? fileName);
50625
+ form.append("label", args.label ?? fileName);
50626
+ form.append("fileName", fileName);
50627
+ form.append("shared", "true");
50628
+ form.append("shareClass", "SHARED");
50629
+ const meta3 = parseLenient(
50630
+ UploadedExpenseFileSchema,
50631
+ await client2.request("POST", "/pub/v3/myfiles/multipart", form),
50632
+ { label: "ofw-mcp", context: "POST /pub/v3/myfiles/multipart (ofw_upload_expense_pdf)", mode: "strict" }
50633
+ );
50634
+ return jsonResponse({
50635
+ fileId: meta3.fileId,
50636
+ fileName: meta3.fileName ?? fileName,
50637
+ mimeType: meta3.fileType ?? mimeType,
50638
+ sizeBytes: meta3.sizeInBytes ?? sizeBytes,
50639
+ shareClass: meta3.shareClass ?? "SHARED",
50640
+ note: "Pass fileId to ofw_create_expense as receiptFileId. Expense visibility is controlled separately by privateExpense."
50641
+ });
50642
+ });
50643
+ if (allowWrites) server.registerTool("ofw_update_expense", {
50644
+ 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,
50645
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50646
+ inputSchema: external_exports.object({
50647
+ expenseId: external_exports.number().int().positive().describe("Existing OFW expense entity id"),
50648
+ title: external_exports.string().trim().min(1).describe("New title (omit to keep)").optional(),
50649
+ amount: external_exports.number().positive().describe("New full expense amount (omit to keep)").optional(),
50650
+ purchaseDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("New expense date, YYYY-MM-DD (omit to keep)").optional(),
50651
+ categoryId: external_exports.number().int().positive().describe("New OFW expense category id (omit to keep)").optional(),
50652
+ payerId: external_exports.number().int().positive().describe("New payer: the userId of the parent who owes (omit to keep)").optional(),
50653
+ children: external_exports.array(external_exports.number().int().positive()).min(1).describe("New child userIds, replacing the current list (omit to keep)").optional(),
50654
+ description: external_exports.string().trim().min(1).nullable().describe("New description; null removes it (omit to keep)").optional(),
50655
+ privateExpense: external_exports.boolean().describe("true = visible only to you; false = shared with the co-parent (omit to keep)").optional(),
50656
+ receiptFileId: external_exports.number().int().positive().nullable().describe("Receipt fileId that REPLACES all current receipts; null removes them (omit to keep)").optional(),
50657
+ confirmToken: confirmTokenParam
50658
+ })
50659
+ }, async (args, ctx) => {
50660
+ const { expenseId, confirmToken } = args;
50661
+ const path = `/pub/v2/expense/expenses/${expenseId}`;
50662
+ const supplied = {};
50663
+ if (args.title !== void 0) supplied.title = args.title;
50664
+ if (args.amount !== void 0) supplied.amount = args.amount;
50665
+ if (args.purchaseDate !== void 0) supplied.purchaseDate = args.purchaseDate;
50666
+ if (args.categoryId !== void 0) supplied.categoryId = args.categoryId;
50667
+ if (args.payerId !== void 0) supplied.payerId = args.payerId;
50668
+ if (args.children !== void 0) supplied.children = args.children;
50669
+ if (args.privateExpense !== void 0) supplied.isPrivate = args.privateExpense;
50670
+ if (args.description !== void 0) supplied.description = args.description;
50671
+ if (args.receiptFileId !== void 0) supplied.fileIds = args.receiptFileId === null ? [] : [args.receiptFileId];
50672
+ if (Object.keys(supplied).length === 0) {
50673
+ return jsonErrorResponse({
50674
+ result: "NO_CHANGES",
50675
+ expenseId,
50676
+ remedy: "Pass the fields to change alongside expenseId (for example privateExpense:false to publish it). Nothing was sent."
50677
+ });
50678
+ }
50679
+ let current = {};
50680
+ let readError;
50681
+ try {
50682
+ const raw = await client2.request("GET", path);
50683
+ current = readExpenseState(parseLenient(external_exports.looseObject({}), raw, { label: "ofw-mcp", context: `GET ${path}` }));
50684
+ } catch (e) {
50685
+ readError = e instanceof Error ? e.message : String(e);
50686
+ }
50687
+ const merged = { ...current, ...supplied };
50688
+ const missing = EXPENSE_FIELDS.filter((f) => merged[f] === void 0);
50689
+ if (missing.length > 0) {
50690
+ return jsonErrorResponse({
50691
+ result: "EXPENSE_FIELDS_UNREADABLE",
50692
+ expenseId,
50693
+ missing: missing.map((f) => ARG_FOR[f]),
50694
+ reason: readError !== void 0 ? `Could not read expense ${expenseId} from OFW (${readError}), so the fields you did not pass have no known value.` : `OFW's copy of expense ${expenseId} did not include a readable value for these fields.`,
50695
+ 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.`
50696
+ });
50697
+ }
50698
+ const next = merged;
50699
+ const payload = {
50700
+ title: next.title,
50701
+ amount: next.amount,
50702
+ purchaseDate: next.purchaseDate,
50703
+ categoryId: next.categoryId,
50704
+ payerId: next.payerId,
50705
+ children: next.children,
50706
+ isPrivate: next.isPrivate
50707
+ };
50708
+ if (next.description !== null) payload.description = next.description;
50709
+ if (next.fileIds.length > 0) payload.fileIds = next.fileIds;
50710
+ const changes = {};
50711
+ for (const f of Object.keys(supplied)) {
50712
+ const from = current[f];
50713
+ if (JSON.stringify(from) !== JSON.stringify(next[f])) {
50714
+ changes[ARG_FOR[f]] = { from: from === void 0 ? "unknown (not readable from OFW)" : from, to: next[f] };
50715
+ }
50716
+ }
50717
+ const baseRead = readError === void 0;
50718
+ if (baseRead && Object.keys(changes).length === 0) {
50719
+ return jsonErrorResponse({
50720
+ result: "NO_CHANGES",
50721
+ expenseId,
50722
+ remedy: "Every field you passed already has that value on OFW, so there is nothing to update. Nothing was sent."
50723
+ });
50724
+ }
50725
+ const gate = await confirmWrite(ctx, {
50726
+ tool: "ofw_update_expense",
50727
+ action: "ofw.expense.update",
50728
+ message: next.isPrivate ? `Review and confirm this update to the private OurFamilyWizard expense "${next.title}".` : `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.`,
50729
+ target: `expense:${expenseId}`,
50730
+ ...baseRead ? { revision: stateRevision(current) } : {},
50731
+ payload,
50732
+ preview: {
50733
+ action: next.isPrivate ? "Update private OurFamilyWizard expense" : "Update shared OurFamilyWizard expense",
50734
+ expenseId,
50735
+ changes,
50736
+ after: {
50737
+ title: next.title,
50738
+ amount: next.amount,
50739
+ purchaseDate: next.purchaseDate,
50740
+ ...expenseParties(next),
50741
+ description: next.description,
50742
+ visibility: next.isPrivate ? "private (only you)" : "shared with the co-parent",
50743
+ receiptFileIds: next.fileIds
50744
+ },
50745
+ ...baseRead ? {} : { baseNote: `The current expense could not be read from OFW (${readError}); every field comes from this call, and nothing is carried over.` },
50746
+ ...next.isPrivate ? {} : { warning: "Visible to the co-parent immediately as a claim in the shared expense ledger; part of the court-visible record." }
50747
+ },
50748
+ confirmToken
50749
+ });
50750
+ if (gate) return gate;
50751
+ let data;
50752
+ try {
50753
+ data = await requestWrite(client2, "PUT", path, payload);
50754
+ } catch (e) {
50755
+ if (!(e instanceof UnconfirmedWriteError)) throw e;
50756
+ return unconfirmedWriteResponse(e, {
50757
+ result: "EXPENSE_UNCONFIRMED",
50758
+ what: `update expense ${expenseId}`,
50759
+ checkWith: `${checkIn} (compare this expense against the values you sent)`
50760
+ });
50761
+ }
50762
+ const warnings = [];
50763
+ try {
50764
+ const after = readExpenseState(await client2.request("GET", path));
50765
+ for (const f of EXPENSE_FIELDS) {
50766
+ if (after[f] !== void 0 && JSON.stringify(after[f]) !== JSON.stringify(next[f])) {
50767
+ warnings.push(`OFW reports ${ARG_FOR[f]} as ${JSON.stringify(after[f])} after the update, not the ${JSON.stringify(next[f])} that was sent.`);
50768
+ }
50769
+ }
50770
+ } catch (e) {
50771
+ warnings.push(`The update was accepted but could not be read back to verify it (${e instanceof Error ? e.message : String(e)}); check ${checkIn}.`);
50772
+ }
50773
+ return jsonResponse({
50774
+ result: "EXPENSE_UPDATED",
50775
+ expenseId,
50776
+ changed: Object.keys(changes),
50777
+ kept: EXPENSE_FIELDS.filter((f) => supplied[f] === void 0).map((f) => ARG_FOR[f]),
50778
+ ...warnings.length > 0 ? { warnings } : {},
50779
+ response: data
50381
50780
  });
50382
- return jsonResponse(wrapped ?? data);
50383
50781
  });
50384
50782
  if (allowWrites) server.registerTool("ofw_create_expense", {
50385
- 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,
50386
- // Not a harmless local write: the claim is co-parent-visible at once and
50387
- // this server has no way to take it back. destructiveHint keeps a host
50783
+ 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,
50784
+ // Not a harmless local write: a shared claim is co-parent-visible at once
50785
+ // and this server has no way to take it back. destructiveHint keeps a host
50388
50786
  // that auto-approves "non-destructive" tools from running it silently.
50389
50787
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50390
50788
  inputSchema: external_exports.object({
50391
- amount: external_exports.number().describe("Expense amount"),
50392
- description: external_exports.string().describe("Expense description"),
50789
+ title: external_exports.string().trim().min(1).describe("Expense title/name shown in the OFW expense log"),
50790
+ amount: external_exports.number().positive().describe("Full expense amount before OFW applies the category split"),
50791
+ purchaseDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date the expense was incurred, YYYY-MM-DD"),
50792
+ categoryId: external_exports.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)"),
50793
+ payerId: external_exports.number().int().positive().describe("OFW userId of the parent who owes/reimburses this expense"),
50794
+ children: external_exports.array(external_exports.number().int().positive()).min(1).describe("One or more OFW child userIds associated with the expense"),
50795
+ description: external_exports.string().trim().min(1).describe("Optional supporting description/details for the expense").optional(),
50796
+ privateExpense: external_exports.boolean().describe("true = visible only to you; false/default = shared with co-parent").optional(),
50797
+ receiptFileId: external_exports.number().int().positive().describe("Single OFW My Files fileId to attach as the receipt, normally returned by ofw_upload_expense_pdf").optional(),
50393
50798
  confirmToken: confirmTokenParam
50394
50799
  })
50395
50800
  }, async (args, ctx) => {
50396
- const { confirmToken, ...payload } = args;
50801
+ const payload = {
50802
+ title: args.title,
50803
+ amount: args.amount,
50804
+ purchaseDate: args.purchaseDate,
50805
+ categoryId: args.categoryId,
50806
+ payerId: args.payerId,
50807
+ children: args.children
50808
+ };
50809
+ if (args.description !== void 0) payload.description = args.description;
50810
+ if (args.privateExpense !== void 0) payload.isPrivate = args.privateExpense;
50811
+ if (args.receiptFileId !== void 0) payload.fileIds = [args.receiptFileId];
50812
+ const isPrivate = args.privateExpense === true;
50397
50813
  const gate = await confirmWrite(ctx, {
50398
50814
  tool: "ofw_create_expense",
50399
50815
  action: "ofw.expense.create",
50400
- 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.",
50816
+ message: isPrivate ? "Review and confirm this private OurFamilyWizard expense (visible only to you)." : "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.",
50401
50817
  target: "expense:new",
50402
50818
  payload,
50403
50819
  preview: {
50404
- action: "Log OurFamilyWizard expense",
50405
- amount: payload.amount,
50406
- description: payload.description,
50407
- warning: "Visible to the co-parent immediately as a claim in the shared expense ledger; part of the court-visible record."
50820
+ action: isPrivate ? "Log private OurFamilyWizard expense" : "Log OurFamilyWizard expense",
50821
+ title: args.title,
50822
+ amount: args.amount,
50823
+ purchaseDate: args.purchaseDate,
50824
+ ...expenseParties(args),
50825
+ ...args.description !== void 0 ? { description: args.description } : {},
50826
+ visibility: isPrivate ? "private (only you)" : "shared with the co-parent",
50827
+ ...args.receiptFileId !== void 0 ? { receiptFileId: args.receiptFileId } : {},
50828
+ ...isPrivate ? {} : { warning: "Visible to the co-parent immediately as a claim in the shared expense ledger; part of the court-visible record." }
50408
50829
  },
50409
- confirmToken
50830
+ confirmToken: args.confirmToken
50410
50831
  });
50411
50832
  if (gate) return gate;
50412
50833
  let data;
50413
50834
  try {
50414
- data = await requestWrite(client2, "POST", "/pub/v2/expense/expenses", payload);
50835
+ data = await requestWrite(client2, "POST", "/pub/v2/expense", payload);
50415
50836
  } catch (e) {
50416
50837
  if (!(e instanceof UnconfirmedWriteError)) throw e;
50417
50838
  return unconfirmedWriteResponse(e, {
50418
50839
  result: "EXPENSE_UNCONFIRMED",
50419
50840
  what: "log this expense",
50420
- checkWith: "ofw_list_expenses (look for this amount and description among the newest expenses)"
50841
+ checkWith: `${checkIn} (look for this title and amount among the newest expenses)`
50421
50842
  });
50422
50843
  }
50423
50844
  return jsonResponse(data);
@@ -51088,6 +51509,12 @@ var OFWCache = class _OFWCache extends LocalCacheStore {
51088
51509
  }
51089
51510
  };
51090
51511
 
51512
+ // src/tool-surface.ts
51513
+ function selectToolRegistrars(r) {
51514
+ if (getExpenseOnly()) return [r.healthcheck, r.expenses];
51515
+ return [r.healthcheck, r.user, r.messages, r.calendar, r.expenses, r.journal];
51516
+ }
51517
+
51091
51518
  // src/index.ts
51092
51519
  var originalEmit = process.emit.bind(process);
51093
51520
  process.emit = function(event, ...args) {
@@ -51102,19 +51529,22 @@ process.emit = function(event, ...args) {
51102
51529
  var nodeCache;
51103
51530
  var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
51104
51531
  var nodeAttachmentIO = new NodeAttachmentIO();
51532
+ var expenseRegistrar = (server, deps) => registerExpenseTools(server, deps, nodeAttachmentIO);
51533
+ var messageRegistrar = (server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO);
51534
+ var tools = selectToolRegistrars({
51535
+ healthcheck: registerHealthcheckTools,
51536
+ user: registerUserTools,
51537
+ messages: messageRegistrar,
51538
+ calendar: registerCalendarTools,
51539
+ expenses: expenseRegistrar,
51540
+ journal: registerJournalTools
51541
+ });
51105
51542
  await runMcp({
51106
51543
  name: "ofw",
51107
- version: "2.19.5",
51544
+ version: "2.20.0",
51108
51545
  // x-release-please-version
51109
51546
  deps: client,
51110
- tools: [
51111
- registerHealthcheckTools,
51112
- registerUserTools,
51113
- (server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO),
51114
- registerCalendarTools,
51115
- registerExpenseTools,
51116
- registerJournalTools
51117
- ],
51547
+ tools,
51118
51548
  banner: "[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion."
51119
51549
  });
51120
51550
  /*! Bundled license information:
package/dist/client.js CHANGED
@@ -144,7 +144,10 @@ export class OFWClient {
144
144
  throw new Error('Rate limited by OFW API');
145
145
  }
146
146
  if (!response.ok) {
147
- throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
147
+ const errorBody = await response.text();
148
+ const safeBody = redactSecrets(errorBody).replace(/\s+/g, ' ').trim().slice(0, 4000);
149
+ throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}` +
150
+ (safeBody ? ` — ${safeBody}` : ''));
148
151
  }
149
152
  return response;
150
153
  }