ofw-mcp 2.3.0 → 2.3.1

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.3.0"
9
+ "version": "2.3.1"
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.3.0",
17
+ "version": "2.3.1",
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.3.0",
4
+ "version": "2.3.1",
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,28 +45,21 @@
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
49
  import { bootstrap } from '@fetchproxy/bootstrap';
49
- import { classifyBridgeError } from '@fetchproxy/server';
50
+ import { classifyBridgeError } from '@chrischall/mcp-utils/fetchproxy';
50
51
  import { loginWithPassword } from './auth-password.js';
51
52
  import { parseBoolEnv } from './config.js';
52
53
  import pkg from '../package.json' with { type: 'json' };
53
54
  /**
54
55
  * Read an env var, trim, and treat blank / `${UNEXPANDED}` placeholders as
55
56
  * unset. Defends against MCP hosts that pass `.mcp.json` env blocks through
56
- * without variable expansion.
57
+ * without variable expansion. Delegates to @chrischall/mcp-utils' `readEnvVar`,
58
+ * which applies the identical sanitization (blank, `undefined`/`null`,
59
+ * `${...}` placeholder → unset).
57
60
  */
58
61
  function readEnv(key) {
59
- const raw = process.env[key];
60
- if (typeof raw !== 'string')
61
- return undefined;
62
- const trimmed = raw.trim();
63
- if (trimmed.length === 0)
64
- return undefined;
65
- if (trimmed === 'undefined' || trimmed === 'null')
66
- return undefined;
67
- if (/^\$\{[^}]*\}$/.test(trimmed))
68
- return undefined;
69
- return trimmed;
62
+ return readEnvVar(key);
70
63
  }
71
64
  /** True if the user has explicitly disabled the fetchproxy fallback. */
72
65
  function fetchproxyDisabled() {
package/dist/bundle.js CHANGED
@@ -10342,10 +10342,10 @@ var require_websocket_server = __commonJS({
10342
10342
  process.nextTick(emitClose, this);
10343
10343
  }
10344
10344
  } else {
10345
- const server2 = this._server;
10345
+ const server = this._server;
10346
10346
  this._removeListeners();
10347
10347
  this._removeListeners = this._server = null;
10348
- server2.close(() => {
10348
+ server.close(() => {
10349
10349
  emitClose(this);
10350
10350
  });
10351
10351
  }
@@ -10530,17 +10530,17 @@ var require_websocket_server = __commonJS({
10530
10530
  }
10531
10531
  };
10532
10532
  module.exports = WebSocketServer2;
10533
- function addListeners(server2, map2) {
10534
- for (const event of Object.keys(map2)) server2.on(event, map2[event]);
10533
+ function addListeners(server, map2) {
10534
+ for (const event of Object.keys(map2)) server.on(event, map2[event]);
10535
10535
  return function removeListeners() {
10536
10536
  for (const event of Object.keys(map2)) {
10537
- server2.removeListener(event, map2[event]);
10537
+ server.removeListener(event, map2[event]);
10538
10538
  }
10539
10539
  };
10540
10540
  }
10541
- function emitClose(server2) {
10542
- server2._state = CLOSED;
10543
- server2.emit("close");
10541
+ function emitClose(server) {
10542
+ server._state = CLOSED;
10543
+ server.emit("close");
10544
10544
  }
10545
10545
  function socketOnError() {
10546
10546
  this.destroy();
@@ -10559,11 +10559,11 @@ var require_websocket_server = __commonJS({
10559
10559
  ` + Object.keys(headers).map((h) => `${h}: ${headers[h]}`).join("\r\n") + "\r\n\r\n" + message
10560
10560
  );
10561
10561
  }
10562
- function abortHandshakeOrEmitwsClientError(server2, req, socket, code, message, headers) {
10563
- if (server2.listenerCount("wsClientError")) {
10562
+ function abortHandshakeOrEmitwsClientError(server, req, socket, code, message, headers) {
10563
+ if (server.listenerCount("wsClientError")) {
10564
10564
  const err = new Error(message);
10565
10565
  Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError);
10566
- server2.emit("wsClientError", err, socket, req);
10566
+ server.emit("wsClientError", err, socket, req);
10567
10567
  } else {
10568
10568
  abortHandshake(socket, code, message, headers);
10569
10569
  }
@@ -32189,11 +32189,11 @@ var Protocol = class {
32189
32189
  *
32190
32190
  * The Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward.
32191
32191
  */
32192
- async connect(transport2) {
32192
+ async connect(transport) {
32193
32193
  if (this._transport) {
32194
32194
  throw new Error("Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.");
32195
32195
  }
32196
- this._transport = transport2;
32196
+ this._transport = transport;
32197
32197
  const _onclose = this.transport?.onclose;
32198
32198
  this._transport.onclose = () => {
32199
32199
  _onclose?.();
@@ -33783,8 +33783,8 @@ var McpServer = class {
33783
33783
  *
33784
33784
  * The `server` object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward.
33785
33785
  */
33786
- async connect(transport2) {
33787
- return await this.server.connect(transport2);
33786
+ async connect(transport) {
33787
+ return await this.server.connect(transport);
33788
33788
  }
33789
33789
  /**
33790
33790
  * Closes the connection.
@@ -34634,8 +34634,152 @@ var StdioServerTransport = class {
34634
34634
  }
34635
34635
  };
34636
34636
 
34637
+ // node_modules/@chrischall/mcp-utils/dist/server/index.js
34638
+ async function createMcpServer(opts) {
34639
+ const server = new McpServer({ name: opts.name, version: opts.version });
34640
+ if (opts.banner !== void 0) {
34641
+ console.error(opts.banner);
34642
+ }
34643
+ const deps = opts.deps;
34644
+ for (const register of opts.tools) {
34645
+ await register(server, deps);
34646
+ }
34647
+ return server;
34648
+ }
34649
+ function withGracefulShutdown(server, opts = {}) {
34650
+ const shouldExit = opts.exit ?? true;
34651
+ let shuttingDown = false;
34652
+ const handler = (signal) => {
34653
+ if (shuttingDown)
34654
+ return;
34655
+ shuttingDown = true;
34656
+ void (async () => {
34657
+ try {
34658
+ if (opts.onSignal)
34659
+ await opts.onSignal(signal);
34660
+ await server.close();
34661
+ } catch (err) {
34662
+ console.error(`[mcp-utils] error during graceful shutdown on ${signal}: ${err instanceof Error ? err.message : String(err)}`);
34663
+ } finally {
34664
+ if (shouldExit)
34665
+ process.exit(0);
34666
+ }
34667
+ })();
34668
+ };
34669
+ process.on("SIGINT", () => handler("SIGINT"));
34670
+ process.on("SIGTERM", () => handler("SIGTERM"));
34671
+ }
34672
+ async function runMcp(opts) {
34673
+ const server = await createMcpServer(opts);
34674
+ const shutdown = opts.shutdown ?? true;
34675
+ if (shutdown !== false) {
34676
+ withGracefulShutdown(server, shutdown === true ? {} : shutdown);
34677
+ }
34678
+ const spec = opts.transport ?? "stdio";
34679
+ const transport = spec === "stdio" ? new StdioServerTransport() : spec;
34680
+ await server.connect(transport);
34681
+ return server;
34682
+ }
34683
+
34684
+ // node_modules/@chrischall/mcp-utils/dist/response/index.js
34685
+ function textResult(data) {
34686
+ return {
34687
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
34688
+ };
34689
+ }
34690
+ function rawTextResult(text) {
34691
+ return { content: [{ type: "text", text }] };
34692
+ }
34693
+
34694
+ // node_modules/@chrischall/mcp-utils/dist/config/index.js
34695
+ import { homedir } from "node:os";
34696
+ import { isAbsolute, join, resolve } from "node:path";
34697
+ var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
34698
+ function readEnvVar(key, opts = {}) {
34699
+ const env = opts.env ?? process.env;
34700
+ const raw = env[key];
34701
+ if (typeof raw === "string") {
34702
+ const trimmed = raw.trim();
34703
+ if (trimmed.length > 0 && trimmed !== "undefined" && trimmed !== "null" && !PLACEHOLDER_RE.test(trimmed)) {
34704
+ return trimmed;
34705
+ }
34706
+ }
34707
+ return opts.default;
34708
+ }
34709
+ var TRUE_TOKENS = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
34710
+ var FALSE_TOKENS = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
34711
+ function parseBoolEnv(key, opts = {}) {
34712
+ const fallback = opts.default ?? false;
34713
+ const raw = readEnvVar(key, { env: opts.env });
34714
+ if (raw === void 0)
34715
+ return fallback;
34716
+ const token = raw.toLowerCase();
34717
+ if (TRUE_TOKENS.has(token))
34718
+ return true;
34719
+ if (FALSE_TOKENS.has(token))
34720
+ return false;
34721
+ return fallback;
34722
+ }
34723
+ function expandPath(p) {
34724
+ let expanded = p;
34725
+ if (p === "~") {
34726
+ expanded = homedir();
34727
+ } else if (p.startsWith("~/")) {
34728
+ expanded = join(homedir(), p.slice(2));
34729
+ }
34730
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
34731
+ }
34732
+ async function loadDotenvSafely(opts = {}) {
34733
+ try {
34734
+ const mod = await import(
34735
+ /* @vite-ignore */
34736
+ "dotenv"
34737
+ );
34738
+ const result = mod.config({
34739
+ ...opts.path !== void 0 ? { path: opts.path } : {},
34740
+ override: opts.override ?? false,
34741
+ quiet: true
34742
+ });
34743
+ return result.error === void 0;
34744
+ } catch {
34745
+ return false;
34746
+ }
34747
+ }
34748
+
34749
+ // node_modules/@chrischall/mcp-utils/dist/fs/index.js
34750
+ import { openAsBlob } from "node:fs";
34751
+ async function fileBlob(path, opts = {}) {
34752
+ let blob;
34753
+ try {
34754
+ blob = await openAsBlob(path, opts.type !== void 0 ? { type: opts.type } : void 0);
34755
+ } catch {
34756
+ throw new Error(`Cannot read file for upload: ${path}`);
34757
+ }
34758
+ if (opts.maxBytes !== void 0 && blob.size > opts.maxBytes) {
34759
+ throw new Error(`${opts.label ?? "File"} is ${blob.size} bytes, over the ${opts.maxBytes}-byte limit: ${path}`);
34760
+ }
34761
+ return blob;
34762
+ }
34763
+
34764
+ // node_modules/@chrischall/mcp-utils/dist/zod/index.js
34765
+ var PositiveInt = external_exports.number().int().positive();
34766
+ var NonNegInt = external_exports.number().int().nonnegative();
34767
+ var NonEmptyString = external_exports.string().min(1);
34768
+ var IsoDate = external_exports.iso.date();
34769
+ var IsoTime = external_exports.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, "must be HH:MM (24h), e.g. 19:30");
34770
+ var schemaOrigin = external_exports.string().optional().describe("Portal origin (e.g. https://<vendor>.example.co) selecting which active session to use. Optional when only one session is active.");
34771
+ var schemaConfirm = external_exports.boolean().optional().describe("Must be true to proceed. Without this, the tool returns a preview.");
34772
+ var paginationSchema = {
34773
+ offset: NonNegInt.default(0).describe("Number of items to skip (0-based)."),
34774
+ limit: external_exports.number().int().min(1).max(200).default(50).describe("Maximum number of items to return (1-200).")
34775
+ };
34776
+ var pageSchema = {
34777
+ page_num: PositiveInt.default(1).describe("1-based page number."),
34778
+ page_size: external_exports.number().int().min(1).max(200).default(50).describe("Number of items per page (1-200).")
34779
+ };
34780
+
34637
34781
  // src/client.ts
34638
- import { dirname, join as join3 } from "path";
34782
+ import { dirname, join as join4 } from "path";
34639
34783
  import { fileURLToPath } from "url";
34640
34784
 
34641
34785
  // node_modules/@fetchproxy/protocol/dist/frames.js
@@ -35494,13 +35638,13 @@ async function openEncryptedFrame(sessionKey, frame) {
35494
35638
  // node_modules/@fetchproxy/server/dist/election.js
35495
35639
  import { createServer, Server as HttpServer } from "node:http";
35496
35640
  async function electRole(opts) {
35497
- const server2 = createServer();
35641
+ const server = createServer();
35498
35642
  return new Promise((resolve2, reject) => {
35499
35643
  const onError = (e) => {
35500
- server2.removeListener("listening", onListening);
35644
+ server.removeListener("listening", onListening);
35501
35645
  if (e.code === "EADDRINUSE") {
35502
35646
  try {
35503
- server2.close();
35647
+ server.close();
35504
35648
  } catch {
35505
35649
  }
35506
35650
  resolve2({ role: "peer" });
@@ -35509,12 +35653,12 @@ async function electRole(opts) {
35509
35653
  }
35510
35654
  };
35511
35655
  const onListening = () => {
35512
- server2.removeListener("error", onError);
35513
- resolve2({ role: "host", server: server2 });
35656
+ server.removeListener("error", onError);
35657
+ resolve2({ role: "host", server });
35514
35658
  };
35515
- server2.once("error", onError);
35516
- server2.once("listening", onListening);
35517
- server2.listen(opts.port, opts.host);
35659
+ server.once("error", onError);
35660
+ server.once("listening", onListening);
35661
+ server.listen(opts.port, opts.host);
35518
35662
  });
35519
35663
  }
35520
35664
 
@@ -35924,19 +36068,19 @@ async function startPeer(opts) {
35924
36068
 
35925
36069
  // node_modules/@fetchproxy/server/dist/identity.js
35926
36070
  import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
35927
- import { join } from "node:path";
35928
- import { homedir } from "node:os";
36071
+ import { join as join2 } from "node:path";
36072
+ import { homedir as homedir2 } from "node:os";
35929
36073
  var SAFE_PLAIN = /^[A-Za-z0-9._-]+$/;
35930
36074
  var SAFE_SCOPED = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
35931
36075
  function defaultIdentityDir() {
35932
- return join(homedir(), ".fetchproxy", "identity");
36076
+ return join2(homedir2(), ".fetchproxy", "identity");
35933
36077
  }
35934
36078
  async function loadOrCreateIdentity(serverName, dir = defaultIdentityDir()) {
35935
36079
  if (!serverName || serverName === ".." || serverName.includes("..") || !SAFE_PLAIN.test(serverName) && !SAFE_SCOPED.test(serverName)) {
35936
36080
  throw new Error(`unsafe serverName for identity file: ${JSON.stringify(serverName)}`);
35937
36081
  }
35938
36082
  const safeFile = serverName.replace(/\//g, "_");
35939
- const path = join(dir, `${safeFile}.json`);
36083
+ const path = join2(dir, `${safeFile}.json`);
35940
36084
  await mkdir(dir, { recursive: true, mode: 448 });
35941
36085
  try {
35942
36086
  const raw = await readFile(path, "utf8");
@@ -37343,7 +37487,7 @@ async function bootstrap(opts) {
37343
37487
  const sessionStorageKeys = new Set(opts.declare.sessionStorage);
37344
37488
  for (const p of sessionStoragePointers)
37345
37489
  sessionStorageKeys.add(p.storageKey);
37346
- const server2 = factory({
37490
+ const server = factory({
37347
37491
  serverName: opts.serverName,
37348
37492
  version: opts.version,
37349
37493
  domains: [...opts.domains],
@@ -37378,10 +37522,10 @@ async function bootstrap(opts) {
37378
37522
  if (opts.storageSubdomain !== void 0)
37379
37523
  storageDomainOpts.subdomain = opts.storageSubdomain;
37380
37524
  try {
37381
- await server2.listen();
37525
+ await server.listen();
37382
37526
  const cookies = {};
37383
37527
  if (opts.declare.cookies.length > 0) {
37384
- const joined = await server2.readCookies({
37528
+ const joined = await server.readCookies({
37385
37529
  keys: opts.declare.cookies,
37386
37530
  ...storageDomainOpts
37387
37531
  });
@@ -37401,7 +37545,7 @@ async function bootstrap(opts) {
37401
37545
  for (const p of localStoragePointers) {
37402
37546
  pointers[p.outputKey] = { storageKey: p.storageKey, jsonPointer: p.jsonPointer };
37403
37547
  }
37404
- localStorage = await server2.readLocalStorage({
37548
+ localStorage = await server.readLocalStorage({
37405
37549
  keys: allKeys,
37406
37550
  ...storageDomainOpts,
37407
37551
  ...localStoragePointers.length > 0 ? { pointers } : {}
@@ -37414,7 +37558,7 @@ async function bootstrap(opts) {
37414
37558
  for (const p of sessionStoragePointers) {
37415
37559
  pointers[p.outputKey] = { storageKey: p.storageKey, jsonPointer: p.jsonPointer };
37416
37560
  }
37417
- sessionStorage = await server2.readSessionStorage({
37561
+ sessionStorage = await server.readSessionStorage({
37418
37562
  keys: allKeys,
37419
37563
  ...storageDomainOpts,
37420
37564
  ...sessionStoragePointers.length > 0 ? { pointers } : {}
@@ -37430,14 +37574,14 @@ async function bootstrap(opts) {
37430
37574
  opts.onWaiting(`waiting to capture ${h.headerName} \u2014 interact with the page in your browser`);
37431
37575
  }
37432
37576
  }
37433
- capturedHeaders[h.headerName] = await server2.captureRequestHeader({
37577
+ capturedHeaders[h.headerName] = await server.captureRequestHeader({
37434
37578
  urlPattern: h.urlPattern,
37435
37579
  headerName: h.headerName
37436
37580
  });
37437
37581
  }
37438
37582
  const indexedDbBucket = {};
37439
37583
  for (const d of indexedDb) {
37440
- const values = await server2.readIndexedDb({
37584
+ const values = await server.readIndexedDb({
37441
37585
  database: d.database,
37442
37586
  store: d.store,
37443
37587
  keys: [...d.keys],
@@ -37454,7 +37598,7 @@ async function bootstrap(opts) {
37454
37598
  };
37455
37599
  } finally {
37456
37600
  try {
37457
- await server2.close();
37601
+ await server.close();
37458
37602
  } catch {
37459
37603
  }
37460
37604
  }
@@ -37519,8 +37663,8 @@ async function loginWithPassword(username, password) {
37519
37663
 
37520
37664
  // src/config.ts
37521
37665
  import { createHash } from "node:crypto";
37522
- import { homedir as homedir2 } from "node:os";
37523
- import { join as join2 } from "node:path";
37666
+ import { homedir as homedir3 } from "node:os";
37667
+ import { join as join3 } from "node:path";
37524
37668
  function readCacheIdentity() {
37525
37669
  const explicit = process.env.OFW_CACHE_IDENTITY;
37526
37670
  if (typeof explicit === "string" && explicit.trim().length > 0) return explicit.trim();
@@ -37531,31 +37675,29 @@ function readCacheIdentity() {
37531
37675
  function getCacheDir() {
37532
37676
  const override = process.env.OFW_CACHE_DIR;
37533
37677
  if (override && override.trim().length > 0) return override.trim();
37534
- return join2(homedir2(), ".cache", "ofw-mcp");
37678
+ return join3(homedir3(), ".cache", "ofw-mcp");
37535
37679
  }
37536
37680
  function getCacheDbPath() {
37537
37681
  const identity = readCacheIdentity();
37538
37682
  const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
37539
- return join2(getCacheDir(), `${hash2}.db`);
37683
+ return join3(getCacheDir(), `${hash2}.db`);
37540
37684
  }
37541
37685
  function getAttachmentsDir() {
37542
37686
  const override = process.env.OFW_ATTACHMENTS_DIR;
37543
37687
  if (override && override.trim().length > 0) return override.trim();
37544
- return join2(homedir2(), "Downloads", "ofw-mcp");
37688
+ return join3(homedir3(), "Downloads", "ofw-mcp");
37545
37689
  }
37546
- function parseBoolEnv(name) {
37547
- const raw = process.env[name];
37548
- if (typeof raw !== "string") return false;
37549
- return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
37690
+ function parseBoolEnv2(name) {
37691
+ return parseBoolEnv(name);
37550
37692
  }
37551
37693
  function getDefaultInlineAttachments() {
37552
- return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
37694
+ return parseBoolEnv2("OFW_INLINE_ATTACHMENTS");
37553
37695
  }
37554
37696
 
37555
37697
  // package.json
37556
37698
  var package_default = {
37557
37699
  name: "ofw-mcp",
37558
- version: "2.3.0",
37700
+ version: "2.3.1",
37559
37701
  mcpName: "io.github.chrischall/ofw-mcp",
37560
37702
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
37561
37703
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -37579,11 +37721,12 @@ var package_default = {
37579
37721
  bundle: `esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from "module"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js`,
37580
37722
  dev: "node --env-file=.env dist/index.js",
37581
37723
  test: "vitest run",
37724
+ "test:coverage": "vitest run --coverage",
37582
37725
  "test:watch": "vitest"
37583
37726
  },
37584
37727
  dependencies: {
37728
+ "@chrischall/mcp-utils": "^0.4.0",
37585
37729
  "@fetchproxy/bootstrap": "^0.11.0",
37586
- "@fetchproxy/server": "^0.11.0",
37587
37730
  "@modelcontextprotocol/sdk": "^1.29.0",
37588
37731
  dotenv: "^17.4.2",
37589
37732
  zod: "^4.4.3"
@@ -37599,16 +37742,10 @@ var package_default = {
37599
37742
 
37600
37743
  // src/auth.ts
37601
37744
  function readEnv(key) {
37602
- const raw = process.env[key];
37603
- if (typeof raw !== "string") return void 0;
37604
- const trimmed = raw.trim();
37605
- if (trimmed.length === 0) return void 0;
37606
- if (trimmed === "undefined" || trimmed === "null") return void 0;
37607
- if (/^\$\{[^}]*\}$/.test(trimmed)) return void 0;
37608
- return trimmed;
37745
+ return readEnvVar(key);
37609
37746
  }
37610
37747
  function fetchproxyDisabled() {
37611
- return parseBoolEnv("OFW_DISABLE_FETCHPROXY");
37748
+ return parseBoolEnv2("OFW_DISABLE_FETCHPROXY");
37612
37749
  }
37613
37750
  async function resolveAuth() {
37614
37751
  const username = readEnv("OFW_USERNAME");
@@ -37668,12 +37805,8 @@ async function resolveAuth() {
37668
37805
  }
37669
37806
 
37670
37807
  // src/client.ts
37671
- try {
37672
- const { config: config2 } = await import("dotenv");
37673
- const __dirname = dirname(fileURLToPath(import.meta.url));
37674
- config2({ path: join3(__dirname, "..", ".env"), override: false, quiet: true });
37675
- } catch {
37676
- }
37808
+ var __dirname = dirname(fileURLToPath(import.meta.url));
37809
+ await loadDotenvSafely({ path: join4(__dirname, "..", ".env") });
37677
37810
  function parseContentDispositionFilename(cd) {
37678
37811
  const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
37679
37812
  if (extMatch) {
@@ -37688,7 +37821,7 @@ function parseContentDispositionFilename(cd) {
37688
37821
  return m ? m[1] : null;
37689
37822
  }
37690
37823
  function debugLogEnabled() {
37691
- return parseBoolEnv("OFW_DEBUG_LOG");
37824
+ return parseBoolEnv2("OFW_DEBUG_LOG");
37692
37825
  }
37693
37826
  function redactHeaders(h) {
37694
37827
  const out = { ...h };
@@ -37817,13 +37950,8 @@ var OFWClient = class {
37817
37950
  var client = new OFWClient();
37818
37951
 
37819
37952
  // src/tools/_shared.ts
37820
- import { isAbsolute, join as join4, resolve } from "node:path";
37821
- function jsonResponse(payload) {
37822
- return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
37823
- }
37824
- function textResponse(text) {
37825
- return { content: [{ type: "text", text }] };
37826
- }
37953
+ var jsonResponse = textResult;
37954
+ var textResponse = rawTextResult;
37827
37955
  function mapRecipients(items) {
37828
37956
  return (items ?? []).map((r) => ({
37829
37957
  userId: r.user?.id ?? 0,
@@ -37831,10 +37959,7 @@ function mapRecipients(items) {
37831
37959
  viewedAt: r.viewed?.dateTime ?? null
37832
37960
  }));
37833
37961
  }
37834
- function expandPath(p) {
37835
- const expanded = p.startsWith("~/") ? join4(process.env.HOME ?? "", p.slice(2)) : p;
37836
- return isAbsolute(expanded) ? expanded : resolve(expanded);
37837
- }
37962
+ var expandPath2 = expandPath;
37838
37963
  async function postMessageAndRefetch(client2, payload) {
37839
37964
  const raw = await client2.request(
37840
37965
  "POST",
@@ -37848,15 +37973,15 @@ async function postMessageAndRefetch(client2, payload) {
37848
37973
  }
37849
37974
 
37850
37975
  // src/tools/user.ts
37851
- function registerUserTools(server2, client2) {
37852
- server2.registerTool("ofw_get_profile", {
37976
+ function registerUserTools(server, client2) {
37977
+ server.registerTool("ofw_get_profile", {
37853
37978
  description: "Get current user and co-parent profile information from OurFamilyWizard",
37854
37979
  annotations: { readOnlyHint: true }
37855
37980
  }, async () => {
37856
37981
  const data = await client2.request("GET", "/pub/v2/profiles");
37857
37982
  return jsonResponse(data);
37858
37983
  });
37859
- server2.registerTool("ofw_get_notifications", {
37984
+ server.registerTool("ofw_get_notifications", {
37860
37985
  description: "Get OurFamilyWizard dashboard summary: unread message count, upcoming events, outstanding expenses. Note: updates your last-seen status.",
37861
37986
  annotations: { readOnlyHint: false }
37862
37987
  }, async () => {
@@ -38397,15 +38522,15 @@ function listDataHintsAtFiles(listData) {
38397
38522
  if (Array.isArray(ld.files)) return ld.files.length > 0;
38398
38523
  return false;
38399
38524
  }
38400
- function registerMessageTools(server2, client2) {
38401
- server2.registerTool("ofw_list_message_folders", {
38525
+ function registerMessageTools(server, client2) {
38526
+ server.registerTool("ofw_list_message_folders", {
38402
38527
  description: "List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.",
38403
38528
  annotations: { readOnlyHint: true }
38404
38529
  }, async () => {
38405
38530
  const data = await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true");
38406
38531
  return jsonResponse(data);
38407
38532
  });
38408
- server2.registerTool("ofw_list_messages", {
38533
+ server.registerTool("ofw_list_messages", {
38409
38534
  description: "List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages \u2014 the cache may have 1000+ messages. Call ofw_sync_messages first if the cache is empty or stale.",
38410
38535
  annotations: { readOnlyHint: true },
38411
38536
  inputSchema: {
@@ -38441,7 +38566,7 @@ function registerMessageTools(server2, client2) {
38441
38566
  }
38442
38567
  return jsonResponse(payload);
38443
38568
  });
38444
- server2.registerTool("ofw_get_message", {
38569
+ server.registerTool("ofw_get_message", {
38445
38570
  description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW (which will mark unread inbox messages as read on OFW). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) \u2014 drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
38446
38571
  annotations: { readOnlyHint: false },
38447
38572
  inputSchema: {
@@ -38506,7 +38631,7 @@ function registerMessageTools(server2, client2) {
38506
38631
  const attachments = listAttachmentsForMessage(detail.id);
38507
38632
  return jsonResponse({ ...row, attachments });
38508
38633
  });
38509
- server2.registerTool("ofw_send_message", {
38634
+ server.registerTool("ofw_send_message", {
38510
38635
  description: "Send a message via OurFamilyWizard. To send an existing draft, pass messageId \u2014 subject/body/recipientIds become optional overrides (missing fields default to the draft's cached values) and the draft is deleted after sending. To send a fresh message, supply subject/body/recipientIds directly. draftId is the legacy spelling of messageId and works the same way. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.",
38511
38636
  annotations: { destructiveHint: true },
38512
38637
  inputSchema: {
@@ -38616,7 +38741,7 @@ function registerMessageTools(server2, client2) {
38616
38741
 
38617
38742
  ${text}` : text);
38618
38743
  });
38619
- server2.registerTool("ofw_list_drafts", {
38744
+ server.registerTool("ofw_list_drafts", {
38620
38745
  description: "List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.",
38621
38746
  annotations: { readOnlyHint: true },
38622
38747
  inputSchema: {
@@ -38630,7 +38755,7 @@ ${text}` : text);
38630
38755
  const payload = drafts.length === 0 ? { drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." } : { drafts };
38631
38756
  return jsonResponse(payload);
38632
38757
  });
38633
- server2.registerTool("ofw_save_draft", {
38758
+ server.registerTool("ofw_save_draft", {
38634
38759
  description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 note that under the hood this creates a NEW draft and deletes the old one (OFW's update-in-place endpoint silently no-ops while echoing the posted body, so we don't use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state.",
38635
38760
  annotations: { readOnlyHint: false },
38636
38761
  inputSchema: {
@@ -38692,7 +38817,7 @@ ${text}` : text);
38692
38817
 
38693
38818
  ${text}` : text);
38694
38819
  });
38695
- server2.registerTool("ofw_delete_draft", {
38820
+ server.registerTool("ofw_delete_draft", {
38696
38821
  description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.",
38697
38822
  annotations: { destructiveHint: true },
38698
38823
  inputSchema: {
@@ -38703,7 +38828,7 @@ ${text}` : text);
38703
38828
  deleteDraft(args.messageId);
38704
38829
  return data ? jsonResponse(data) : textResponse("Draft deleted.");
38705
38830
  });
38706
- server2.registerTool("ofw_get_unread_sent", {
38831
+ server.registerTool("ofw_get_unread_sent", {
38707
38832
  description: "List sent messages that have not been read by one or more recipients. Reads from local cache; call ofw_sync_messages first if cache is stale.",
38708
38833
  annotations: { readOnlyHint: true },
38709
38834
  inputSchema: {
@@ -38729,7 +38854,7 @@ ${text}` : text);
38729
38854
  }
38730
38855
  return jsonResponse(unread);
38731
38856
  });
38732
- server2.registerTool("ofw_upload_attachment", {
38857
+ server.registerTool("ofw_upload_attachment", {
38733
38858
  description: `Upload a local file to OurFamilyWizard's "My Files" so it can be attached to a message. Returns the fileId \u2014 pass that to ofw_send_message or ofw_save_draft in myFileIDs to attach it. The file is uploaded as PRIVATE (visible only to you) by default; pass shareClass:"SHARED" to share with co-parents directly via the My Files area.`,
38734
38859
  annotations: { destructiveHint: false },
38735
38860
  inputSchema: {
@@ -38739,14 +38864,13 @@ ${text}` : text);
38739
38864
  description: external_exports.string().describe("Description shown in OFW My Files (default: filename)").optional()
38740
38865
  }
38741
38866
  }, async (args) => {
38742
- const abs = expandPath(args.path);
38867
+ const abs = expandPath2(args.path);
38743
38868
  const stat = statSync(abs);
38744
38869
  if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
38745
- const buf = readFileSync(abs);
38746
38870
  const fileName = basename(abs);
38747
38871
  const mime = mimeFromName(fileName);
38748
38872
  const form = new FormData();
38749
- form.append("file", new Blob([new Uint8Array(buf)], { type: mime }), fileName);
38873
+ form.append("file", await fileBlob(abs, { type: mime }), fileName);
38750
38874
  form.append("source", "message");
38751
38875
  form.append("description", args.description ?? fileName);
38752
38876
  form.append("label", args.label ?? fileName);
@@ -38758,7 +38882,7 @@ ${text}` : text);
38758
38882
  fileName: meta3.fileName ?? fileName,
38759
38883
  label: meta3.label ?? args.label ?? fileName,
38760
38884
  mimeType: meta3.fileType ?? mime,
38761
- sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : buf.length,
38885
+ sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : stat.size,
38762
38886
  metadata: meta3,
38763
38887
  messageId: 0
38764
38888
  });
@@ -38766,12 +38890,12 @@ ${text}` : text);
38766
38890
  fileId: meta3.fileId,
38767
38891
  fileName: meta3.fileName ?? fileName,
38768
38892
  mimeType: meta3.fileType ?? mime,
38769
- sizeBytes: meta3.sizeInBytes ?? buf.length,
38893
+ sizeBytes: meta3.sizeInBytes ?? stat.size,
38770
38894
  shareClass: meta3.shareClass ?? args.shareClass ?? "PRIVATE",
38771
38895
  note: "Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it."
38772
38896
  });
38773
38897
  });
38774
- server2.registerTool("ofw_download_attachment", {
38898
+ server.registerTool("ofw_download_attachment", {
38775
38899
  description: 'Download an OFW message attachment by fileId. By default, bytes are saved to disk (~/Downloads/ofw-mcp/) and the response carries the absolute path, mime type, and size for the caller to read back. Pass inline:true to skip disk entirely and return the bytes as MCP content blocks \u2014 images come back as ImageContent (the model sees them directly); other files come back as an EmbeddedResource blob. Use inline for small files where you want the model to read content immediately and the host is sandboxed; use disk for large files or when you want a persistent local copy. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var (set to "true" to make inline the default). fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo. Re-downloading to the same path is a no-op (disk mode only).',
38776
38900
  annotations: { readOnlyHint: false },
38777
38901
  inputSchema: {
@@ -38825,7 +38949,7 @@ ${text}` : text);
38825
38949
  let dest;
38826
38950
  if (args.saveTo) {
38827
38951
  const isDirArg = args.saveTo.endsWith("/") || args.saveTo.endsWith("\\");
38828
- const abs = expandPath(args.saveTo);
38952
+ const abs = expandPath2(args.saveTo);
38829
38953
  dest = isDirArg ? join5(abs, `${fileId}-${cached2.fileName}`) : abs;
38830
38954
  } else {
38831
38955
  dest = join5(getAttachmentsDir(), `${fileId}-${cached2.fileName}`);
@@ -38852,7 +38976,7 @@ ${text}` : text);
38852
38976
  fileName: response.suggestedFileName ?? cached2.fileName
38853
38977
  });
38854
38978
  });
38855
- server2.registerTool("ofw_sync_messages", {
38979
+ server.registerTool("ofw_sync_messages", {
38856
38980
  description: "Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps).",
38857
38981
  annotations: { readOnlyHint: false },
38858
38982
  inputSchema: {
@@ -38876,8 +39000,8 @@ async function deleteOFWMessages(client2, ids) {
38876
39000
  }
38877
39001
 
38878
39002
  // src/tools/calendar.ts
38879
- function registerCalendarTools(server2, client2) {
38880
- server2.registerTool("ofw_list_events", {
39003
+ function registerCalendarTools(server, client2) {
39004
+ server.registerTool("ofw_list_events", {
38881
39005
  description: "List OurFamilyWizard calendar events in a date range",
38882
39006
  annotations: { readOnlyHint: true },
38883
39007
  inputSchema: {
@@ -38893,7 +39017,7 @@ function registerCalendarTools(server2, client2) {
38893
39017
  );
38894
39018
  return jsonResponse(data);
38895
39019
  });
38896
- server2.registerTool("ofw_create_event", {
39020
+ server.registerTool("ofw_create_event", {
38897
39021
  description: "Create a calendar event in OurFamilyWizard",
38898
39022
  annotations: { destructiveHint: false },
38899
39023
  inputSchema: {
@@ -38913,7 +39037,7 @@ function registerCalendarTools(server2, client2) {
38913
39037
  const data = await client2.request("POST", "/pub/v1/calendar/events", args);
38914
39038
  return jsonResponse(data);
38915
39039
  });
38916
- server2.registerTool("ofw_update_event", {
39040
+ server.registerTool("ofw_update_event", {
38917
39041
  description: "Update an existing OurFamilyWizard calendar event",
38918
39042
  annotations: { destructiveHint: true },
38919
39043
  inputSchema: {
@@ -38931,7 +39055,7 @@ function registerCalendarTools(server2, client2) {
38931
39055
  const data = await client2.request("PUT", `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
38932
39056
  return jsonResponse(data);
38933
39057
  });
38934
- server2.registerTool("ofw_delete_event", {
39058
+ server.registerTool("ofw_delete_event", {
38935
39059
  description: "Delete an OurFamilyWizard calendar event",
38936
39060
  annotations: { destructiveHint: true },
38937
39061
  inputSchema: {
@@ -38944,15 +39068,15 @@ function registerCalendarTools(server2, client2) {
38944
39068
  }
38945
39069
 
38946
39070
  // src/tools/expenses.ts
38947
- function registerExpenseTools(server2, client2) {
38948
- server2.registerTool("ofw_get_expense_totals", {
39071
+ function registerExpenseTools(server, client2) {
39072
+ server.registerTool("ofw_get_expense_totals", {
38949
39073
  description: "Get OurFamilyWizard expense summary totals (owed/paid)",
38950
39074
  annotations: { readOnlyHint: true }
38951
39075
  }, async () => {
38952
39076
  const data = await client2.request("GET", "/pub/v2/expense/expenses/totals");
38953
39077
  return jsonResponse(data);
38954
39078
  });
38955
- server2.registerTool("ofw_list_expenses", {
39079
+ server.registerTool("ofw_list_expenses", {
38956
39080
  description: "List OurFamilyWizard expenses with pagination",
38957
39081
  annotations: { readOnlyHint: true },
38958
39082
  inputSchema: {
@@ -38965,7 +39089,7 @@ function registerExpenseTools(server2, client2) {
38965
39089
  const data = await client2.request("GET", `/pub/v2/expense/expenses?start=${start}&max=${max}`);
38966
39090
  return jsonResponse(data);
38967
39091
  });
38968
- server2.registerTool("ofw_create_expense", {
39092
+ server.registerTool("ofw_create_expense", {
38969
39093
  description: "Log a new expense in OurFamilyWizard",
38970
39094
  annotations: { destructiveHint: false },
38971
39095
  inputSchema: {
@@ -38979,8 +39103,8 @@ function registerExpenseTools(server2, client2) {
38979
39103
  }
38980
39104
 
38981
39105
  // src/tools/journal.ts
38982
- function registerJournalTools(server2, client2) {
38983
- server2.registerTool("ofw_list_journal_entries", {
39106
+ function registerJournalTools(server, client2) {
39107
+ server.registerTool("ofw_list_journal_entries", {
38984
39108
  description: "List OurFamilyWizard journal entries",
38985
39109
  annotations: { readOnlyHint: true },
38986
39110
  inputSchema: {
@@ -38993,7 +39117,7 @@ function registerJournalTools(server2, client2) {
38993
39117
  const data = await client2.request("GET", `/pub/v1/journals?start=${start}&max=${max}`);
38994
39118
  return jsonResponse(data);
38995
39119
  });
38996
- server2.registerTool("ofw_create_journal_entry", {
39120
+ server.registerTool("ofw_create_journal_entry", {
38997
39121
  description: "Create a new journal entry in OurFamilyWizard",
38998
39122
  annotations: { destructiveHint: false },
38999
39123
  inputSchema: {
@@ -39017,12 +39141,17 @@ process.emit = function(event, ...args) {
39017
39141
  }
39018
39142
  return originalEmit(event, ...args);
39019
39143
  };
39020
- var server = new McpServer({ name: "ofw", version: "2.3.0" });
39021
- registerUserTools(server, client);
39022
- registerMessageTools(server, client);
39023
- registerCalendarTools(server, client);
39024
- registerExpenseTools(server, client);
39025
- registerJournalTools(server, client);
39026
- console.error("[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.");
39027
- var transport = new StdioServerTransport();
39028
- await server.connect(transport);
39144
+ await runMcp({
39145
+ name: "ofw",
39146
+ version: "2.3.1",
39147
+ // x-release-please-version
39148
+ deps: client,
39149
+ tools: [
39150
+ registerUserTools,
39151
+ registerMessageTools,
39152
+ registerCalendarTools,
39153
+ registerExpenseTools,
39154
+ registerJournalTools
39155
+ ],
39156
+ banner: "[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion."
39157
+ });
package/dist/cache.js CHANGED
@@ -182,6 +182,7 @@ export function countMessages(opts) {
182
182
  const { where, params } = buildMessageFilter(opts);
183
183
  const r = db.prepare(`SELECT COUNT(*) as n FROM messages ${where}`)
184
184
  .get(...params);
185
+ /* v8 ignore next -- SELECT COUNT(*) always returns exactly one row; the ?./?? are defensive */
185
186
  return r?.n ?? 0;
186
187
  }
187
188
  function draftFromDb(r) {
package/dist/client.js CHANGED
@@ -1,17 +1,14 @@
1
+ import { loadDotenvSafely } from '@chrischall/mcp-utils';
1
2
  import { dirname, join } from 'path';
2
3
  import { fileURLToPath } from 'url';
3
4
  import { resolveAuth } from './auth.js';
4
5
  import { parseBoolEnv } from './config.js';
5
6
  import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
6
- // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb bundle)
7
- try {
8
- const { config } = await import('dotenv');
9
- const __dirname = dirname(fileURLToPath(import.meta.url));
10
- config({ path: join(__dirname, '..', '.env'), override: false, quiet: true });
11
- }
12
- catch {
13
- // not available — rely on process.env (mcpb sets credentials via mcp_config.env)
14
- }
7
+ // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
8
+ // bundle). loadDotenvSafely applies override:false + quiet:true and swallows a
9
+ // missing dotenv module, matching the prior inline try/catch exactly.
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ await loadDotenvSafely({ path: join(__dirname, '..', '.env') });
15
12
  // Parse a Content-Disposition header for a filename. Prefers RFC 6266
16
13
  // `filename*=UTF-8''…` (percent-decoded) and falls back to `filename="…"`.
17
14
  function parseContentDispositionFilename(cd) {
@@ -36,6 +33,7 @@ function debugLogEnabled() {
36
33
  }
37
34
  function redactHeaders(h) {
38
35
  const out = { ...h };
36
+ /* v8 ignore next -- request headers always carry Authorization (set in request()); the guard is defensive for arbitrary header maps */
39
37
  if (out.Authorization)
40
38
  out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}…`;
41
39
  return out;
package/dist/config.js CHANGED
@@ -1,6 +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
5
  // Cache identity drives the per-user SQLite DB filename. Order of preference:
5
6
  // 1. OFW_CACHE_IDENTITY — explicit override for users who want to label the
6
7
  // cache themselves (e.g. when authing via fetchproxy and OFW_USERNAME is
@@ -45,12 +46,13 @@ export function getAttachmentsDir() {
45
46
  * (case-insensitive, trimmed). Anything else — unset, empty, or other
46
47
  * values — is false. Used for OFW_INLINE_ATTACHMENTS, OFW_DISABLE_FETCHPROXY,
47
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).
48
53
  */
49
54
  export function parseBoolEnv(name) {
50
- const raw = process.env[name];
51
- if (typeof raw !== 'string')
52
- return false;
53
- return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
55
+ return parseBoolEnvUtil(name);
54
56
  }
55
57
  // Default for ofw_download_attachment's `inline` arg when the caller doesn't
56
58
  // pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
package/dist/index.js CHANGED
@@ -9,20 +9,29 @@ process.emit = function (event, ...args) {
9
9
  }
10
10
  return originalEmit(event, ...args);
11
11
  };
12
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { runMcp } from '@chrischall/mcp-utils';
14
13
  import { client } from './client.js';
15
14
  import { registerUserTools } from './tools/user.js';
16
15
  import { registerMessageTools } from './tools/messages.js';
17
16
  import { registerCalendarTools } from './tools/calendar.js';
18
17
  import { registerExpenseTools } from './tools/expenses.js';
19
18
  import { registerJournalTools } from './tools/journal.js';
20
- const server = new McpServer({ name: 'ofw', version: '2.3.0' }); // x-release-please-version
21
- registerUserTools(server, client);
22
- registerMessageTools(server, client);
23
- registerCalendarTools(server, client);
24
- registerExpenseTools(server, client);
25
- registerJournalTools(server, client);
26
- console.error('[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.');
27
- const transport = new StdioServerTransport();
28
- await server.connect(transport);
19
+ // runMcp builds the McpServer, applies the registrars (with `client` threaded
20
+ // through as deps), prints the banner to stderr, wires SIGINT/SIGTERM graceful
21
+ // shutdown, and connects the stdio transport. The deferred-config-error pattern
22
+ // is preserved: `client` is constructed at module load in ./client.js (auth is
23
+ // resolved lazily on the first tool call), so the host's initial tools/list
24
+ // always succeeds before any credential check runs.
25
+ await runMcp({
26
+ name: 'ofw',
27
+ version: '2.3.1', // x-release-please-version
28
+ deps: client,
29
+ tools: [
30
+ registerUserTools,
31
+ registerMessageTools,
32
+ registerCalendarTools,
33
+ registerExpenseTools,
34
+ registerJournalTools,
35
+ ],
36
+ banner: '[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
37
+ });
@@ -1,10 +1,9 @@
1
- import { isAbsolute, join, resolve } from 'node:path';
2
- export function jsonResponse(payload) {
3
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
4
- }
5
- export function textResponse(text) {
6
- return { content: [{ type: 'text', text }] };
7
- }
1
+ import { expandPath as expandPathUtil, rawTextResult, textResult } from '@chrischall/mcp-utils';
2
+ // Pretty-printed JSON tool result. Thin wrapper over @chrischall/mcp-utils'
3
+ // `textResult` so the rest of the codebase keeps the local name.
4
+ export const jsonResponse = textResult;
5
+ // Raw-string tool result. Wrapper over @chrischall/mcp-utils' `rawTextResult`.
6
+ export const textResponse = rawTextResult;
8
7
  // Translates OFW API recipient shape into the cache's normalized Recipient.
9
8
  // Used wherever we surface or persist recipients (sync, get_message, send,
10
9
  // save_draft) — all five call sites had near-identical inline mappings.
@@ -15,11 +14,9 @@ export function mapRecipients(items) {
15
14
  viewedAt: r.viewed?.dateTime ?? null,
16
15
  }));
17
16
  }
18
- // Expand a user-provided path: ~ → $HOME, relative → absolute.
19
- export function expandPath(p) {
20
- const expanded = p.startsWith('~/') ? join(process.env.HOME ?? '', p.slice(2)) : p;
21
- return isAbsolute(expanded) ? expanded : resolve(expanded);
22
- }
17
+ // Expand a user-provided path: ~ → home, relative → absolute. Re-exports
18
+ // @chrischall/mcp-utils' `expandPath`.
19
+ export const expandPath = expandPathUtil;
23
20
  /**
24
21
  * POST a payload to /pub/v3/messages, then immediately GET the detail
25
22
  * endpoint for the resulting message id. This is the only correct way to
@@ -4,6 +4,7 @@ import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, ups
4
4
  import { getAttachmentsDir, getDefaultInlineAttachments } from '../config.js';
5
5
  import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
6
  import { basename, dirname, extname, join } from 'node:path';
7
+ import { fileBlob } from '@chrischall/mcp-utils';
7
8
  import { expandPath, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse } from './_shared.js';
8
9
  // Lightweight mime sniff from extension. OFW re-derives mime from the filename
9
10
  // server-side anyway, so this is just a polite Content-Type for the Blob.
@@ -425,12 +426,12 @@ export function registerMessageTools(server, client) {
425
426
  const stat = statSync(abs); // throws if missing
426
427
  if (!stat.isFile())
427
428
  throw new Error(`Not a file: ${abs}`);
428
- const buf = readFileSync(abs);
429
429
  const fileName = basename(abs);
430
430
  const mime = mimeFromName(fileName);
431
431
  // Build the multipart payload matching the OFW web UI's request shape.
432
432
  const form = new FormData();
433
- form.append('file', new Blob([new Uint8Array(buf)], { type: mime }), fileName);
433
+ // fileBlob streams the file off disk (a file-backed Blob) instead of buffering it.
434
+ form.append('file', await fileBlob(abs, { type: mime }), fileName);
434
435
  form.append('source', 'message');
435
436
  form.append('description', args.description ?? fileName);
436
437
  form.append('label', args.label ?? fileName);
@@ -445,7 +446,7 @@ export function registerMessageTools(server, client) {
445
446
  fileName: meta.fileName ?? fileName,
446
447
  label: meta.label ?? args.label ?? fileName,
447
448
  mimeType: meta.fileType ?? mime,
448
- sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : buf.length,
449
+ sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : stat.size,
449
450
  metadata: meta,
450
451
  messageId: 0,
451
452
  });
@@ -453,7 +454,7 @@ export function registerMessageTools(server, client) {
453
454
  fileId: meta.fileId,
454
455
  fileName: meta.fileName ?? fileName,
455
456
  mimeType: meta.fileType ?? mime,
456
- sizeBytes: meta.sizeInBytes ?? buf.length,
457
+ sizeBytes: meta.sizeInBytes ?? stat.size,
457
458
  shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
458
459
  note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
459
460
  });
@@ -476,6 +477,7 @@ export function registerMessageTools(server, client) {
476
477
  // sentinel — gets re-linked if a message later references this file.
477
478
  await fetchAttachmentMeta(client, fileId, 0);
478
479
  cached = getAttachment(fileId);
480
+ /* v8 ignore next -- fetchAttachmentMeta persists the row it just fetched; a still-null read here is an unreachable storage failure */
479
481
  if (!cached)
480
482
  throw new Error(`failed to fetch metadata for fileId ${fileId}`);
481
483
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "mcpName": "io.github.chrischall/ofw-mcp",
5
5
  "description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -24,11 +24,12 @@
24
24
  "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from \"module\"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js",
25
25
  "dev": "node --env-file=.env dist/index.js",
26
26
  "test": "vitest run",
27
+ "test:coverage": "vitest run --coverage",
27
28
  "test:watch": "vitest"
28
29
  },
29
30
  "dependencies": {
31
+ "@chrischall/mcp-utils": "^0.4.0",
30
32
  "@fetchproxy/bootstrap": "^0.11.0",
31
- "@fetchproxy/server": "^0.11.0",
32
33
  "@modelcontextprotocol/sdk": "^1.29.0",
33
34
  "dotenv": "^17.4.2",
34
35
  "zod": "^4.4.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.3.0",
9
+ "version": "2.3.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.3.0",
14
+ "version": "2.3.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },