ofw-mcp 2.4.3 → 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.3"
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.3",
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.3",
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
@@ -38243,53 +38282,10 @@ async function loginWithPassword(username, password) {
38243
38282
  };
38244
38283
  }
38245
38284
 
38246
- // src/config.ts
38247
- import { createHash } from "node:crypto";
38248
- import { homedir as homedir3 } from "node:os";
38249
- import { join as join3 } from "node:path";
38250
- function readCacheIdentity() {
38251
- const explicit = process.env.OFW_CACHE_IDENTITY;
38252
- if (typeof explicit === "string" && explicit.trim().length > 0) return explicit.trim();
38253
- const username = process.env.OFW_USERNAME;
38254
- if (typeof username === "string" && username.trim().length > 0) return username.trim();
38255
- return "_default";
38256
- }
38257
- function getCacheDir() {
38258
- const override = process.env.OFW_CACHE_DIR;
38259
- if (override && override.trim().length > 0) return override.trim();
38260
- return join3(homedir3(), ".cache", "ofw-mcp");
38261
- }
38262
- function getCacheDbPath() {
38263
- const identity = readCacheIdentity();
38264
- const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
38265
- return join3(getCacheDir(), `${hash2}.db`);
38266
- }
38267
- function getAttachmentsDir() {
38268
- const override = process.env.OFW_ATTACHMENTS_DIR;
38269
- if (override && override.trim().length > 0) return override.trim();
38270
- return join3(homedir3(), "Downloads", "ofw-mcp");
38271
- }
38272
- function parseBoolEnv2(name) {
38273
- return parseBoolEnv(name);
38274
- }
38275
- function getWriteMode() {
38276
- const raw = process.env.OFW_WRITE_MODE;
38277
- if (typeof raw !== "string" || raw.trim().length === 0) return "all";
38278
- const mode = raw.trim().toLowerCase();
38279
- if (mode === "none" || mode === "drafts" || mode === "all") return mode;
38280
- console.error(
38281
- `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
38282
- );
38283
- return "none";
38284
- }
38285
- function getDefaultInlineAttachments() {
38286
- return parseBoolEnv2("OFW_INLINE_ATTACHMENTS");
38287
- }
38288
-
38289
38285
  // package.json
38290
38286
  var package_default = {
38291
38287
  name: "ofw-mcp",
38292
- version: "2.4.3",
38288
+ version: "2.4.4",
38293
38289
  license: "MIT",
38294
38290
  mcpName: "io.github.chrischall/ofw-mcp",
38295
38291
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -38321,14 +38317,14 @@ var package_default = {
38321
38317
  "test:watch": "vitest"
38322
38318
  },
38323
38319
  dependencies: {
38324
- "@chrischall/mcp-utils": "^0.10.3",
38320
+ "@chrischall/mcp-utils": "^0.12.0",
38325
38321
  "@fetchproxy/bootstrap": "^1.3.0",
38326
38322
  "@modelcontextprotocol/sdk": "^1.29.0",
38327
38323
  dotenv: "^17.4.2",
38328
38324
  zod: "^4.4.3"
38329
38325
  },
38330
38326
  devDependencies: {
38331
- "@types/node": "^25.9.1",
38327
+ "@types/node": "^26.0.0",
38332
38328
  "@vitest/coverage-v8": "^4.1.7",
38333
38329
  esbuild: "^0.28.0",
38334
38330
  typescript: "^6.0.3",
@@ -38337,15 +38333,12 @@ var package_default = {
38337
38333
  };
38338
38334
 
38339
38335
  // src/auth.ts
38340
- function readEnv(key) {
38341
- return readEnvVar(key);
38342
- }
38343
38336
  function fetchproxyDisabled() {
38344
- return parseBoolEnv2("OFW_DISABLE_FETCHPROXY");
38337
+ return parseBoolEnv("OFW_DISABLE_FETCHPROXY");
38345
38338
  }
38346
38339
  async function resolveAuth() {
38347
- const username = readEnv("OFW_USERNAME");
38348
- const password = readEnv("OFW_PASSWORD");
38340
+ const username = readEnvVar("OFW_USERNAME");
38341
+ const password = readEnvVar("OFW_PASSWORD");
38349
38342
  if (username && password) {
38350
38343
  const { token, expiresAt } = await loginWithPassword(username, password);
38351
38344
  return { token, expiresAt, source: "env" };
@@ -38402,7 +38395,7 @@ async function resolveAuth() {
38402
38395
 
38403
38396
  // src/client.ts
38404
38397
  var __dirname = dirname(fileURLToPath(import.meta.url));
38405
- await loadDotenvSafely({ path: join4(__dirname, "..", ".env") });
38398
+ await loadDotenvSafely({ path: join3(__dirname, "..", ".env") });
38406
38399
  function parseContentDispositionFilename(cd) {
38407
38400
  const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
38408
38401
  if (extMatch) {
@@ -38417,12 +38410,7 @@ function parseContentDispositionFilename(cd) {
38417
38410
  return m ? m[1] : null;
38418
38411
  }
38419
38412
  function debugLogEnabled() {
38420
- return parseBoolEnv2("OFW_DEBUG_LOG");
38421
- }
38422
- function redactHeaders(h) {
38423
- const out = { ...h };
38424
- if (out.Authorization) out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}\u2026`;
38425
- return out;
38413
+ return parseBoolEnv("OFW_DEBUG_LOG");
38426
38414
  }
38427
38415
  var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
38428
38416
  function getRequestTimeoutMs() {
@@ -38517,7 +38505,7 @@ var OFWClient = class {
38517
38505
  if (debugLogEnabled()) {
38518
38506
  const bodyPreview = body === void 0 ? "<none>" : isFormData ? `<FormData entries=${Array.from(body.keys()).join(",")}>` : JSON.stringify(body);
38519
38507
  console.error(`[ofw-debug] \u2192 ${method} ${url2}${isRetry ? " (retry)" : ""}`);
38520
- console.error(`[ofw-debug] headers: ${JSON.stringify(redactHeaders(headers))}`);
38508
+ console.error(`[ofw-debug] headers: ${redactSecrets(JSON.stringify(headers))}`);
38521
38509
  console.error(`[ofw-debug] body: ${bodyPreview}`);
38522
38510
  }
38523
38511
  const timeoutMs = getRequestTimeoutMs();
@@ -38557,17 +38545,6 @@ var OFWClient = class {
38557
38545
  };
38558
38546
  var client = new OFWClient();
38559
38547
 
38560
- // src/validate.ts
38561
- function parseOFW(schema, raw, ctx, mode = "lenient") {
38562
- const result = schema.safeParse(raw);
38563
- if (result.success) return result.data;
38564
- const issues = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
38565
- const message = `OFW response for ${ctx} failed validation: ${issues}`;
38566
- if (mode === "strict") throw new Error(message);
38567
- console.error(`[ofw-mcp] WARNING: ${message} \u2014 continuing with the raw response; fields derived from it may be missing or wrong.`);
38568
- return raw;
38569
- }
38570
-
38571
38548
  // src/tools/_shared.ts
38572
38549
  var jsonResponse = textResult;
38573
38550
  var textResponse = rawTextResult;
@@ -38599,19 +38576,17 @@ var PostMessagesResponseSchema = external_exports.looseObject({
38599
38576
  entityId: external_exports.number().optional()
38600
38577
  }).nullable();
38601
38578
  async function postMessageAndRefetch(client2, payload, detailSchema, ctx) {
38602
- const raw = parseOFW(
38579
+ const raw = parseLenient(
38603
38580
  PostMessagesResponseSchema,
38604
38581
  await client2.request("POST", "/pub/v3/messages", payload),
38605
- `POST /pub/v3/messages (${ctx})`,
38606
- "strict"
38582
+ { label: "ofw-mcp", context: `POST /pub/v3/messages (${ctx})`, mode: "strict" }
38607
38583
  );
38608
38584
  const id = typeof raw?.id === "number" ? raw.id : typeof raw?.entityId === "number" ? raw.entityId : null;
38609
38585
  if (id === null) return { id: null, detail: null, raw };
38610
- const detail = parseOFW(
38586
+ const detail = parseLenient(
38611
38587
  detailSchema,
38612
38588
  await client2.request("GET", `/pub/v3/messages/${id}`),
38613
- `GET /pub/v3/messages/{id} (${ctx})`,
38614
- "strict"
38589
+ { label: "ofw-mcp", context: `GET /pub/v3/messages/{id} (${ctx})`, mode: "strict" }
38615
38590
  );
38616
38591
  return { id, detail, raw };
38617
38592
  }
@@ -38638,6 +38613,44 @@ function registerUserTools(server, client2) {
38638
38613
  import { DatabaseSync } from "node:sqlite";
38639
38614
  import { mkdirSync, chmodSync, existsSync } from "node:fs";
38640
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
38641
38654
  var instance = null;
38642
38655
  var SCHEMA_V1 = `
38643
38656
  CREATE TABLE IF NOT EXISTS messages (
@@ -38990,10 +39003,10 @@ var FileMetaSchema = external_exports.looseObject({
38990
39003
  fileSize: external_exports.number().optional()
38991
39004
  });
38992
39005
  async function fetchAttachmentMeta(client2, fileId, messageId) {
38993
- const meta3 = parseOFW(
39006
+ const meta3 = parseLenient(
38994
39007
  FileMetaSchema,
38995
39008
  await client2.request("GET", `/pub/v1/myfiles/${fileId}`),
38996
- "GET /pub/v1/myfiles/{fileId}"
39009
+ { label: "ofw-mcp", context: "GET /pub/v1/myfiles/{fileId}" }
38997
39010
  );
38998
39011
  upsertAttachmentForMessage({
38999
39012
  fileId: meta3.fileId ?? fileId,
@@ -39012,10 +39025,10 @@ var FoldersSchema = external_exports.looseObject({
39012
39025
  systemFolders: external_exports.array(external_exports.looseObject({ id: external_exports.string(), folderType: external_exports.string() })).optional()
39013
39026
  });
39014
39027
  async function resolveFolderIds(client2) {
39015
- const data = parseOFW(
39028
+ const data = parseLenient(
39016
39029
  FoldersSchema,
39017
39030
  await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true"),
39018
- "GET /pub/v1/messageFolders"
39031
+ { label: "ofw-mcp", context: "GET /pub/v1/messageFolders" }
39019
39032
  );
39020
39033
  const sys = data.systemFolders ?? [];
39021
39034
  const find = (type) => {
@@ -39051,10 +39064,10 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39051
39064
  const unread = [];
39052
39065
  while (true) {
39053
39066
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
39054
- const list = parseOFW(
39067
+ const list = parseLenient(
39055
39068
  ListResponseSchema,
39056
39069
  await client2.request("GET", path),
39057
- `GET /pub/v3/messages?folders={${folder}}`
39070
+ { label: "ofw-mcp", context: `GET /pub/v3/messages?folders={${folder}}` }
39058
39071
  );
39059
39072
  const items = list.data ?? [];
39060
39073
  if (items.length === 0) break;
@@ -39070,10 +39083,10 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39070
39083
  let fetchedBodyAt = null;
39071
39084
  let detailFileIds = [];
39072
39085
  if (shouldFetchBody) {
39073
- const detail = parseOFW(
39086
+ const detail = parseLenient(
39074
39087
  DetailResponseSchema,
39075
39088
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
39076
- "GET /pub/v3/messages/{id} (sync)"
39089
+ { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (sync)" }
39077
39090
  );
39078
39091
  body = detail.body ?? "";
39079
39092
  fetchedBodyAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -39133,10 +39146,10 @@ async function syncDrafts(client2, draftsFolderId) {
39133
39146
  let page = 1;
39134
39147
  while (true) {
39135
39148
  const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
39136
- const list = parseOFW(
39149
+ const list = parseLenient(
39137
39150
  DraftListResponseSchema,
39138
39151
  await client2.request("GET", path),
39139
- "GET /pub/v3/messages?folders={drafts}"
39152
+ { label: "ofw-mcp", context: "GET /pub/v3/messages?folders={drafts}" }
39140
39153
  );
39141
39154
  const pageItems = list.data ?? [];
39142
39155
  items.push(...pageItems);
@@ -39149,10 +39162,10 @@ async function syncDrafts(client2, draftsFolderId) {
39149
39162
  seenIds.add(item.id);
39150
39163
  const modifiedAt = item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString();
39151
39164
  const existing = getDraft(item.id);
39152
- const detail = parseOFW(
39165
+ const detail = parseLenient(
39153
39166
  DraftDetailSchema,
39154
39167
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
39155
- "GET /pub/v3/messages/{id} (drafts sync)"
39168
+ { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (drafts sync)" }
39156
39169
  );
39157
39170
  const row = {
39158
39171
  id: item.id,
@@ -39352,10 +39365,10 @@ function registerMessageTools(server, client2) {
39352
39365
  let attachments2 = listAttachmentsForMessage(id);
39353
39366
  if (attachments2.length === 0 && listDataHintsAtFiles(cached2.listData)) {
39354
39367
  try {
39355
- const detail2 = parseOFW(
39368
+ const detail2 = parseLenient(
39356
39369
  DetailFilesSchema,
39357
39370
  await client2.request("GET", `/pub/v3/messages/${id}`),
39358
- "GET /pub/v3/messages/{id} (attachment backfill)"
39371
+ { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (attachment backfill)" }
39359
39372
  );
39360
39373
  if (Array.isArray(detail2.files) && detail2.files.length > 0) {
39361
39374
  await fetchAttachmentMetaForMessage(client2, id, detail2.files);
@@ -39366,10 +39379,10 @@ function registerMessageTools(server, client2) {
39366
39379
  }
39367
39380
  return jsonResponse({ ...cached2, attachments: attachments2 });
39368
39381
  }
39369
- const detail = parseOFW(
39382
+ const detail = parseLenient(
39370
39383
  MessageDetailSchema,
39371
39384
  await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`),
39372
- "GET /pub/v3/messages/{id} (ofw_get_message)"
39385
+ { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (ofw_get_message)" }
39373
39386
  );
39374
39387
  const folder = cached2?.folder ?? "inbox";
39375
39388
  const row = {
@@ -39651,11 +39664,10 @@ ${text}` : text);
39651
39664
  form.append("label", args.label ?? fileName);
39652
39665
  form.append("fileName", fileName);
39653
39666
  form.append("shareClass", args.shareClass ?? "PRIVATE");
39654
- const meta3 = parseOFW(
39667
+ const meta3 = parseLenient(
39655
39668
  UploadedFileSchema,
39656
39669
  await client2.request("POST", "/pub/v3/myfiles/multipart", form),
39657
- "POST /pub/v3/myfiles/multipart (ofw_upload_attachment)",
39658
- "strict"
39670
+ { label: "ofw-mcp", context: "POST /pub/v3/myfiles/multipart (ofw_upload_attachment)", mode: "strict" }
39659
39671
  );
39660
39672
  upsertAttachmentForMessage({
39661
39673
  fileId: meta3.fileId,
@@ -39727,12 +39739,13 @@ ${text}` : text);
39727
39739
  } }] };
39728
39740
  }
