ofw-mcp 2.4.2 → 2.4.4

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.4.2"
9
+ "version": "2.4.4"
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.4.2",
17
+ "version": "2.4.4",
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.4.2",
4
+ "version": "2.4.4",
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/dist/auth.js CHANGED
@@ -45,22 +45,11 @@
45
45
  // - `./auth-password.js` (loginWithPassword) is a separate module
46
46
  // specifically so it can be mocked here too. This keeps the
47
47
  // selection logic independent of either implementation.
48
- import { readEnvVar } from '@chrischall/mcp-utils';
48
+ import { parseBoolEnv, readEnvVar } from '@chrischall/mcp-utils';
49
49
  import { bootstrap } from '@fetchproxy/bootstrap';
50
50
  import { classifyBridgeError } from '@chrischall/mcp-utils/fetchproxy';
51
51
  import { loginWithPassword } from './auth-password.js';
52
- import { parseBoolEnv } from './config.js';
53
52
  import pkg from '../package.json' with { type: 'json' };
54
- /**
55
- * Read an env var, trim, and treat blank / `${UNEXPANDED}` placeholders as
56
- * unset. Defends against MCP hosts that pass `.mcp.json` env blocks through
57
- * without variable expansion. Delegates to @chrischall/mcp-utils' `readEnvVar`,
58
- * which applies the identical sanitization (blank, `undefined`/`null`,
59
- * `${...}` placeholder → unset).
60
- */
61
- function readEnv(key) {
62
- return readEnvVar(key);
63
- }
64
53
  /** True if the user has explicitly disabled the fetchproxy fallback. */
