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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +24 -1
- package/dist/bundle.js +479 -49
- 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/expenses.js +563 -56
- package/package.json +1 -1
- package/server.json +14 -2
- package/skills/ofw/SKILL.md +10 -3
- package/skills/ofw-fpx/references/requests.md +58 -4
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
|
|
25791
|
-
if (!Array.isArray(
|
|
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:
|
|
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
|
|
27023
|
-
if (method === "tools/list" && Array.isArray(
|
|
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.
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
50353
|
-
|
|
50354
|
-
|
|
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("
|
|
50362
|
-
description: "List OurFamilyWizard
|
|
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
|
-
|
|
50366
|
-
|
|
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
|
|
50370
|
-
const
|
|
50371
|
-
const data = await client2.request("GET", `/pub/v2/expense/expenses?
|
|
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
|
|
50374
|
-
|
|
50375
|
-
|
|
50376
|
-
|
|
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
|
-
|
|
50380
|
-
|
|
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:
|
|
50386
|
-
// Not a harmless local write:
|
|
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
|
-
|
|
50392
|
-
|
|
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
|
|
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
|
-
|
|
50406
|
-
|
|
50407
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
}
|