39729
39741
  let dest;
39742
+ const safeName = basename(cached2.fileName);
39730
39743
  if (args.saveTo) {
39731
39744
  const isDirArg = args.saveTo.endsWith("/") || args.saveTo.endsWith("\\");
39732
39745
  const abs = expandPath2(args.saveTo);
39733
- dest = isDirArg ? join5(abs, `${fileId}-${cached2.fileName}`) : abs;
39746
+ dest = isDirArg ? join5(abs, `${fileId}-${safeName}`) : abs;
39734
39747
  } else {
39735
- dest = join5(getAttachmentsDir(), `${fileId}-${cached2.fileName}`);
39748
+ dest = join5(getAttachmentsDir(), `${fileId}-${safeName}`);
39736
39749
  }
39737
39750
  if (!args.force && cached2.downloadedPath === dest) {
39738
39751
  return jsonResponse({
@@ -39926,7 +39939,7 @@ process.emit = function(event, ...args) {
39926
39939
  };
39927
39940
  await runMcp({
39928
39941
  name: "ofw",
39929
- version: "2.4.3",
39942
+ version: "2.4.4",
39930
39943
  // x-release-please-version
39931
39944
  deps: client,
39932
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.3', // 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.3",
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.10.3",
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.3",
9
+ "version": "2.4.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.4.3",
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
- }