ofw-mcp 2.20.4 → 2.20.5
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 +6 -2
- package/dist/bundle.js +109 -9
- package/dist/client.js +13 -4
- package/dist/protocol.js +18 -8
- package/dist/tools/expenses.js +2 -1
- package/dist/version.js +1 -1
- package/mint.yaml +6 -1
- package/package.json +3 -3
- package/server.json +2 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "OurFamilyWizard tools for Claude Code",
|
|
9
|
-
"version": "2.20.
|
|
9
|
+
"version": "2.20.5"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"displayName": "OurFamilyWizard",
|
|
15
15
|
"source": "./",
|
|
16
16
|
"description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
|
|
17
|
-
"version": "2.20.
|
|
17
|
+
"version": "2.20.5",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Chris Chall"
|
|
20
20
|
},
|
package/README.md
CHANGED
|
@@ -293,7 +293,9 @@ Calendar events sit between the two message tiers: they have no draft stage (a c
|
|
|
293
293
|
|
|
294
294
|
### Egress allowlist
|
|
295
295
|
|
|
296
|
-
Every
|
|
296
|
+
Every request that carries your OFW token or login passes its constructed URL through a host check before `fetch` — the server refuses to send them to any host other than `ofw.ourfamilywizard.com`. Today it always passes, and that is the point: it makes "the credential only ever goes to OFW" a structural invariant rather than a code-review promise, so a later refactor or a compromised dependency that pointed a request elsewhere throws instead of carrying your bearer token or messages off-host. It is a pre-flight check on the URL we build, not a redirect-following egress filter.
|
|
297
|
+
|
|
298
|
+
The one other host the server contacts is `*.oaiusercontent.com`, and only for `ofw_upload_expense_pdf`'s `url` mode: it downloads the receipt PDF from that signed file URL with no OFW credential attached, re-checking every redirect hop against the same suffix, capping the size and requiring a real PDF. The hosted registration's egress allowlist (`mint.yaml`) names exactly these two hosts.
|
|
297
299
|
|
|
298
300
|
## Troubleshooting
|
|
299
301
|
|
|
@@ -317,7 +319,7 @@ Every outbound request passes its constructed URL through a host check before `f
|
|
|
317
319
|
- They are passed to the server as environment variables and never logged
|
|
318
320
|
- The server authenticates with OFW using the same login flow as the web app
|
|
319
321
|
- Use a strong, unique OFW password
|
|
320
|
-
-
|
|
322
|
+
- Requests carrying your OFW credential are host-allowlisted to `ofw.ourfamilywizard.com`; the only other host contacted is the receipt-download host for `ofw_upload_expense_pdf`'s `url` mode (see [Egress allowlist](#egress-allowlist))
|
|
321
323
|
|
|
322
324
|
### Local data, and how to remove it
|
|
323
325
|
|
|
@@ -339,6 +341,8 @@ rm -f ~/.ofw-mcp/session.json # or your OFW_SESSION_FILE
|
|
|
339
341
|
|
|
340
342
|
The next start rebuilds the cache from OurFamilyWizard with `ofw_sync_messages`. A directory you pointed `OFW_ATTACHMENTS_DIR` at yourself keeps its own permissions (it may be shared on purpose); only what the server creates, and the default `~/Downloads/ofw-mcp`, are locked down.
|
|
341
343
|
|
|
344
|
+
**On a hosted connector (mcp-host)** the same data lives in the registration's persistent data directory instead of on your machine: the message cache under `$HOME/.cache/ofw-mcp/`, the session token at `$MCP_DATA_DIR/.ofw-mcp/session.json`, the browser-bridge identity under `$HOME/.fetchproxy/identity/`, and spent confirm-token nonces under `$MCP_DATA_DIR/.mcp-confirm/`. Downloads are returned inline and never written to disk. The commands above do not reach it — how long that directory is kept, and whether disconnecting or deleting the registration removes it, is the host's retention policy, not this server's.
|
|
345
|
+
|
|
342
346
|
## Development
|
|
343
347
|
|
|
344
348
|
```bash
|
package/dist/bundle.js
CHANGED
|
@@ -4483,6 +4483,7 @@ __export(util_exports, {
|
|
|
4483
4483
|
required: () => required,
|
|
4484
4484
|
safeExtend: () => safeExtend,
|
|
4485
4485
|
shallowClone: () => shallowClone,
|
|
4486
|
+
sizeCaps: () => sizeCaps,
|
|
4486
4487
|
slugify: () => slugify,
|
|
4487
4488
|
stringifyPrimitive: () => stringifyPrimitive,
|
|
4488
4489
|
toZod: () => toZod,
|
|
@@ -5002,6 +5003,18 @@ function prefixIssues(path, issues) {
|
|
|
5002
5003
|
function unwrapMessage(message) {
|
|
5003
5004
|
return typeof message === "string" ? message : message?.message;
|
|
5004
5005
|
}
|
|
5006
|
+
function sizeCaps(defChecks) {
|
|
5007
|
+
const caps = [];
|
|
5008
|
+
for (const ch of defChecks ?? []) {
|
|
5009
|
+
const d = ch._zod.def;
|
|
5010
|
+
if (!/_(length|size)$|_equals$/.test(d.check))
|
|
5011
|
+
break;
|
|
5012
|
+
const max = d.maximum ?? d.length ?? d.size;
|
|
5013
|
+
if (max >= 0)
|
|
5014
|
+
caps.push([max, ch]);
|
|
5015
|
+
}
|
|
5016
|
+
return caps;
|
|
5017
|
+
}
|
|
5005
5018
|
function attachSchema(issues, start, inst) {
|
|
5006
5019
|
var _a3;
|
|
5007
5020
|
for (let i = start; i < issues.length; i++) {
|
|
@@ -6422,7 +6435,7 @@ ${content.join("\n")}
|
|
|
6422
6435
|
var version = {
|
|
6423
6436
|
major: 4,
|
|
6424
6437
|
minor: 6,
|
|
6425
|
-
patch:
|
|
6438
|
+
patch: 7
|
|
6426
6439
|
};
|
|
6427
6440
|
|
|
6428
6441
|
// node_modules/zod/v4/core/schemas.js
|
|
@@ -7178,6 +7191,31 @@ var $ZodDate = /* @__PURE__ */ $constructor("$ZodDate", (inst, def) => {
|
|
|
7178
7191
|
return payload;
|
|
7179
7192
|
};
|
|
7180
7193
|
});
|
|
7194
|
+
function capSize(inst, size) {
|
|
7195
|
+
const caps = sizeCaps(inst._zod.def.checks);
|
|
7196
|
+
if (!caps.length)
|
|
7197
|
+
return;
|
|
7198
|
+
const cap = Math.min(...caps.map((c) => c[0]));
|
|
7199
|
+
const parse3 = inst._zod.parse;
|
|
7200
|
+
inst._zod.parse = (payload, ctx) => {
|
|
7201
|
+
const n = size(payload.value);
|
|
7202
|
+
if (n > cap && !ctx?.skipChecks && ctx?.direction !== "backward") {
|
|
7203
|
+
for (const [max, ch] of sizeCaps(inst._zod.def.checks)) {
|
|
7204
|
+
if (n > max && ch._zod.def.when(payload)) {
|
|
7205
|
+
const start = payload.issues.length;
|
|
7206
|
+
ch._zod.check(payload);
|
|
7207
|
+
payload.issues[start].continue = false;
|
|
7208
|
+
attachSchema(payload.issues, start, inst);
|
|
7209
|
+
return payload;
|
|
7210
|
+
}
|
|
7211
|
+
}
|
|
7212
|
+
}
|
|
7213
|
+
return parse3(payload, ctx);
|
|
7214
|
+
};
|
|
7215
|
+
}
|
|
7216
|
+
var arraySize = (input2) => Array.isArray(input2) ? input2.length : -1;
|
|
7217
|
+
var mapSize = (input2) => input2 instanceof Map ? input2.size : -1;
|
|
7218
|
+
var setSize = (input2) => input2 instanceof Set ? input2.size : -1;
|
|
7181
7219
|
function handleArrayResult(result, final, index) {
|
|
7182
7220
|
if (result.issues.length) {
|
|
7183
7221
|
final.issues.push(...prefixIssues(index, result.issues));
|
|
@@ -7221,6 +7259,7 @@ var $ZodArray = /* @__PURE__ */ $constructor("$ZodArray", (inst, def) => {
|
|
|
7221
7259
|
}
|
|
7222
7260
|
return payload;
|
|
7223
7261
|
};
|
|
7262
|
+
capSize(inst, arraySize);
|
|
7224
7263
|
});
|
|
7225
7264
|
function handlePropertyResult(result, final, key, input2, optin, optout) {
|
|
7226
7265
|
const isPresent = key in input2;
|
|
@@ -8177,6 +8216,7 @@ var $ZodMap = /* @__PURE__ */ $constructor("$ZodMap", (inst, def) => {
|
|
|
8177
8216
|
return Promise.all(proms).then(() => payload);
|
|
8178
8217
|
return payload;
|
|
8179
8218
|
};
|
|
8219
|
+
capSize(inst, mapSize);
|
|
8180
8220
|
});
|
|
8181
8221
|
function handleMapResult(keyResult, valueResult, final, key, input2, inst, ctx) {
|
|
8182
8222
|
if (keyResult.issues.length) {
|
|
@@ -8243,6 +8283,7 @@ var $ZodSet = /* @__PURE__ */ $constructor("$ZodSet", (inst, def) => {
|
|
|
8243
8283
|
return Promise.all(proms).then(() => payload);
|
|
8244
8284
|
return payload;
|
|
8245
8285
|
};
|
|
8286
|
+
capSize(inst, setSize);
|
|
8246
8287
|
});
|
|
8247
8288
|
function handleSetResult(result, final) {
|
|
8248
8289
|
if (result.issues.length) {
|
|
@@ -17773,9 +17814,15 @@ function generateNullableCheck(doc, ctx, schema, accessor, buildsValue = true) {
|
|
|
17773
17814
|
doc.write(`}`);
|
|
17774
17815
|
return outputVar;
|
|
17775
17816
|
}
|
|
17817
|
+
function generateSizeGate(doc, schema, size) {
|
|
17818
|
+
for (const [max, check2] of sizeCaps(schema._zod.def.checks)) {
|
|
17819
|
+
doc.write(`if (${size} > ${numericOperand(max, check2._zod.def.check)}) return INVALID;`);
|
|
17820
|
+
}
|
|
17821
|
+
}
|
|
17776
17822
|
function generateArrayCheck(doc, ctx, schema, accessor, buildsValue = true) {
|
|
17777
17823
|
const def = schema._zod.def;
|
|
17778
17824
|
doc.write(`if (!Array.isArray(${accessor})) return INVALID;`);
|
|
17825
|
+
generateSizeGate(doc, schema, `${accessor}.length`);
|
|
17779
17826
|
const outputVar = buildsValue ? newVar(ctx) : null;
|
|
17780
17827
|
const iVar = newVar(ctx);
|
|
17781
17828
|
const elemVar = newVar(ctx);
|
|
@@ -18175,6 +18222,7 @@ function literalPropertyKey(ctx, key) {
|
|
|
18175
18222
|
function generateMapCheck(doc, ctx, schema, accessor) {
|
|
18176
18223
|
const def = schema._zod.def;
|
|
18177
18224
|
doc.write(`if (!(${accessor} instanceof Map)) return INVALID;`);
|
|
18225
|
+
generateSizeGate(doc, schema, `${accessor}.size`);
|
|
18178
18226
|
const outputVar = newVar(ctx);
|
|
18179
18227
|
const kVar = newVar(ctx);
|
|
18180
18228
|
const valVar = newVar(ctx);
|
|
@@ -18191,6 +18239,7 @@ function generateMapCheck(doc, ctx, schema, accessor) {
|
|
|
18191
18239
|
function generateSetCheck(doc, ctx, schema, accessor) {
|
|
18192
18240
|
const def = schema._zod.def;
|
|
18193
18241
|
doc.write(`if (!(${accessor} instanceof Set)) return INVALID;`);
|
|
18242
|
+
generateSizeGate(doc, schema, `${accessor}.size`);
|
|
18194
18243
|
const outputVar = newVar(ctx);
|
|
18195
18244
|
const valVar = newVar(ctx);
|
|
18196
18245
|
doc.write(`const ${outputVar} = new Set();`);
|
|
@@ -23500,7 +23549,7 @@ function date4(params) {
|
|
|
23500
23549
|
return _coercedDate(ZodDate, params);
|
|
23501
23550
|
}
|
|
23502
23551
|
|
|
23503
|
-
// node_modules/@modelcontextprotocol/core/dist/auth-BNDyLTqp.mjs
|
|
23552
|
+
// node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/dist/auth-BNDyLTqp.mjs
|
|
23504
23553
|
var LATEST_PROTOCOL_VERSION = "2025-11-25";
|
|
23505
23554
|
var SUPPORTED_PROTOCOL_VERSIONS = [
|
|
23506
23555
|
LATEST_PROTOCOL_VERSION,
|
|
@@ -38325,7 +38374,7 @@ var API_KEY_RE = new RegExp([
|
|
|
38325
38374
|
// webhook signing secret (Stripe-style)
|
|
38326
38375
|
].map((p) => `\\b${p}`).join("|"), "g");
|
|
38327
38376
|
var GOOGLE_OAUTH_TOKEN_RE = /(?<![A-Za-z0-9+/])(?:ya29\.|1\/\/)[A-Za-z0-9._-]{8,}/g;
|
|
38328
|
-
var QUERY_SECRET_RE = new RegExp(`([
|
|
38377
|
+
var QUERY_SECRET_RE = new RegExp(`([?&#;](?:(?:access|refresh|id|auth|session|csrf|xsrf|api)[_-]?token|client[_-]?secret|api[_-]?secret|api[_-]?key|password|passwd|signature|secret|token|auth|key|sig|(?:code[_-]?)?verifier|(?:php|j)?sess(?:ion)?[_-]?id|sid|x[-_]${HEADER_NAME_RUN}?(?:api[-_]?key|token|secret|auth)${HEADER_NAME_RUN})=)[^&#\\s"'<>\`]+`, "gi");
|
|
38329
38378
|
var OAUTH_CODE_RE = /([?&]code=)(?=[^&#\s"'<>`]{16,})[^&#\s"'<>`]+/gi;
|
|
38330
38379
|
var FORM_PASSWORD_RE = /((?:^|[\s,;:"'(\[{])(?:password|passwd)=)[^&#\s"'<>`]+/gim;
|
|
38331
38380
|
var HEADER_SECRET_RE = new RegExp(`(\\b(?:x[-_]${HEADER_NAME_RUN}?(?:api[-_]?key|token|secret|auth)${HEADER_NAME_RUN}|api[-_]?key)\\s*:\\s*)[^\\s,;"'<>\`]+`, "gi");
|
|
@@ -38336,6 +38385,8 @@ var JSON_SECRET_KEYS = [
|
|
|
38336
38385
|
"api[_-]?secret",
|
|
38337
38386
|
"(?:x[_-])?api[_-]?key",
|
|
38338
38387
|
"private[_-]?key",
|
|
38388
|
+
"aws[_-]?secret[_-]?access[_-]?key",
|
|
38389
|
+
// fleet-audit#1297
|
|
38339
38390
|
"code[_-]?verifier",
|
|
38340
38391
|
// PKCE; a bare `verifier` key is too generic for JSON
|
|
38341
38392
|
`x[_-]${HEADER_NAME_RUN}?(?:api[_-]?key|token|secret|auth)${HEADER_NAME_RUN}`,
|
|
@@ -39423,10 +39474,34 @@ async function fileBlob(path, opts = {}) {
|
|
|
39423
39474
|
return blob;
|
|
39424
39475
|
}
|
|
39425
39476
|
|
|
39477
|
+
// node_modules/@chrischall/mcp-utils/dist/internal/ascii.js
|
|
39478
|
+
function asciiLower(s) {
|
|
39479
|
+
return s.replace(/[A-Z]+/g, (m) => m.toLowerCase());
|
|
39480
|
+
}
|
|
39481
|
+
|
|
39426
39482
|
// node_modules/@chrischall/mcp-utils/dist/scrape/index.js
|
|
39427
39483
|
var NEXT_DATA_MAX_CHARS = 16 * 1024 * 1024;
|
|
39428
39484
|
function isCloudflareChallenge(html) {
|
|
39429
|
-
return
|
|
39485
|
+
return html.includes("_cf_chl_opt") || hasJustAMomentTitle(html);
|
|
39486
|
+
}
|
|
39487
|
+
var JUST_A_MOMENT = "just a moment";
|
|
39488
|
+
function hasJustAMomentTitle(html) {
|
|
39489
|
+
const lower = asciiLower(html);
|
|
39490
|
+
let i = 0;
|
|
39491
|
+
for (; ; ) {
|
|
39492
|
+
const open3 = lower.indexOf("<title", i);
|
|
39493
|
+
if (open3 < 0)
|
|
39494
|
+
return false;
|
|
39495
|
+
const gt = lower.indexOf(">", open3 + 6);
|
|
39496
|
+
if (gt < 0)
|
|
39497
|
+
return false;
|
|
39498
|
+
let j = gt + 1;
|
|
39499
|
+
while (j < lower.length && /\s/.test(lower[j]))
|
|
39500
|
+
j++;
|
|
39501
|
+
if (lower.startsWith(JUST_A_MOMENT, j))
|
|
39502
|
+
return true;
|
|
39503
|
+
i = gt + 1;
|
|
39504
|
+
}
|
|
39430
39505
|
}
|
|
39431
39506
|
|
|
39432
39507
|
// node_modules/@chrischall/mcp-utils/dist/http/net-atoms.js
|
|
@@ -40724,6 +40799,17 @@ function isPublicSuffix(host) {
|
|
|
40724
40799
|
return false;
|
|
40725
40800
|
}
|
|
40726
40801
|
|
|
40802
|
+
// node_modules/@fetchproxy/protocol/dist/ip-literal.js
|
|
40803
|
+
function isIpLiteralHost(host) {
|
|
40804
|
+
if (host.includes(":") || host.startsWith("["))
|
|
40805
|
+
return true;
|
|
40806
|
+
const labels = host.split(".");
|
|
40807
|
+
if (labels.length > 1 && labels[labels.length - 1] === "")
|
|
40808
|
+
labels.pop();
|
|
40809
|
+
const last = labels[labels.length - 1] ?? "";
|
|
40810
|
+
return /^[0-9]+$/.test(last) || /^0x[0-9a-f]*$/i.test(last);
|
|
40811
|
+
}
|
|
40812
|
+
|
|
40727
40813
|
// node_modules/@fetchproxy/protocol/dist/validate.js
|
|
40728
40814
|
var ProtocolError3 = class extends Error {
|
|
40729
40815
|
constructor(message) {
|
|
@@ -41189,6 +41275,9 @@ function validateHello(raw) {
|
|
|
41189
41275
|
if (!HOSTNAME_RE.test(d)) {
|
|
41190
41276
|
throw new ProtocolError3(`hello.domains: invalid hostname ${JSON.stringify(d)}`);
|
|
41191
41277
|
}
|
|
41278
|
+
if (isIpLiteralHost(d)) {
|
|
41279
|
+
throw new ProtocolError3(`hello.domains: ${JSON.stringify(d)} is an IP address \u2014 declare the site's DNS hostname; IP-literal origins (LAN, loopback, link-local) are not reachable through the bridge`);
|
|
41280
|
+
}
|
|
41192
41281
|
if (isPublicSuffix(d)) {
|
|
41193
41282
|
throw new ProtocolError3(`hello.domains: ${JSON.stringify(d)} is a public suffix \u2014 declare a registrable domain (e.g. "example.${d}"), not the suffix every site under it shares`);
|
|
41194
41283
|
}
|
|
@@ -43314,7 +43403,7 @@ async function startHost(opts) {
|
|
|
43314
43403
|
if (ownRetryableRefusal)
|
|
43315
43404
|
resetSessionPromise();
|
|
43316
43405
|
resolveOwnSession(ownSession);
|
|
43317
|
-
} else {
|
|
43406
|
+
} else if (identified === "extension" && extensionWs === ws) {
|
|
43318
43407
|
const slot = peers.get(frame.mcpId);
|
|
43319
43408
|
if (slot)
|
|
43320
43409
|
slot.ws.send(JSON.stringify(frame));
|
|
@@ -44340,6 +44429,9 @@ var FetchproxyServer = class {
|
|
|
44340
44429
|
throw new Error("FetchproxyServer: opts.domains must be a non-empty array of hostnames");
|
|
44341
44430
|
}
|
|
44342
44431
|
for (const d of opts.domains) {
|
|
44432
|
+
if (typeof d === "string" && isIpLiteralHost(d)) {
|
|
44433
|
+
throw new Error(`FetchproxyServer: opts.domains entry ${JSON.stringify(d)} is an IP address \u2014 declare the site's DNS hostname; IP-literal origins (LAN, loopback, link-local) are not reachable through the bridge`);
|
|
44434
|
+
}
|
|
44343
44435
|
if (typeof d === "string" && isPublicSuffix(d)) {
|
|
44344
44436
|
throw new Error(`FetchproxyServer: opts.domains entry ${JSON.stringify(d)} is a public suffix \u2014 declare a registrable domain (e.g. "example.${d}"), not the suffix every site under it shares`);
|
|
44345
44437
|
}
|
|
@@ -46274,6 +46366,9 @@ var FetchproxyServer = class {
|
|
|
46274
46366
|
}
|
|
46275
46367
|
};
|
|
46276
46368
|
|
|
46369
|
+
// node_modules/@fetchproxy/server/dist/bot-wall.js
|
|
46370
|
+
var CLOUDFLARE_TITLE_SCAN_MAX = 64 * 1024;
|
|
46371
|
+
|
|
46277
46372
|
// node_modules/@fetchproxy/bootstrap/dist/index.js
|
|
46278
46373
|
var defaultFactory = (opts) => new FetchproxyServer(opts);
|
|
46279
46374
|
var CAPTURE_BOOTSTRAP_DEADLINE_MS = 45e3;
|
|
@@ -46629,6 +46724,7 @@ var OFW_PROTOCOL_HEADERS = {
|
|
|
46629
46724
|
var OFW_TOKEN_TTL_MS = 6 * 60 * 60 * 1e3;
|
|
46630
46725
|
var OFW_TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1e3;
|
|
46631
46726
|
var ALLOWED_HOST = new URL(BASE_URL).host;
|
|
46727
|
+
var RECEIPT_HOST_SUFFIX = ".oaiusercontent.com";
|
|
46632
46728
|
function assertOfwUrl(rawUrl) {
|
|
46633
46729
|
let host;
|
|
46634
46730
|
try {
|
|
@@ -46739,7 +46835,7 @@ function describeTokenlessLogin(record2) {
|
|
|
46739
46835
|
|
|
46740
46836
|
// src/version.ts
|
|
46741
46837
|
var SERVER_NAME = "ofw-mcp";
|
|
46742
|
-
var VERSION = "2.20.
|
|
46838
|
+
var VERSION = "2.20.5";
|
|
46743
46839
|
|
|
46744
46840
|
// src/auth.ts
|
|
46745
46841
|
function fetchproxyDisabled() {
|
|
@@ -46869,6 +46965,10 @@ try {
|
|
|
46869
46965
|
function debugLogEnabled() {
|
|
46870
46966
|
return parseBoolEnv("OFW_DEBUG_LOG");
|
|
46871
46967
|
}
|
|
46968
|
+
function debugBody(text) {
|
|
46969
|
+
if (process.env.MCP_DATA_DIR) return `<${text.length} chars, elided on a hosted server>`;
|
|
46970
|
+
return text;
|
|
46971
|
+
}
|
|
46872
46972
|
var DEFAULT_REQUEST_TIMEOUT_MS2 = 3e4;
|
|
46873
46973
|
function getRequestTimeoutMs() {
|
|
46874
46974
|
const raw = process.env.OFW_REQUEST_TIMEOUT_MS;
|
|
@@ -46969,7 +47069,7 @@ var OFWClient = class {
|
|
|
46969
47069
|
return this.fetchAuthed(method, path, body, "application/json", async (response) => {
|
|
46970
47070
|
const text = await response.text();
|
|
46971
47071
|
if (debugLogEnabled()) {
|
|
46972
|
-
console.error(`[ofw-debug] response body: ${text
|
|
47072
|
+
console.error(`[ofw-debug] response body: ${text ? debugBody(text) : "<empty>"}`);
|
|
46973
47073
|
}
|
|
46974
47074
|
return text ? JSON.parse(text) : null;
|
|
46975
47075
|
});
|
|
@@ -47043,7 +47143,7 @@ var OFWClient = class {
|
|
|
47043
47143
|
const url2 = `${BASE_URL}${path}`;
|
|
47044
47144
|
assertOfwUrl(url2);
|
|
47045
47145
|
if (debugLogEnabled()) {
|
|
47046
|
-
const bodyPreview = body === void 0 ? "<none>" : isFormData ? `<FormData entries=${Array.from(body.keys()).join(",")}>` : JSON.stringify(body);
|
|
47146
|
+
const bodyPreview = body === void 0 ? "<none>" : isFormData ? `<FormData entries=${Array.from(body.keys()).join(",")}>` : debugBody(JSON.stringify(body));
|
|
47047
47147
|
console.error(`[ofw-debug] \u2192 ${method} ${url2}${isRetry ? " (retry)" : ""}`);
|
|
47048
47148
|
console.error(`[ofw-debug] headers: ${redactSecrets(JSON.stringify(headers))}`);
|
|
47049
47149
|
console.error(`[ofw-debug] body: ${bodyPreview}`);
|
|
@@ -51542,7 +51642,7 @@ function assertReceiptUrl(url2) {
|
|
|
51542
51642
|
if (url2.protocol !== "https:") {
|
|
51543
51643
|
throw new Error("Remote expense receipt URLs must use HTTPS.");
|
|
51544
51644
|
}
|
|
51545
|
-
if (!url2.hostname.toLowerCase().endsWith(
|
|
51645
|
+
if (!url2.hostname.toLowerCase().endsWith(RECEIPT_HOST_SUFFIX)) {
|
|
51546
51646
|
throw new Error("Remote expense receipt URLs must be signed oaiusercontent.com file URLs.");
|
|
51547
51647
|
}
|
|
51548
51648
|
}
|
package/dist/client.js
CHANGED
|
@@ -19,11 +19,20 @@ catch {
|
|
|
19
19
|
/* v8 ignore next -- only reached in a non-Node runtime: no .env to load */
|
|
20
20
|
}
|
|
21
21
|
// Set OFW_DEBUG_LOG=1 (or true/yes/on) to log every OFW request/response to
|
|
22
|
-
// stderr. Authorization is redacted.
|
|
23
|
-
// only when debugging, never in normal use.
|
|
22
|
+
// stderr. Authorization is redacted. Locally, bodies are logged in full — set
|
|
23
|
+
// this only when debugging, never in normal use.
|
|
24
24
|
function debugLogEnabled() {
|
|
25
25
|
return parseBoolEnv('OFW_DEBUG_LOG');
|
|
26
26
|
}
|
|
27
|
+
// On a hosted server (mcp-host injects MCP_DATA_DIR) stderr is captured by the
|
|
28
|
+
// host's log pipeline, which the operator can read — so request and response
|
|
29
|
+
// bodies (message text, drafts, expense ledgers) are replaced by their size.
|
|
30
|
+
// Locally stderr is the user's own terminal and bodies stay visible.
|
|
31
|
+
function debugBody(text) {
|
|
32
|
+
if (process.env.MCP_DATA_DIR)
|
|
33
|
+
return `<${text.length} chars, elided on a hosted server>`;
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
27
36
|
// Per-request timeout. Overridable via OFW_REQUEST_TIMEOUT_MS. The default
|
|
28
37
|
// (30s) is comfortably above OFW's typical p99 but low enough that a stuck
|
|
29
38
|
// upstream fails fast instead of burning the MCP client-side budget — which
|
|
@@ -160,7 +169,7 @@ export class OFWClient {
|
|
|
160
169
|
return this.fetchAuthed(method, path, body, 'application/json', async (response) => {
|
|
161
170
|
const text = await response.text();
|
|
162
171
|
if (debugLogEnabled()) {
|
|
163
|
-
console.error(`[ofw-debug] response body: ${text
|
|
172
|
+
console.error(`[ofw-debug] response body: ${text ? debugBody(text) : '<empty>'}`);
|
|
164
173
|
}
|
|
165
174
|
return (text ? JSON.parse(text) : null);
|
|
166
175
|
});
|
|
@@ -243,7 +252,7 @@ export class OFWClient {
|
|
|
243
252
|
? '<none>'
|
|
244
253
|
: isFormData
|
|
245
254
|
? `<FormData entries=${Array.from(body.keys()).join(',')}>`
|
|
246
|
-
: JSON.stringify(body);
|
|
255
|
+
: debugBody(JSON.stringify(body));
|
|
247
256
|
console.error(`[ofw-debug] → ${method} ${url}${isRetry ? ' (retry)' : ''}`);
|
|
248
257
|
// redactSecrets scrubs the Bearer token (and any other secret shapes)
|
|
249
258
|
// from the serialized header map — shared fleet redaction, never bespoke.
|
package/dist/protocol.js
CHANGED
|
@@ -15,13 +15,23 @@ export const OFW_TOKEN_TTL_MS = 6 * 60 * 60 * 1000;
|
|
|
15
15
|
// How early we treat a token as expiring. Re-auth before this skew so a
|
|
16
16
|
// long-running request doesn't get a stale token mid-flight.
|
|
17
17
|
export const OFW_TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000;
|
|
18
|
-
// The only host
|
|
19
|
-
// so the two cannot drift.
|
|
18
|
+
// The only host the authenticated API path is ever allowed to contact.
|
|
19
|
+
// Derived from BASE_URL so the two cannot drift.
|
|
20
20
|
const ALLOWED_HOST = new URL(BASE_URL).host;
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* The one OTHER host family this server fetches: signed `*.oaiusercontent.com`
|
|
23
|
+
* file URLs that `ofw_upload_expense_pdf` downloads a receipt from (its `url`
|
|
24
|
+
* mode). That fetch carries no OFW credential and is checked per redirect hop
|
|
25
|
+
* by `assertReceiptUrl` in tools/expenses.ts, not by `assertOfwUrl`. mint.yaml's
|
|
26
|
+
* hosted egress allowlist names exactly these two hosts — tests pin it.
|
|
27
|
+
*/
|
|
28
|
+
export const RECEIPT_HOST_SUFFIX = '.oaiusercontent.com';
|
|
29
|
+
/**
|
|
30
|
+
* Enforced egress allowlist for every request that carries the OFW bearer
|
|
31
|
+
* token or a login: its fully-constructed URL passes through here before
|
|
32
|
+
* `fetch` (API calls in client.ts, the form login in auth-password.ts). The
|
|
33
|
+
* credential-free remote-receipt download is the one exception, held to
|
|
34
|
+
* `RECEIPT_HOST_SUFFIX` instead.
|
|
25
35
|
*
|
|
26
36
|
* Today this always passes, and that is the point. Every URL is
|
|
27
37
|
* `${BASE_URL}${path}` with a path that begins with '/', so no id or query
|
|
@@ -29,9 +39,9 @@ const ALLOWED_HOST = new URL(BASE_URL).host;
|
|
|
29
39
|
* the authority component when a '/' already precedes it. The check exists so
|
|
30
40
|
* that stays true after a future refactor: a code change (or a compromised
|
|
31
41
|
* dependency) that pointed `fetch` somewhere else would throw here instead of
|
|
32
|
-
* carrying the bearer token or a message body off-host. It makes "
|
|
33
|
-
* only ever
|
|
34
|
-
* promise.
|
|
42
|
+
* carrying the bearer token or a message body off-host. It makes "the
|
|
43
|
+
* credential only ever goes to OFW" a structural invariant rather than a
|
|
44
|
+
* code-review promise.
|
|
35
45
|
*
|
|
36
46
|
* Scope, stated honestly: this is a PRE-FLIGHT check on the URL we construct.
|
|
37
47
|
* It does not follow redirects, so it is not a complete egress control — an
|
package/dist/tools/expenses.js
CHANGED
|
@@ -5,6 +5,7 @@ import { CONFIRM_NOTE, confirmTokenParam, confirmWrite, stateRevision } from './
|
|
|
5
5
|
import { readUpstreamPaging } from './pagination.js';
|
|
6
6
|
import { getExpenseUploadOnly, getWriteMode } from '../config.js';
|
|
7
7
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
8
|
+
import { RECEIPT_HOST_SUFFIX } from '../protocol.js';
|
|
8
9
|
const UploadedExpenseFileSchema = z.looseObject({
|
|
9
10
|
fileId: z.number(),
|
|
10
11
|
fileName: z.string().optional(),
|
|
@@ -32,7 +33,7 @@ function assertReceiptUrl(url) {
|
|
|
32
33
|
if (url.protocol !== 'https:') {
|
|
33
34
|
throw new Error('Remote expense receipt URLs must use HTTPS.');
|
|
34
35
|
}
|
|
35
|
-
if (!url.hostname.toLowerCase().endsWith(
|
|
36
|
+
if (!url.hostname.toLowerCase().endsWith(RECEIPT_HOST_SUFFIX)) {
|
|
36
37
|
throw new Error('Remote expense receipt URLs must be signed oaiusercontent.com file URLs.');
|
|
37
38
|
}
|
|
38
39
|
}
|
package/dist/version.js
CHANGED
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
// package.json drift. Importing these instead of package.json keeps the whole
|
|
5
5
|
// manifest — npm scripts included — out of dist/bundle.js.
|
|
6
6
|
export const SERVER_NAME = 'ofw-mcp';
|
|
7
|
-
export const VERSION = '2.20.
|
|
7
|
+
export const VERSION = '2.20.5'; // x-release-please-version
|
package/mint.yaml
CHANGED
|
@@ -107,7 +107,8 @@ env:
|
|
|
107
107
|
help: >-
|
|
108
108
|
Set to 1 to write verbose request/response diagnostics to stderr. Off by
|
|
109
109
|
default; useful when a read is failing and you need to see the upstream
|
|
110
|
-
exchange.
|
|
110
|
+
exchange. On a hosted server only method, path, status and timing are
|
|
111
|
+
logged — request and response bodies are replaced by their size.
|
|
111
112
|
- name: OFW_AUTO_REFRESH
|
|
112
113
|
required: false
|
|
113
114
|
help: >-
|
|
@@ -174,3 +175,7 @@ egress:
|
|
|
174
175
|
# Only hosts the SERVER process actually fetches. Hosts that appear
|
|
175
176
|
# solely in a URL this server BUILDS and returns are excluded.
|
|
176
177
|
- ofw.ourfamilywizard.com
|
|
178
|
+
# ofw_upload_expense_pdf `url` mode downloads a receipt from a signed
|
|
179
|
+
# file URL (no OFW credential is sent there; every redirect hop is
|
|
180
|
+
# re-checked against this suffix in src/tools/expenses.ts).
|
|
181
|
+
- "*.oaiusercontent.com"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.20.
|
|
3
|
+
"version": "2.20.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"mcpName": "io.github.chrischall/ofw-mcp",
|
|
6
6
|
"description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@chrischall/mcp-utils": "^3.0.
|
|
38
|
-
"@fetchproxy/bootstrap": "^3.6.
|
|
37
|
+
"@chrischall/mcp-utils": "^3.0.3",
|
|
38
|
+
"@fetchproxy/bootstrap": "^3.6.2",
|
|
39
39
|
"@modelcontextprotocol/server": "^2.3.0",
|
|
40
40
|
"dotenv": "^18.0.0",
|
|
41
41
|
"zod": "^4.6.5"
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/ofw-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "2.20.
|
|
9
|
+
"version": "2.20.5",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.20.
|
|
14
|
+
"version": "2.20.5",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|