65
54
  function fetchproxyDisabled() {
66
55
  return parseBoolEnv('OFW_DISABLE_FETCHPROXY');
@@ -75,8 +64,11 @@ function fetchproxyDisabled() {
75
64
  */
76
65
  export async function resolveAuth() {
77
66
  // ── Path 1: env-var credentials (unchanged from pre-fetchproxy behavior).
78
- const username = readEnv('OFW_USERNAME');
79
- const password = readEnv('OFW_PASSWORD');
67
+ // `readEnvVar` trims and treats blank / `"undefined"` / `"null"` /
68
+ // `${UNEXPANDED}` placeholders as unset — defends against MCP hosts that
69
+ // pass `.mcp.json` env blocks through without variable expansion.
70
+ const username = readEnvVar('OFW_USERNAME');
71
+ const password = readEnvVar('OFW_PASSWORD');
80
72
  if (username && password) {
81
73
  const { token, expiresAt } = await loginWithPassword(username, password);
82
74
  return { token, expiresAt, source: 'env' };
package/dist/bundle.js CHANGED
@@ -34686,6 +34686,22 @@ async function runMcp(opts) {
34686
34686
  }
34687
34687
 
34688
34688
  // node_modules/@chrischall/mcp-utils/dist/errors/index.js
34689
+ var McpToolError = class extends Error {
34690
+ /** Actionable remediation text, when one applies. */
34691
+ hint;
34692
+ constructor(message, opts) {
34693
+ super(message, opts?.cause !== void 0 ? { cause: opts.cause } : void 0);
34694
+ this.name = "McpToolError";
34695
+ if (opts?.hint !== void 0)
34696
+ this.hint = opts.hint;
34697
+ Object.setPrototypeOf(this, new.target.prototype);
34698
+ }
34699
+ };
34700
+ var BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
34701
+ var JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
34702
+ var BASIC_AUTH_RE = /(authorization\s*:\s*basic\s+)[A-Za-z0-9+/=_-]{6,}/gi;
34703
+ var SET_COOKIE_RE = /(\bset-cookie\s*:\s*)([^=;,\s]+)=[^;,\s]*/gi;
34704
+ var COOKIE_HEADER_RE = /((?<!set-)\bcookie\s*:\s*)((?:[^=;,\s]+=[^;,\s]*)(?:;\s*[^=;,\s]+=[^;,\s]*)*)/gi;
34689
34705
  var API_KEY_RE = new RegExp([
34690
34706
  "sk-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])",
34691
34707
  // OpenAI / Anthropic (incl. sk-ant-…)
@@ -34700,6 +34716,14 @@ var API_KEY_RE = new RegExp([
34700
34716
  "whsec_[A-Za-z0-9]{16,}\\b"
34701
34717
  // webhook signing secret (Stripe-style)
34702
34718
  ].map((p) => `\\b${p}`).join("|"), "g");
34719
+ var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
34720
+ var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
34721
+ var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
34722
+ var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
34723
+ var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
34724
+ function redactSecrets(text) {
34725
+ return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
34726
+ }
34703
34727
 
34704
34728
  // node_modules/@chrischall/mcp-utils/dist/response/index.js
34705
34729
  function textResult(data) {
@@ -34781,6 +34805,21 @@ async function fileBlob(path, opts = {}) {
34781
34805
  return blob;
34782
34806
  }
34783
34807
 
34808
+ // node_modules/@chrischall/mcp-utils/dist/zod/parse-lenient.js
34809
+ function parseLenient(schema, raw, opts) {
34810
+ const result = schema.safeParse(raw);
34811
+ if (result.success)
34812
+ return result.data;
34813
+ const issues = result.error.issues.map((issue2) => `${issue2.path.join(".") || "(root)"}: ${issue2.message}`).join("; ");
34814
+ if (opts.mode === "strict") {
34815
+ throw new McpToolError(`Unexpected ${opts.context} shape from the upstream API. ${issues}`, {
34816
+ hint: "The upstream API may have changed; the schema needs updating."
34817
+ });
34818
+ }
34819
+ console.error(`[${opts.label}] WARNING: unexpected ${opts.context} shape \u2014 proceeding with the raw response. ${issues}`);
34820
+ return raw;
34821
+ }
34822
+
34784
34823
  // node_modules/@chrischall/mcp-utils/dist/zod/index.js
34785
34824
  var PositiveInt = external_exports.number().int().positive();
34786
34825
  var NonNegInt = external_exports.number().int().nonnegative();
@@ -34878,7 +34917,7 @@ var TokenManager = class {
34878
34917
  };
34879
34918
 
34880
34919
  // src/client.ts
34881
- import { dirname, join as join4 } from "path";
34920
+ import { dirname, join as join3 } from "path";
34882
34921
  import { fileURLToPath } from "url";
34883
34922
 
34884
34923
  // node_modules/@fetchproxy/protocol/dist/frames.js
@@ -35837,10 +35876,20 @@ async function openEncryptedFrame(sessionKey, frame) {
35837
35876
 
35838
35877
  // node_modules/@fetchproxy/server/dist/election.js
35839
35878
  import { createServer, Server as HttpServer } from "node:http";
35879
+ var DEFAULT_BIND_TIMEOUT_MS = 5e3;
35840
35880
  async function electRole(opts) {
35841
35881
  const server = createServer();
35882
+ const bindTimeoutMs = opts.bindTimeoutMs ?? DEFAULT_BIND_TIMEOUT_MS;
35842
35883
  return new Promise((resolve2, reject) => {
35884
+ let timer;
35885
+ const clearBindTimer = () => {
35886
+ if (timer) {
35887
+ clearTimeout(timer);
35888
+ timer = void 0;
35889
+ }
35890
+ };
35843
35891
  const onError = (e) => {
35892
+ clearBindTimer();
35844
35893
  server.removeListener("listening", onListening);
35845
35894
  if (e.code === "EADDRINUSE") {
35846
35895
  try {
@@ -35853,11 +35902,25 @@ async function electRole(opts) {
35853
35902
  }
35854
35903
  };
35855
35904
  const onListening = () => {
35905
+ clearBindTimer();
35856
35906
  server.removeListener("error", onError);
35857
35907
  resolve2({ role: "host", server });
35858
35908
  };
35859
35909
  server.once("error", onError);
35860
35910
  server.once("listening", onListening);
35911
+ if (bindTimeoutMs > 0) {
35912
+ timer = setTimeout(() => {
35913
+ server.removeListener("error", onError);
35914
+ server.removeListener("listening", onListening);
35915
+ try {
35916
+ server.close();
35917
+ } catch {
35918
+ }
35919
+ reject(new Error(`fetchproxy: bind to ${opts.host}:${opts.port} timed out after ${bindTimeoutMs}ms (port may be in a bad state)`));
35920
+ }, bindTimeoutMs);
35921
+ if (typeof timer.unref === "function")
35922
+ timer.unref();
35923
+ }
35861
35924
  server.listen(opts.port, opts.host);
35862
35925
  });
35863
35926
  }
@@ -35950,6 +36013,44 @@ var SessionState = class {
35950
36013
  }
35951
36014
  };
35952
36015
 
36016
+ // node_modules/@fetchproxy/server/dist/session-ready.js
36017
+ var SESSION_READY_TIMEOUT_MS = 3e4;
36018
+ var FetchproxySessionNotReadyError = class extends Error {
36019
+ reason;
36020
+ pairCode;
36021
+ mcpId;
36022
+ hint;
36023
+ constructor(info) {
36024
+ const pairing = info.pairCode !== null && info.pairCode !== "";
36025
+ const hint = pairing ? `Open the Transporter extension popup and approve pair code ${info.pairCode} for "${info.mcpId}", then retry.` : `The extension is connected but hasn't confirmed a session for "${info.mcpId}" \u2014 sign in to the target site in that browser (and approve the requested scope if it changed), then retry.`;
36026
+ super(`fetchproxy: ${pairing ? "pairing not yet approved" : "no confirmed browser session"} for "${info.mcpId}". ${hint}`);
36027
+ this.name = "FetchproxySessionNotReadyError";
36028
+ this.reason = pairing ? "pair-required" : "not-ready";
36029
+ this.pairCode = pairing ? info.pairCode : null;
36030
+ this.mcpId = info.mcpId;
36031
+ this.hint = hint;
36032
+ Object.setPrototypeOf(this, new.target.prototype);
36033
+ }
36034
+ };
36035
+ async function awaitSessionReady(ready, opts) {
36036
+ const ms = opts.timeoutMs ?? SESSION_READY_TIMEOUT_MS;
36037
+ if (ms <= 0)
36038
+ return ready;
36039
+ let timer;
36040
+ const timeout = new Promise((_, reject) => {
36041
+ timer = setTimeout(() => {
36042
+ reject(new FetchproxySessionNotReadyError({ mcpId: opts.mcpId, pairCode: opts.pendingPairCode() }));
36043
+ }, ms);
36044
+ timer.unref?.();
36045
+ });
36046
+ try {
36047
+ return await Promise.race([ready, timeout]);
36048
+ } finally {
36049
+ if (timer)
36050
+ clearTimeout(timer);
36051
+ }
36052
+ }
36053
+
35953
36054
  // node_modules/@fetchproxy/server/dist/host.js
35954
36055
  var PUBLIC_ORIGIN_RE = /^https?:\/\/(?!(127\.0\.0\.1|localhost)(:|$))/i;
35955
36056
  var enc2 = new TextEncoder();
@@ -36037,6 +36138,29 @@ async function startHost(opts) {
36037
36138
  return;
36038
36139
  }
36039
36140
  if (frame.type === "hello" && frame.role === "server") {
36141
+ const peerEdPub = fromB64(frame.identityEd25519Pub);
36142
+ const peerSigMsg = concatBytes(enc2.encode(frame.mcpId), fromB64(frame.sessionNonce));
36143
+ const peerSig = fromB64(frame.sessionSig);
36144
+ let peerSigOk = false;
36145
+ try {
36146
+ peerSigOk = await ed25519Verify(peerEdPub, peerSigMsg, peerSig);
36147
+ } catch {
36148
+ peerSigOk = false;
36149
+ }
36150
+ if (!peerSigOk) {
36151
+ console.warn("[fetchproxy] peer hello signature invalid \u2014 refusing registration (possible squatter)");
36152
+ ws.close(1008, "peer hello signature invalid");
36153
+ return;
36154
+ }
36155
+ const existing = peers.get(frame.mcpId);
36156
+ if (existing && existing.ws !== ws) {
36157
+ const existingEdPub = existing.helloFrame.identityEd25519Pub;
36158
+ if (existingEdPub !== frame.identityEd25519Pub) {
36159
+ console.warn("[fetchproxy] peer mcpId already mapped to a different identity \u2014 refusing (mcpId squatting)");
36160
+ ws.close(1008, "mcpId already registered to another identity");
36161
+ return;
36162
+ }
36163
+ }
36040
36164
  identified = "peer";
36041
36165
  peerMcpId = frame.mcpId;
36042
36166
  peers.set(frame.mcpId, { ws, helloFrame: frame });
@@ -36129,8 +36253,10 @@ async function startHost(opts) {
36129
36253
  resetSessionPromise();
36130
36254
  disconnectListeners.forEach((cb) => cb());
36131
36255
  }
36132
- if (identified === "peer" && peerMcpId)
36133
- peers.delete(peerMcpId);
36256
+ if (identified === "peer" && peerMcpId) {
36257
+ if (peers.get(peerMcpId)?.ws === ws)
36258
+ peers.delete(peerMcpId);
36259
+ }
36134
36260
  });
36135
36261
  });
36136
36262
  return {
@@ -36146,7 +36272,10 @@ async function startHost(opts) {
36146
36272
  });
36147
36273
  }),
36148
36274
  sendOwnInner: async (inner) => {
36149
- const session = await ownSessionReady;
36275
+ const session = await awaitSessionReady(ownSessionReady, {
36276
+ mcpId: opts.ownMcpId,
36277
+ pendingPairCode: () => ownPendingPairCode
36278
+ });
36150
36279
  if (!extensionWs)
36151
36280
  throw new Error("host: no extension connected");
36152
36281
  const sealed = await sealInnerFrame(session.sessionKey, opts.ownMcpId, session.nextOutboundSeq(), inner);
@@ -36251,7 +36380,10 @@ async function startPeer(opts) {
36251
36380
  ws,
36252
36381
  session: sessionPromise,
36253
36382
  sendInner: async (inner) => {
36254
- await sessionPromise;
36383
+ await awaitSessionReady(sessionPromise, {
36384
+ mcpId: opts.mcpId,
36385
+ pendingPairCode: () => pendingPairCode
36386
+ });
36255
36387
  const s = session;
36256
36388
  const sealed = await sealInnerFrame(s.sessionKey, opts.mcpId, s.nextOutboundSeq(), inner);
36257
36389
  ws.send(JSON.stringify(sealed));
@@ -36917,6 +37049,35 @@ var FetchproxyServer = class {
36917
37049
  this.keepAliveTimer = null;
36918
37050
  }
36919
37051
  }
37052
+ /**
37053
+ * Send an inner request frame via whichever bridge handle is active. If the
37054
+ * send throws (e.g. `FetchproxySessionNotReadyError` — the session never
37055
+ * confirmed), the frame never reached the bridge, so no reply will arrive:
37056
+ * drop the just-registered pending resolver for this id (it lives in exactly
37057
+ * one of the op maps — request ids are unique) so it doesn't leak until the
37058
+ * server closes, then rethrow.
37059
+ */
37060
+ async sendInnerFrame(inner) {
37061
+ try {
37062
+ if (this.hostHandle) {
37063
+ await this.hostHandle.sendOwnInner(inner);
37064
+ } else if (this.peerHandle) {
37065
+ await this.peerHandle.sendInner(inner);
37066
+ }
37067
+ } catch (err) {
37068
+ if ("id" in inner && typeof inner.id === "number") {
37069
+ const { id } = inner;
37070
+ this.pending.delete(id);
37071
+ this.pendingReadCookies.delete(id);
37072
+ this.pendingStorage.delete(id);
37073
+ this.pendingCapture.delete(id);
37074
+ this.pendingRedirect.delete(id);
37075
+ this.pendingDownload.delete(id);
37076
+ this.pendingIdb.delete(id);
37077
+ }
37078
+ throw err;
37079
+ }
37080
+ }
36920
37081
  /**
36921
37082
  * Single bridge round-trip, wrapped by `fetchTimeoutMs` when set.
36922
37083
  * On timeout returns the `{ok:false, kind:'timeout'}` envelope —
@@ -36928,11 +37089,7 @@ var FetchproxyServer = class {
36928
37089
  const pending = new Promise((resolve2) => {
36929
37090
  this.pending.set(id, resolve2);
36930
37091
  });
36931
- if (this.hostHandle) {
36932
- await this.hostHandle.sendOwnInner(inner);
36933
- } else if (this.peerHandle) {
36934
- await this.peerHandle.sendInner(inner);
36935
- }
37092
+ await this.sendInnerFrame(inner);
36936
37093
  const timeoutMs = this.opts.fetchTimeoutMs;
36937
37094
  if (timeoutMs === void 0 || timeoutMs <= 0)
36938
37095
  return pending;
@@ -36961,6 +37118,51 @@ var FetchproxyServer = class {
36961
37118
  clearTimeout(timer);
36962
37119
  }
36963
37120
  }
37121
+ /**
37122
+ * FP-B2: bound a non-`fetch` verb's reply wait by `fetchTimeoutMs`.
37123
+ *
37124
+ * Before this, only `fetch()` raced its `pending` reply against a timer
37125
+ * (`_fetchOnceWithTimeout`); `readCookies`, the storage reads,
37126
+ * `capture_request_header`, `capture_redirect`, `download`, and
37127
+ * `read_indexed_db` awaited their `pending` promise with no race, so a
37128
+ * wedged extension hung the tool call indefinitely.
37129
+ *
37130
+ * `pending` is the already-registered reply promise. `pendingMap`/`id`
37131
+ * point at the op-specific map entry so we can drop it on expiry exactly
37132
+ * as `_fetchOnceWithTimeout` does — otherwise a late bridge reply would
37133
+ * resolve into a stale resolver / leak. On timeout we reject with a
37134
+ * `FetchproxyTimeoutError` (the same throwable the convenience methods
37135
+ * already surface for fetch timeouts). `0`/unset opts out (unbounded),
37136
+ * matching the fetch path.
37137
+ */
37138
+ async _withVerbTimeout(pending, pendingMap, id, url2) {
37139
+ const timeoutMs = this.opts.fetchTimeoutMs;
37140
+ if (timeoutMs === void 0 || timeoutMs <= 0)
37141
+ return pending;
37142
+ let timer;
37143
+ const start = Date.now();
37144
+ try {
37145
+ return await Promise.race([
37146
+ pending,
37147
+ new Promise((_resolve, reject) => {
37148
+ timer = setTimeout(() => {
37149
+ pendingMap.delete(id);
37150
+ reject(new FetchproxyTimeoutError({
37151
+ url: url2,
37152
+ timeoutMs,
37153
+ role: this.role,
37154
+ port: this.opts.port,
37155
+ elapsedMs: Date.now() - start,
37156
+ retryAttempted: false
37157
+ }));
37158
+ }, timeoutMs);
37159
+ })
37160
+ ]);
37161
+ } finally {
37162
+ if (timer)
37163
+ clearTimeout(timer);
37164
+ }
37165
+ }
36964
37166
  /**
36965
37167
  * Map an `ok:false` fetch result to its typed throwable. Centralizes
36966
37168
  * the kind-to-error-class switch so `request()` and (via the same
@@ -37251,12 +37453,8 @@ var FetchproxyServer = class {
37251
37453
  const pending = new Promise((resolve2) => {
37252
37454
  this.pendingReadCookies.set(id, resolve2);
37253
37455
  });
37254
- if (this.hostHandle) {
37255
- await this.hostHandle.sendOwnInner(inner);
37256
- } else if (this.peerHandle) {
37257
- await this.peerHandle.sendInner(inner);
37258
- }
37259
- const result = await pending;
37456
+ await this.sendInnerFrame(inner);
37457
+ const result = await this._withVerbTimeout(pending, this.pendingReadCookies, id, `https://${host}`);
37260
37458
  if (!result.ok) {
37261
37459
  throw new FetchproxyProtocolError(result.error);
37262
37460
  }
@@ -37322,12 +37520,8 @@ var FetchproxyServer = class {
37322
37520
  const pending = new Promise((resolve2, reject) => {
37323
37521
  this.pendingStorage.set(id, { resolve: resolve2, reject });
37324
37522
  });
37325
- if (this.hostHandle) {
37326
- await this.hostHandle.sendOwnInner(inner);
37327
- } else if (this.peerHandle) {
37328
- await this.peerHandle.sendInner(inner);
37329
- }
37330
- return pending;
37523
+ await this.sendInnerFrame(inner);
37524
+ return this._withVerbTimeout(pending, this.pendingStorage, id, `https://${host}`);
37331
37525
  }
37332
37526
  /**
37333
37527
  * 0.3.0+: snapshot the next outgoing request's named header. Single-
@@ -37431,12 +37625,8 @@ var FetchproxyServer = class {
37431
37625
  const pending = new Promise((resolve2, reject) => {
37432
37626
  this.pendingCapture.set(id, { resolve: resolve2, reject });
37433
37627
  });
37434
- if (this.hostHandle) {
37435
- await this.hostHandle.sendOwnInner(inner);
37436
- } else if (this.peerHandle) {
37437
- await this.peerHandle.sendInner(inner);
37438
- }
37439
- return pending;
37628
+ await this.sendInnerFrame(inner);
37629
+ return this._withVerbTimeout(pending, this.pendingCapture, id, `https://${opts.host}${opts.path ?? "/*"}`);
37440
37630
  }
37441
37631
  /**
37442
37632
  * Snapshot the redirect target URL of the next request the browser
@@ -37520,12 +37710,8 @@ var FetchproxyServer = class {
37520
37710
  const pending = new Promise((resolve2, reject) => {
37521
37711
  this.pendingRedirect.set(id, { resolve: resolve2, reject });
37522
37712
  });
37523
- if (this.hostHandle) {
37524
- await this.hostHandle.sendOwnInner(inner);
37525
- } else if (this.peerHandle) {
37526
- await this.peerHandle.sendInner(inner);
37527
- }
37528
- return pending;
37713
+ await this.sendInnerFrame(inner);
37714
+ return this._withVerbTimeout(pending, this.pendingRedirect, id, `https://${opts.host}${opts.path ?? "/*"}`);
37529
37715
  }
37530
37716
  /**
37531
37717
  * Download `url` through the BROWSER's own network stack via
@@ -37606,12 +37792,8 @@ var FetchproxyServer = class {
37606
37792
  const pending = new Promise((resolve2, reject) => {
37607
37793
  this.pendingDownload.set(id, { resolve: resolve2, reject });
37608
37794
  });
37609
- if (this.hostHandle) {
37610
- await this.hostHandle.sendOwnInner(inner);
37611
- } else if (this.peerHandle) {
37612
- await this.peerHandle.sendInner(inner);
37613
- }
37614
- return pending;
37795
+ await this.sendInnerFrame(inner);
37796
+ return this._withVerbTimeout(pending, this.pendingDownload, id, opts.url);
37615
37797
  }
37616
37798
  /**
37617
37799
  * 0.4.0+: read declared IndexedDB keys from the user's signed-in
@@ -37658,12 +37840,8 @@ var FetchproxyServer = class {
37658
37840
  const pending = new Promise((resolve2, reject) => {
37659
37841
  this.pendingIdb.set(id, { resolve: resolve2, reject });
37660
37842
  });
37661
- if (this.hostHandle) {
37662
- await this.hostHandle.sendOwnInner(inner);
37663
- } else if (this.peerHandle) {
37664
- await this.peerHandle.sendInner(inner);
37665
- }
37666
- return pending;
37843
+ await this.sendInnerFrame(inner);
37844
+ return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
37667
37845
  }
37668
37846
  assertScopeSubset(requested, declared, label) {
37669
37847
  const undeclared = undeclaredKeys(requested, declared);
@@ -38104,53 +38282,10 @@ async function loginWithPassword(username, password) {
38104
38282
  };
38105
38283
  }
38106
38284
 
38107
- // src/config.ts
38108
- import { createHash } from "node:crypto";
38109
- import { homedir as homedir3 } from "node:os";
38110
- import { join as join3 } from "node:path";
38111
- function readCacheIdentity() {
38112
- const explicit = process.env.OFW_CACHE_IDENTITY;
38113
- if (typeof explicit === "string" && explicit.trim().length > 0) return explicit.trim();
38114
- const username = process.env.OFW_USERNAME;
38115
- if (typeof username === "string" && username.trim().length > 0) return username.trim();
38116
- return "_default";
38117
- }
38118
- function getCacheDir() {
38119
- const override = process.env.OFW_CACHE_DIR;
38120
- if (override && override.trim().length > 0) return override.trim();
38121
- return join3(homedir3(), ".cache", "ofw-mcp");
38122
- }
38123
- function getCacheDbPath() {
38124
- const identity = readCacheIdentity();
38125
- const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
38126
- return join3(getCacheDir(), `${hash2}.db`);
38127
- }
38128
- function getAttachmentsDir() {
38129
- const override = process.env.OFW_ATTACHMENTS_DIR;
38130
- if (override && override.trim().length > 0) return override.trim();
38131
- return join3(homedir3(), "Downloads", "ofw-mcp");
38132
- }
38133
- function parseBoolEnv2(name) {
38134
- return parseBoolEnv(name);
38135
- }
38136
- function getWriteMode() {
38137
- const raw = process.env.OFW_WRITE_MODE;
38138
- if (typeof raw !== "string" || raw.trim().length === 0) return "all";
38139
- const mode = raw.trim().toLowerCase();
38140
- if (mode === "none" || mode === "drafts" || mode === "all") return mode;
38141
- console.error(
38142
- `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
38143
- );
38144
- return "none";
38145
- }
38146
- function getDefaultInlineAttachments() {
38147
- return parseBoolEnv2("OFW_INLINE_ATTACHMENTS");
38148
- }
38149
-
38150
38285
  // package.json
38151
38286
  var package_default = {
38152
38287
  name: "ofw-mcp",
38153
- version: "2.4.2",
38288
+ version: "2.4.4",
38154
38289
  license: "MIT",
38155
38290
  mcpName: "io.github.chrischall/ofw-mcp",
38156
38291
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -38182,14 +38317,14 @@ var package_default = {
38182
38317
  "test:watch": "vitest"
38183
38318
  },
38184
38319
  dependencies: {
38185
- "@chrischall/mcp-utils": "^0.9.0",
38320
+ "@chrischall/mcp-utils": "^0.12.0",
38186
38321
  "@fetchproxy/bootstrap": "^1.3.0",
38187
38322
  "@modelcontextprotocol/sdk": "^1.29.0",
38188
38323
  dotenv: "^17.4.2",
38189
38324
  zod: "^4.4.3"
38190
38325
  },
38191
38326
  devDependencies: {
38192
- "@types/node": "^25.9.1",
38327
+ "@types/node": "^26.0.0",
38193
38328
  "@vitest/coverage-v8": "^4.1.7",
38194
38329
  esbuild: "^0.28.0",
38195
38330
  typescript: "^6.0.3",
@@ -38198,15 +38333,12 @@ var package_default = {
38198
38333
  };
38199
38334
 
38200
38335
  // src/auth.ts
38201
- function readEnv(key) {
38202
- return readEnvVar(key);
38203
- }
38204
38336
  function fetchproxyDisabled() {
38205
- return parseBoolEnv2("OFW_DISABLE_FETCHPROXY");
38337
+ return parseBoolEnv("OFW_DISABLE_FETCHPROXY");
38206
38338
  }
38207
38339
  async function resolveAuth() {
38208
- const username = readEnv("OFW_USERNAME");
38209
- const password = readEnv("OFW_PASSWORD");
38340
+ const username = readEnvVar("OFW_USERNAME");
38341
+ const password = readEnvVar("OFW_PASSWORD");
38210
38342
  if (username && password) {
38211
38343
  const { token, expiresAt } = await loginWithPassword(username, password);
38212
38344
  return { token, expiresAt, source: "env" };
@@ -38263,7 +38395,7 @@ async function resolveAuth() {
38263
38395
 
38264
38396
  // src/client.ts
38265
38397
  var __dirname = dirname(fileURLToPath(import.meta.url));
38266
- await loadDotenvSafely({ path: join4(__dirname, "..", ".env") });
38398
+ await loadDotenvSafely({ path: join3(__dirname, "..", ".env") });
38267
38399
  function parseContentDispositionFilename(cd) {
38268
38400
  const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
38269
38401
  if (extMatch) {
@@ -38278,12 +38410,7 @@ function parseContentDispositionFilename(cd) {
38278
38410
  return m ? m[1] : null;
38279
38411
  }
38280
38412
  function debugLogEnabled() {
38281
- return parseBoolEnv2("OFW_DEBUG_LOG");
38282
- }
38283
- function redactHeaders(h) {
38284
- const out = { ...h };
38285
- if (out.Authorization) out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}\u2026`;
38286
- return out;
38413
+ return parseBoolEnv("OFW_DEBUG_LOG");
38287
38414
  }
38288
38415
  var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
38289
38416
  function getRequestTimeoutMs() {
@@ -38378,7 +38505,7 @@ var OFWClient = class {
38378
38505
  if (debugLogEnabled()) {
38379
38506
  const bodyPreview = body === void 0 ? "<none>" : isFormData ? `<FormData entries=${Array.from(body.keys()).join(",")}>` : JSON.stringify(body);
38380
38507
  console.error(`[ofw-debug] \u2192 ${method} ${url2}${isRetry ? " (retry)" : ""}`);
38381
- console.error(`[ofw-debug] headers: ${JSON.stringify(redactHeaders(headers))}`);
38508
+ console.error(`[ofw-debug] headers: ${redactSecrets(JSON.stringify(headers))}`);
38382
38509
  console.error(`[ofw-debug] body: ${bodyPreview}`);
38383
38510
  }
38384
38511
  const timeoutMs = getRequestTimeoutMs();
@@ -38418,17 +38545,6 @@ var OFWClient = class {
38418
38545
  };
38419
38546
  var client = new OFWClient();
38420
38547
 
38421
- // src/validate.ts
38422
- function parseOFW(schema, raw, ctx, mode = "lenient") {
38423
- const result = schema.safeParse(raw);
38424
- if (result.success) return result.data;
38425
- const issues = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
38426
- const message = `OFW response for ${ctx} failed validation: ${issues}`;
38427
- if (mode === "strict") throw new Error(message);
38428
- console.error(`[ofw-mcp] WARNING: ${message} \u2014 continuing with the raw response; fields derived from it may be missing or wrong.`);
38429
- return raw;
38430
- }
38431
-
38432
38548
  // src/tools/_shared.ts
38433
38549
  var jsonResponse = textResult;
38434
38550
  var textResponse = rawTextResult;
@@ -38460,19 +38576,17 @@ var PostMessagesResponseSchema = external_exports.looseObject({
38460
38576
  entityId: external_exports.number().optional()
38461
38577
  }).nullable();
38462
38578
  async function postMessageAndRefetch(client2, payload, detailSchema, ctx) {
38463
- const raw = parseOFW(
38579
+ const raw = parseLenient(
38464
38580
  PostMessagesResponseSchema,
38465
38581
  await client2.request("POST", "/pub/v3/messages", payload),
38466
- `POST /pub/v3/messages (${ctx})`,
38467
- "strict"
38582
+ { label: "ofw-mcp", context: `POST /pub/v3/messages (${ctx})`, mode: "strict" }
38468
38583
  );
38469
38584
  const id = typeof raw?.id === "number" ? raw.id : typeof raw?.entityId === "number" ? raw.entityId : null;
38470
38585
  if (id === null) return { id: null, detail: null, raw };
38471
- const detail = parseOFW(
38586
+ const detail = parseLenient(
38472
38587
  detailSchema,
38473
38588
  await client2.request("GET", `/pub/v3/messages/${id}`),
38474
- `GET /pub/v3/messages/{id} (${ctx})`,
38475
- "strict"
38589
+ { label: "ofw-mcp", context: `GET /pub/v3/messages/{id} (${ctx})`, mode: "strict" }
38476
38590
  );
38477
38591
  return { id, detail, raw };
38478
38592
  }
@@ -38499,6 +38613,44 @@ function registerUserTools(server, client2) {
38499
38613
  import { DatabaseSync } from "node:sqlite";
38500
38614
  import { mkdirSync, chmodSync, existsSync } from "node:fs";
38501
38615
  import { dirname as dirname2 } from "node:path";
38616
+
38617
+ // src/config.ts
38618
+ import { createHash } from "node:crypto";
38619
+ import { homedir as homedir3 } from "node:os";
38620
+ import { join as join4 } from "node:path";
38621
+ function readCacheIdentity() {
38622
+ return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
38623
+ }
38624
+ function getCacheDir() {
38625
+ const override = process.env.OFW_CACHE_DIR;
38626
+ if (override && override.trim().length > 0) return override.trim();
38627
+ return join4(homedir3(), ".cache", "ofw-mcp");
38628
+ }
38629
+ function getCacheDbPath() {
38630
+ const identity = readCacheIdentity();
38631
+ const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
38632
+ return join4(getCacheDir(), `${hash2}.db`);
38633
+ }
38634
+ function getAttachmentsDir() {
38635
+ const override = process.env.OFW_ATTACHMENTS_DIR;
38636
+ if (override && override.trim().length > 0) return override.trim();
38637
+ return join4(homedir3(), "Downloads", "ofw-mcp");
38638
+ }
38639
+ function getWriteMode() {
38640
+ const raw = process.env.OFW_WRITE_MODE;
38641
+ if (typeof raw !== "string" || raw.trim().length === 0) return "all";
38642
+ const mode = raw.trim().toLowerCase();
38643
+ if (mode === "none" || mode === "drafts" || mode === "all") return mode;
38644
+ console.error(
38645
+ `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
38646
+ );
38647
+ return "none";
38648
+ }
38649
+ function getDefaultInlineAttachments() {
38650
+ return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
38651
+ }
38652
+
38653
+ // src/cache.ts
38502
38654
  var instance = null;
38503
38655
  var SCHEMA_V1 = `
38504
38656
  CREATE TABLE IF NOT EXISTS messages (
@@ -38851,10 +39003,10 @@ var FileMetaSchema = external_exports.looseObject({
38851
39003
  fileSize: external_exports.number().optional()
38852
39004
  });
38853
39005
  async function fetchAttachmentMeta(client2, fileId, messageId) {
38854
- const meta3 = parseOFW(
39006
+ const meta3 = parseLenient(
38855
39007
  FileMetaSchema,
38856
39008
  await client2.request("GET", `/pub/v1/myfiles/${fileId}`),
38857
- "GET /pub/v1/myfiles/{fileId}"
39009
+ { label: "ofw-mcp", context: "GET /pub/v1/myfiles/{fileId}" }
38858
39010
  );
38859
39011
  upsertAttachmentForMessage({
38860
39012
  fileId: meta3.fileId ?? fileId,
@@ -38873,10 +39025,10 @@ var FoldersSchema = external_exports.looseObject({
38873
39025
  systemFolders: external_exports.array(external_exports.looseObject({ id: external_exports.string(), folderType: external_exports.string() })).optional()
38874
39026
  });
38875
39027
  async function resolveFolderIds(client2) {
38876
- const data = parseOFW(
39028
+ const data = parseLenient(
38877
39029
  FoldersSchema,
38878
39030
  await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true"),
38879
- "GET /pub/v1/messageFolders"
39031
+ { label: "ofw-mcp", context: "GET /pub/v1/messageFolders" }
38880
39032
  );
38881
39033
  const sys = data.systemFolders ?? [];
38882
39034
  const find = (type) => {
@@ -38912,10 +39064,10 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
38912
39064
  const unread = [];
38913
39065
  while (true) {
38914
39066
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
38915
- const list = parseOFW(
39067
+ const list = parseLenient(
38916
39068
  ListResponseSchema,
38917
39069
  await client2.request("GET", path),
38918
- `GET /pub/v3/messages?folders={${folder}}`
39070
+ { label: "ofw-mcp", context: `GET /pub/v3/messages?folders={${folder}}` }
38919
39071
  );
38920
39072
  const items = list.data ?? [];
38921
39073
  if (items.length === 0) break;
@@ -38931,10 +39083,10 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
38931
39083
  let fetchedBodyAt = null;
38932
39084
  let detailFileIds = [];
38933
39085
  if (shouldFetchBody) {
38934
- const detail = parseOFW(
39086
+ const detail = parseLenient(
38935
39087
  DetailResponseSchema,
38936
39088
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
38937
- "GET /pub/v3/messages/{id} (sync)"
39089
+ { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (sync)" }
38938
39090
  );
38939
39091
  body = detail.body ?? "";
38940
39092
  fetchedBodyAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -38994,10 +39146,10 @@ async function syncDrafts(client2, draftsFolderId) {
38994
39146
  let page = 1;
38995
39147
  while (true) {
38996
39148
  const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
38997
- const list = parseOFW(
39149
+ const list = parseLenient(
38998
39150
  DraftListResponseSchema,
38999
39151
  await client2.request("GET", path),
39000
- "GET /pub/v3/messages?folders={drafts}"
39152
+ { label: "ofw-mcp", context: "GET /pub/v3/messages?folders={drafts}" }
39001
39153
  );
39002
39154
  const pageItems = list.data ?? [];
39003
39155
  items.push(...pageItems);
@@ -39010,10 +39162,10 @@ async function syncDrafts(client2, draftsFolderId) {
39010
39162
  seenIds.add(item.id);
39011
39163
  const modifiedAt = item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString();
39012
39164
  const existing = getDraft(item.id);
39013
- const detail = parseOFW(
39165
+ const detail = parseLenient(
39014
39166
  DraftDetailSchema,
39015
39167
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
39016
- "GET /pub/v3/messages/{id} (drafts sync)"
39168
+ { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (drafts sync)" }
39017
39169
  );
39018
39170
  const row = {
39019
39171
  id: item.id,
@@ -39213,10 +39365,10 @@ function registerMessageTools(server, client2) {
39213
39365
  let attachments2 = listAttachmentsForMessage(id);
39214
39366
  if (attachments2.length === 0 && listDataHintsAtFiles(cached2.listData)) {
39215
39367
  try {
39216
- const detail2 = parseOFW(
39368
+ const detail2 = parseLenient(
39217
39369
  DetailFilesSchema,
39218
39370
  await client2.request("GET", `/pub/v3/messages/${id}`),
39219
- "GET /pub/v3/messages/{id} (attachment backfill)"
39371
+ { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (attachment backfill)" }
39220
39372
  );
39221
39373
  if (Array.isArray(detail2.files) && detail2.files.length > 0) {
39222
39374
  await fetchAttachmentMetaForMessage(client2, id, detail2.files);
@@ -39227,10 +39379,10 @@ function registerMessageTools(server, client2) {
39227
39379
  }
39228
39380
  return jsonResponse({ ...cached2, attachments: attachments2 });
39229
39381
  }
39230
- const detail = parseOFW(
39382
+ const detail = parseLenient(
39231
39383
  MessageDetailSchema,
39232
39384
  await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`),
39233
- "GET /pub/v3/messages/{id} (ofw_get_message)"
39385
+ { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (ofw_get_message)" }
39234
39386
  );
39235
39387
  const folder = cached2?.folder ?? "inbox";
39236
39388
  const row = {
@@ -39512,11 +39664,10 @@ ${text}` : text);
39512
39664
  form.append("label", args.label ?? fileName);
39513
39665
  form.append("fileName", fileName);
39514
39666
  form.append("shareClass", args.shareClass ?? "PRIVATE");
39515
- const meta3 = parseOFW(
39667
+ const meta3 = parseLenient(
39516
39668
  UploadedFileSchema,
39517
39669
  await client2.request("POST", "/pub/v3/myfiles/multipart", form),
39518
- "POST /pub/v3/myfiles/multipart (ofw_upload_attachment)",
39519
- "strict"
39670
+ { label: "ofw-mcp", context: "POST /pub/v3/myfiles/multipart (ofw_upload_attachment)", mode: "strict" }
39520
39671
  );
39521
39672
  upsertAttachmentForMessage({
39522
39673
  fileId: meta3.fileId,
@@ -39588,12 +39739,13 @@ ${text}` : text);
39588
39739
  } }] };
39589
39740
  }
39590
39741
  let dest;
39742
+ const safeName = basename(cached2.fileName);
39591
39743
  if (args.saveTo) {
39592
39744
  const isDirArg = args.saveTo.endsWith("/") || args.saveTo.endsWith("\\");
39593
39745
  const abs = expandPath2(args.saveTo);
39594
- dest = isDirArg ? join5(abs, `${fileId}-${cached2.fileName}`) : abs;
39746
+ dest = isDirArg ? join5(abs, `${fileId}-${safeName}`) : abs;
39595
39747
  } else {
39596
- dest = join5(getAttachmentsDir(), `${fileId}-${cached2.fileName}`);
39748
+ dest = join5(getAttachmentsDir(), `${fileId}-${safeName}`);
39597
39749
  }
39598
39750
  if (!args.force && cached2.downloadedPath === dest) {
39599
39751
  return jsonResponse({
@@ -39787,7 +39939,7 @@ process.emit = function(event, ...args) {
39787
39939
  };
39788
39940
  await runMcp({
39789
39941
  name: "ofw",
39790
- version: "2.4.2",
39942
+ version: "2.4.4",
39791
39943
  // x-release-please-version
39792
39944
  deps: client,
39793
39945
  tools: [
package/dist/client.js CHANGED
@@ -1,9 +1,8 @@
1
- import { loadDotenvSafely } from '@chrischall/mcp-utils';
1
+ import { loadDotenvSafely, parseBoolEnv, redactSecrets } from '@chrischall/mcp-utils';
2
2
  import { TokenManager } from '@chrischall/mcp-utils/session';
3
3
  import { dirname, join } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { resolveAuth } from './auth.js';
6
- import { parseBoolEnv } from './config.js';
7
6
  import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
8
7
  // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
9
8
  // bundle). loadDotenvSafely applies override:false + quiet:true and swallows a
@@ -32,13 +31,6 @@ function parseContentDispositionFilename(cd) {
32
31
  function debugLogEnabled() {
33
32
  return parseBoolEnv('OFW_DEBUG_LOG');
34
33
  }
35
- function redactHeaders(h) {
36
- const out = { ...h };
37
- /* v8 ignore next -- request headers always carry Authorization (set in request()); the guard is defensive for arbitrary header maps */
38
- if (out.Authorization)
39
- out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}…`;
40
- return out;
41
- }
42
34
  // Per-request timeout. Overridable via OFW_REQUEST_TIMEOUT_MS. The default
43
35
  // (30s) is comfortably above OFW's typical p99 but low enough that a stuck
44
36
  // upstream fails fast instead of burning the MCP client-side budget — which
@@ -148,7 +140,9 @@ export class OFWClient {
148
140
  ? `<FormData entries=${Array.from(body.keys()).join(',')}>`
149
141
  : JSON.stringify(body);
150
142
  console.error(`[ofw-debug] → ${method} ${url}${isRetry ? ' (retry)' : ''}`);
151
- console.error(`[ofw-debug] headers: ${JSON.stringify(redactHeaders(headers))}`);
143
+ // redactSecrets scrubs the Bearer token (and any other secret shapes)
144
+ // from the serialized header map — shared fleet redaction, never bespoke.
145
+ console.error(`[ofw-debug] headers: ${redactSecrets(JSON.stringify(headers))}`);
152
146
  console.error(`[ofw-debug] body: ${bodyPreview}`);
153
147
  }
154
148
  // AbortController + setTimeout (not AbortSignal.timeout) so vitest fake
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- import { parseBoolEnv as parseBoolEnvUtil } from '@chrischall/mcp-utils';
4
+ import { parseBoolEnv, readEnvVar } from '@chrischall/mcp-utils';
5
5
  // Cache identity drives the per-user SQLite DB filename. Order of preference:
6
6
  // 1. OFW_CACHE_IDENTITY — explicit override for users who want to label the
7
7
  // cache themselves (e.g. when authing via fetchproxy and OFW_USERNAME is
@@ -11,13 +11,7 @@ import { parseBoolEnv as parseBoolEnvUtil } from '@chrischall/mcp-utils';
11
11
  // Single-user installs are fine on this; multi-account users should set
12
12
  // OFW_CACHE_IDENTITY explicitly so their caches don't collide.
13
13
  function readCacheIdentity() {
14
- const explicit = process.env.OFW_CACHE_IDENTITY;
15
- if (typeof explicit === 'string' && explicit.trim().length > 0)
16
- return explicit.trim();
17
- const username = process.env.OFW_USERNAME;
18
- if (typeof username === 'string' && username.trim().length > 0)
19
- return username.trim();
20
- return '_default';
14
+ return readEnvVar('OFW_CACHE_IDENTITY') ?? readEnvVar('OFW_USERNAME') ?? '_default';
21
15
  }
22
16
  export function getCacheDir() {
23
17
  const override = process.env.OFW_CACHE_DIR;
@@ -41,19 +35,6 @@ export function getAttachmentsDir() {
41
35
  // location across macOS/Linux/Windows.
42
36
  return join(homedir(), 'Downloads', 'ofw-mcp');
43
37
  }
44
- /**
45
- * True when a boolean-shaped env var is set to "1", "true", "yes", or "on"
46
- * (case-insensitive, trimmed). Anything else — unset, empty, or other
47
- * values — is false. Used for OFW_INLINE_ATTACHMENTS, OFW_DISABLE_FETCHPROXY,
48
- * OFW_DEBUG_LOG, etc.
49
- *
50
- * Delegates to @chrischall/mcp-utils' `parseBoolEnv` (which also recognizes
51
- * the falsy set 0/false/no/off — behavior-equivalent here since callers only
52
- * care about the truthy case and everything else defaults to false).
53
- */
54
- export function parseBoolEnv(name) {
55
- return parseBoolEnvUtil(name);
56
- }
57
38
  /**
58
39
  * Gate for write-tool registration, read at registration time (startup).
59
40
  *
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import { registerJournalTools } from './tools/journal.js';
24
24
  // always succeeds before any credential check runs.
25
25
  await runMcp({
26
26
  name: 'ofw',
27
- version: '2.4.2', // x-release-please-version
27
+ version: '2.4.4', // x-release-please-version
28
28
  deps: client,
29
29
  tools: [
30
30
  registerUserTools,
package/dist/sync.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { setMeta, upsertMessage, getMessage, deleteMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
2
2
  import { z } from 'zod';
3
3
  import { ApiRecipientSchema, mapRecipients } from './tools/_shared.js';
4
- import { parseOFW } from './validate.js';
4
+ import { parseLenient } from '@chrischall/mcp-utils';
5
5
  // Each OFW message detail returns `files: [fileId, ...]`. We fetch the metadata
6
6
  // for each file id (cheap JSON call) so the model can see filenames/mime types
7
7
  // without downloading bytes. Bytes are pulled lazily by ofw_download_attachment.
@@ -22,7 +22,7 @@ const FileMetaSchema = z.looseObject({
22
22
  // best-effort helper below; callers that need the result (download tool) let
23
23
  // the throw propagate.
24
24
  export async function fetchAttachmentMeta(client, fileId, messageId) {
25
- const meta = parseOFW(FileMetaSchema, await client.request('GET', `/pub/v1/myfiles/${fileId}`), 'GET /pub/v1/myfiles/{fileId}');
25
+ const meta = parseLenient(FileMetaSchema, await client.request('GET', `/pub/v1/myfiles/${fileId}`), { label: 'ofw-mcp', context: 'GET /pub/v1/myfiles/{fileId}' });
26
26
  upsertAttachmentForMessage({
27
27
  fileId: meta.fileId ?? fileId,
28
28
  fileName: meta.fileName ?? `file-${fileId}`,
@@ -44,7 +44,7 @@ const FoldersSchema = z.looseObject({
44
44
  systemFolders: z.array(z.looseObject({ id: z.string(), folderType: z.string() })).optional(),
45
45
  });
46
46
  export async function resolveFolderIds(client) {
47
- const data = parseOFW(FoldersSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), 'GET /pub/v1/messageFolders');
47
+ const data = parseLenient(FoldersSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), { label: 'ofw-mcp', context: 'GET /pub/v1/messageFolders' });
48
48
  const sys = data.systemFolders ?? [];
49
49
  const find = (type) => {
50
50
  const f = sys.find((x) => x.folderType === type);
@@ -83,7 +83,7 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
83
83
  const unread = [];
84
84
  while (true) {
85
85
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
86
- const list = parseOFW(ListResponseSchema, await client.request('GET', path), `GET /pub/v3/messages?folders={${folder}}`);
86
+ const list = parseLenient(ListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: `GET /pub/v3/messages?folders={${folder}}` });
87
87
  const items = list.data ?? [];
88
88
  if (items.length === 0)
89
89
  break;
@@ -101,7 +101,7 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
101
101
  let fetchedBodyAt = null;
102
102
  let detailFileIds = [];
103
103
  if (shouldFetchBody) {
104
- const detail = parseOFW(DetailResponseSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), 'GET /pub/v3/messages/{id} (sync)');
104
+ const detail = parseLenient(DetailResponseSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (sync)' });
105
105
  body = detail.body ?? '';
106
106
  fetchedBodyAt = new Date().toISOString();
107
107
  if (Array.isArray(detail.files) && detail.files.length > 0) {
@@ -170,7 +170,7 @@ export async function syncDrafts(client, draftsFolderId) {
170
170
  let page = 1;
171
171
  while (true) {
172
172
  const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
173
- const list = parseOFW(DraftListResponseSchema, await client.request('GET', path), 'GET /pub/v3/messages?folders={drafts}');
173
+ const list = parseLenient(DraftListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: 'GET /pub/v3/messages?folders={drafts}' });
174
174
  const pageItems = list.data ?? [];
175
175
  items.push(...pageItems);
176
176
  if (pageItems.length < 50)
@@ -186,7 +186,7 @@ export async function syncDrafts(client, draftsFolderId) {
186
186
  // timestamp for drafts — direct UI edits don't bump it — so we can't
187
187
  // use it to skip the detail fetch. Always re-fetch; drafts are few.
188
188
  const existing = getDraft(item.id);
189
- const detail = parseOFW(DraftDetailSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), 'GET /pub/v3/messages/{id} (drafts sync)');
189
+ const detail = parseLenient(DraftDetailSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (drafts sync)' });
190
190
  const row = {
191
191
  id: item.id,
192
192
  subject: detail.subject ?? item.subject ?? '(no subject)',
@@ -1,6 +1,6 @@
1
1
  import { expandPath as expandPathUtil, rawTextResult, textResult } from '@chrischall/mcp-utils';
2
2
  import { z } from 'zod';
3
- import { parseOFW } from '../validate.js';
3
+ import { parseLenient } from '@chrischall/mcp-utils';
4
4
  // Pretty-printed JSON tool result. Thin wrapper over @chrischall/mcp-utils'
5
5
  // `textResult` so the rest of the codebase keeps the local name.
6
6
  export const jsonResponse = textResult;
@@ -77,14 +77,23 @@ const PostMessagesResponseSchema = z.looseObject({
77
77
  * `if (result.id !== null)`. When id is null (no id field in the
78
78
  * response — never observed in production, but defensive), `raw`
79
79
  * carries the POST response so the caller can still surface it.
80
+ *
81
+ * The generic is parametrized on the schema's OUTPUT type `T`
82
+ * (`detailSchema: z.ZodType<T>`, `detail: T`) rather than on the schema
83
+ * type itself. This mirrors `parseLenient`'s own signature
84
+ * (`<T>(schema: ZodType<T>, …): T`) exactly, so `T` is inferred straight
85
+ * from the schema and flows into the return type with no `as` cast — the
86
+ * compiler verifies that `detail` matches `detailSchema`'s output. (A
87
+ * `<S extends z.ZodType>` constraint would widen the output to `unknown`
88
+ * and force a cast at this call site.)
80
89
  */
81
90
  export async function postMessageAndRefetch(client, payload, detailSchema, ctx) {
82
- const raw = parseOFW(PostMessagesResponseSchema, await client.request('POST', '/pub/v3/messages', payload), `POST /pub/v3/messages (${ctx})`, 'strict');
91
+ const raw = parseLenient(PostMessagesResponseSchema, await client.request('POST', '/pub/v3/messages', payload), { label: 'ofw-mcp', context: `POST /pub/v3/messages (${ctx})`, mode: 'strict' });
83
92
  const id = typeof raw?.id === 'number' ? raw.id
84
93
  : typeof raw?.entityId === 'number' ? raw.entityId
85
94
  : null;
86
95
  if (id === null)
87
96
  return { id: null, detail: null, raw };
88
- const detail = parseOFW(detailSchema, await client.request('GET', `/pub/v3/messages/${id}`), `GET /pub/v3/messages/{id} (${ctx})`, 'strict');
97
+ const detail = parseLenient(detailSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: `GET /pub/v3/messages/{id} (${ctx})`, mode: 'strict' });
89
98
  return { id, detail, raw };
90
99
  }
@@ -6,7 +6,7 @@ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
6
  import { basename, dirname, extname, join } from 'node:path';
7
7
  import { fileBlob } from '@chrischall/mcp-utils';
8
8
  import { ApiRecipientSchema, expandPath, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
9
- import { parseOFW } from '../validate.js';
9
+ import { parseLenient } from '@chrischall/mcp-utils';
10
10
  // Schemas for the load-bearing fields of each /pub/v3 response this file
11
11
  // reads (issue #83). Loose: unknown keys pass through into cached listData.
12
12
  const DateSchema = z.looseObject({ dateTime: z.string() });
@@ -193,7 +193,7 @@ export function registerMessageTools(server, client) {
193
193
  // OFW state isn't changing).
194
194
  if (attachments.length === 0 && listDataHintsAtFiles(cached.listData)) {
195
195
  try {
196
- const detail = parseOFW(DetailFilesSchema, await client.request('GET', `/pub/v3/messages/${id}`), 'GET /pub/v3/messages/{id} (attachment backfill)');
196
+ const detail = parseLenient(DetailFilesSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (attachment backfill)' });
197
197
  if (Array.isArray(detail.files) && detail.files.length > 0) {
198
198
  await fetchAttachmentMetaForMessage(client, id, detail.files);
199
199
  attachments = listAttachmentsForMessage(id);
@@ -205,7 +205,7 @@ export function registerMessageTools(server, client) {
205
205
  }
206
206
  return jsonResponse({ ...cached, attachments });
207
207
  }
208
- const detail = parseOFW(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`), 'GET /pub/v3/messages/{id} (ofw_get_message)');
208
+ const detail = parseLenient(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (ofw_get_message)' });
209
209
  const folder = cached?.folder ?? 'inbox';
210
210
  const row = {
211
211
  id: detail.id,
@@ -508,7 +508,7 @@ export function registerMessageTools(server, client) {
508
508
  form.append('label', args.label ?? fileName);
509
509
  form.append('fileName', fileName);
510
510
  form.append('shareClass', args.shareClass ?? 'PRIVATE');
511
- const meta = parseOFW(UploadedFileSchema, await client.request('POST', '/pub/v3/myfiles/multipart', form), 'POST /pub/v3/myfiles/multipart (ofw_upload_attachment)', 'strict');
511
+ const meta = parseLenient(UploadedFileSchema, await client.request('POST', '/pub/v3/myfiles/multipart', form), { label: 'ofw-mcp', context: 'POST /pub/v3/myfiles/multipart (ofw_upload_attachment)', mode: 'strict' });
512
512
  // Cache metadata so subsequent ofw_get_message calls can surface it and
513
513
  // ofw_download_attachment can short-circuit. messageId is 0 (the
514
514
  // not-yet-linked sentinel) until a message actually references this file.
@@ -583,14 +583,19 @@ export function registerMessageTools(server, client) {
583
583
  } }] };
584
584
  }
585
585
  let dest;
586
+ // The filename comes from OFW file metadata — i.e. it is controlled by the
587
+ // co-parent who uploaded the attachment. basename() it before interpolating
588
+ // into a path so a crafted `../…` name can't escape the target directory
589
+ // (the upload path at :549 already applies basename to its input).
590
+ const safeName = basename(cached.fileName);
586
591
  if (args.saveTo) {
587
592
  // Treat saveTo as a directory if it ends with a separator; otherwise as a full path.
588
593
  const isDirArg = args.saveTo.endsWith('/') || args.saveTo.endsWith('\\');
589
594
  const abs = expandPath(args.saveTo);
590
- dest = isDirArg ? join(abs, `${fileId}-${cached.fileName}`) : abs;
595
+ dest = isDirArg ? join(abs, `${fileId}-${safeName}`) : abs;
591
596
  }
592
597
  else {
593
- dest = join(getAttachmentsDir(), `${fileId}-${cached.fileName}`);
598
+ dest = join(getAttachmentsDir(), `${fileId}-${safeName}`);
594
599
  }
595
600
  if (!args.force && cached.downloadedPath === dest) {
596
601
  return jsonResponse({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.4.2",
3
+ "version": "2.4.4",
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)",
@@ -32,14 +32,14 @@
32
32
  "test:watch": "vitest"
33
33
  },
34
34
  "dependencies": {
35
- "@chrischall/mcp-utils": "^0.9.0",
35
+ "@chrischall/mcp-utils": "^0.12.0",
36
36
  "@fetchproxy/bootstrap": "^1.3.0",
37
37
  "@modelcontextprotocol/sdk": "^1.29.0",
38
38
  "dotenv": "^17.4.2",
39
39
  "zod": "^4.4.3"
40
40
  },
41
41
  "devDependencies": {
42
- "@types/node": "^25.9.1",
42
+ "@types/node": "^26.0.0",
43
43
  "@vitest/coverage-v8": "^4.1.7",
44
44
  "esbuild": "^0.28.0",
45
45
  "typescript": "^6.0.3",
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.4.2",
9
+ "version": "2.4.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.4.2",
14
+ "version": "2.4.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
package/dist/validate.js DELETED
@@ -1,35 +0,0 @@
1
- /**
2
- * Validate an OFW API response against a zod schema at the call site.
3
- *
4
- * Every OFW endpoint is reverse-engineered and undocumented, so a backend
5
- * change on their side would otherwise flow `undefined` silently into the
6
- * SQLite cache and persist (issue #83). Schemas are `.looseObject(...)`
7
- * covering ONLY the fields the code actually reads — cosmetic API additions
8
- * pass through untouched (and stay present in the parsed output, which
9
- * matters for `listData`/`metadata` blobs cached verbatim).
10
- *
11
- * Two modes, chosen per call site:
12
- * - `'lenient'` (default) — read/sync paths. On mismatch, log a structured
13
- * warning to stderr naming the endpoint and fields, then return the RAW
14
- * response unchanged so the existing `??` fallbacks keep the tool useful.
15
- * - `'strict'` — write paths (send/save_draft verification, upload). On
16
- * mismatch, throw: proceeding on an unverifiable response risks deleting
17
- * a draft, mis-reporting a send, or caching an unusable fileId.
18
- *
19
- * The error/warning text is deliberately precise ("date.dateTime: expected
20
- * string…") — it's the failure signal a maintainer (human or Claude) fixes
21
- * in one session, vs. "some messages show the wrong date sometimes".
22
- */
23
- export function parseOFW(schema, raw, ctx, mode = 'lenient') {
24
- const result = schema.safeParse(raw);
25
- if (result.success)
26
- return result.data;
27
- const issues = result.error.issues
28
- .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
29
- .join('; ');
30
- const message = `OFW response for ${ctx} failed validation: ${issues}`;
31
- if (mode === 'strict')
32
- throw new Error(message);
33
- console.error(`[ofw-mcp] WARNING: ${message} — continuing with the raw response; fields derived from it may be missing or wrong.`);
34
- return raw;
35
- }