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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "OurFamilyWizard tools for Claude Code",
9
- "version": "2.20.4"
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.4",
17
+ "version": "2.20.5",
18
18
  "author": {
19
19
  "name": "Chris Chall"
20
20
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ofw",
3
3
  "displayName": "OurFamilyWizard",
4
- "version": "2.20.4",
4
+ "version": "2.20.5",
5
5
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
6
6
  "author": {
7
7
  "name": "Chris Chall"
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 outbound request passes its constructed URL through a host check before `fetch` — the server refuses to contact any host other than `ofw.ourfamilywizard.com`. Today it always passes, and that is the point: it makes "this server only ever talks 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.
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
- - Outbound requests are host-allowlisted to `ofw.ourfamilywizard.com` (see [Egress allowlist](#egress-allowlist))
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: 5
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(`([?&](?:(?:access|refresh|id|auth|session|csrf|xsrf)[_-]?token|client[_-]?secret|api[_-]?secret|api[_-]?key|password|passwd|signature|token|key|sig|(?:code[_-]?)?verifier|(?:php|j)?sess(?:ion)?[_-]?id|sid|x[-_]${HEADER_NAME_RUN}?(?:api[-_]?key|token|secret|auth)${HEADER_NAME_RUN})=)[^&#\\s"'<>\`]+`, "gi");
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 /_cf_chl_opt/.test(html) || /<title[^>]*>\s*Just a moment/i.test(html);
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.4";
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 || "<empty>"}`);
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(".oaiusercontent.com")) {
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. Bodies are logged in full — set this
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 || '<empty>'}`);
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 this server is ever allowed to contact. Derived from BASE_URL
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
- * Enforced egress allowlist: every outbound request passes its
23
- * fully-constructed URL through here before `fetch` (API calls in client.ts,
24
- * the form login in auth-password.ts).
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 "this server
33
- * only ever talks to OFW" a structural invariant rather than a code-review
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
@@ -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('.oaiusercontent.com')) {
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.4'; // x-release-please-version
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.4",
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.0",
38
- "@fetchproxy/bootstrap": "^3.6.0",
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.4",
9
+ "version": "2.20.5",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.20.4",
14
+ "version": "2.20.5",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },