ofw-mcp 2.12.0 → 2.14.0

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.12.0"
9
+ "version": "2.14.0"
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.12.0",
17
+ "version": "2.14.0",
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.12.0",
4
+ "version": "2.14.0",
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
@@ -69,6 +69,38 @@ function fetchproxyDisabled() {
69
69
  * return value as opaque credentials — they should not branch on `source`.
70
70
  * The field exists for logging / future cache-keying only.
71
71
  */
72
+ /**
73
+ * The message raised when NEITHER auth path is configured.
74
+ *
75
+ * Exported with {@link isNoAuthConfigured} because a second reader needs to
76
+ * tell this case apart from every other auth failure: "nothing is set up" and
77
+ * "the password was rejected" or "the bridge is down" need opposite advice, and
78
+ * `ofw_healthcheck` gives the wrong one if it confuses them. A prefix match on
79
+ * a copy of this string in the other module would pass its own test while
80
+ * silently stopping matching the day this wording changed.
81
+ */
82
+ export const NO_AUTH_CONFIGURED = 'OFW auth: set OFW_USERNAME + OFW_PASSWORD, ' +
83
+ 'or install the fetchproxy extension and sign into ourfamilywizard.com ' +
84
+ '(unset OFW_DISABLE_FETCHPROXY if it is set).';
85
+ /**
86
+ * Prefix of the message raised when the fetchproxy bridge is unreachable.
87
+ *
88
+ * A PREFIX rather than a whole constant because the upstream `.hint` — the
89
+ * actionable "click the toolbar icon" copy — is appended per failure. Exported
90
+ * with {@link isBridgeDown} for the reason {@link NO_AUTH_CONFIGURED} is: a
91
+ * reader that matched a copy of this text would keep its own test green on the
92
+ * day the wording changed, while silently misreporting a downed bridge as an
93
+ * unconfigured server.
94
+ */
95
+ export const BRIDGE_DOWN_PREFIX = 'OFW auth: fetchproxy bridge is down';
96
+ /** True for the {@link BRIDGE_DOWN_PREFIX} failure and nothing else. */
97
+ export function isBridgeDown(e) {
98
+ return e instanceof Error && e.message.startsWith(BRIDGE_DOWN_PREFIX);
99
+ }
100
+ /** True for the {@link NO_AUTH_CONFIGURED} failure and nothing else. */
101
+ export function isNoAuthConfigured(e) {
102
+ return e instanceof Error && e.message === NO_AUTH_CONFIGURED;
103
+ }
72
104
  export async function resolveAuth() {
73
105
  // Which paths are CONFIGURED. `resolveAuthPattern` runs the first one
74
106
  // provided, in the fleet's fixed priority order (token → oauth →
@@ -125,7 +157,7 @@ export async function resolveAuth() {
125
157
  // FetchproxyBridgeDownError only escapes bootstrap() after the lazy-revive retry fails — surface .hint verbatim (actionable "click toolbar icon" copy).
126
158
  if (classifyBridgeError(e) === 'bridge_down') {
127
159
  const downErr = e;
128
- throw new Error(`OFW auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`);
160
+ throw new Error(`${BRIDGE_DOWN_PREFIX} (extension service worker unreachable after retry). ${downErr.hint}`);
129
161
  }
130
162
  const msg = e instanceof Error ? e.message : String(e);
131
163
  throw new Error(`OFW auth: no OFW_USERNAME/OFW_PASSWORD set, and fetchproxy fallback failed: ${msg}`);
@@ -137,9 +169,7 @@ export async function resolveAuth() {
137
169
  // because this one names OFW's own two fixes side-by-side and the generic
138
170
  // one cannot.
139
171
  if (!pattern.sessionScrape && !pattern.fetchproxy) {
140
- throw new Error('OFW auth: set OFW_USERNAME + OFW_PASSWORD, ' +
141
- 'or install the fetchproxy extension and sign into ourfamilywizard.com ' +
142
- '(unset OFW_DISABLE_FETCHPROXY if it is set).');
172
+ throw new Error(NO_AUTH_CONFIGURED);
143
173
  }
144
174
  // Errors from the winning path propagate UNWRAPPED, which is what keeps the
145
175
  // bridge-down `.hint` above intact.
package/dist/bundle.js CHANGED
@@ -3314,8 +3314,8 @@ var require_utils = __commonJS({
3314
3314
  var HOST_DELIMS = { "@": "%40", "/": "%2F", "?": "%3F", "#": "%23", ":": "%3A" };
3315
3315
  var HOST_DELIM_RE = /[@/?#:]/g;
3316
3316
  var HOST_DELIM_NO_COLON_RE = /[@/?#]/g;
3317
- function reescapeHostDelimiters(host, isIP) {
3318
- const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE;
3317
+ function reescapeHostDelimiters(host, isIP2) {
3318
+ const re = isIP2 ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE;
3319
3319
  re.lastIndex = 0;
3320
3320
  return host.replace(re, (ch) => HOST_DELIMS[ch]);
3321
3321
  }
@@ -3804,7 +3804,7 @@ var require_fast_uri = __commonJS({
3804
3804
  fragment: void 0
3805
3805
  };
3806
3806
  let malformedAuthorityOrPort = false;
3807
- let isIP = false;
3807
+ let isIP2 = false;
3808
3808
  if (options.reference === "suffix") {
3809
3809
  if (options.scheme) {
3810
3810
  uri = options.scheme + ":" + uri;
@@ -3853,9 +3853,9 @@ var require_fast_uri = __commonJS({
3853
3853
  if (ipv4result === false) {
3854
3854
  const ipv6result = normalizeIPv6(parsed.host);
3855
3855
  parsed.host = ipv6result.host.toLowerCase();
3856
- isIP = ipv6result.isIPV6;
3856
+ isIP2 = ipv6result.isIPV6;
3857
3857
  } else {
3858
- isIP = true;
3858
+ isIP2 = true;
3859
3859
  }
3860
3860
  }
3861
3861
  if (parsed.scheme === void 0 && parsed.userinfo === void 0 && parsed.host === void 0 && parsed.port === void 0 && parsed.query === void 0 && !parsed.path) {
@@ -3872,7 +3872,7 @@ var require_fast_uri = __commonJS({
3872
3872
  }
3873
3873
  const schemeHandler = getSchemeHandler(options.scheme || parsed.scheme);
3874
3874
  if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) {
3875
- if (parsed.host && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) {
3875
+ if (parsed.host && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP2 === false && nonSimpleDomain(parsed.host)) {
3876
3876
  try {
3877
3877
  parsed.host = new URL("http://" + parsed.host).hostname;
3878
3878
  } catch (e) {
@@ -3886,7 +3886,7 @@ var require_fast_uri = __commonJS({
3886
3886
  parsed.scheme = unescape(parsed.scheme);
3887
3887
  }
3888
3888
  if (parsed.host !== void 0) {
3889
- parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP);
3889
+ parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP2);
3890
3890
  }
3891
3891
  }
3892
3892
  if (parsed.path) {
@@ -7199,7 +7199,7 @@ var require_permessage_deflate = __commonJS({
7199
7199
  acceptAsServer(offers) {
7200
7200
  const opts = this._options;
7201
7201
  const accepted = offers.find((params) => {
7202
- if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && !params.client_max_window_bits) {
7202
+ if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && (typeof params.client_max_window_bits === "number" ? opts.clientMaxWindowBits > params.client_max_window_bits : !params.client_max_window_bits)) {
7203
7203
  return false;
7204
7204
  }
7205
7205
  return true;
@@ -34678,6 +34678,7 @@ var StdioServerTransport = class {
34678
34678
  };
34679
34679
 
34680
34680
  // node_modules/@chrischall/mcp-utils/dist/errors/index.js
34681
+ var DEFAULT_ERROR_MESSAGE_MAX = 500;
34681
34682
  var McpToolError = class extends Error {
34682
34683
  /** Actionable remediation text, when one applies. */
34683
34684
  hint;
@@ -34719,6 +34720,18 @@ var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*('
34719
34720
  function redactSecrets(text) {
34720
34721
  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]");
34721
34722
  }
34723
+ function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
34724
+ const str = text === null || text === void 0 ? "" : String(text);
34725
+ const redacted = redactSecrets(str);
34726
+ if (redacted.length <= max)
34727
+ return redacted;
34728
+ return `${redacted.slice(0, max)}\u2026 [truncated]`;
34729
+ }
34730
+ function messageOf(err) {
34731
+ if (err instanceof Error)
34732
+ return err.message;
34733
+ return String(err);
34734
+ }
34722
34735
 
34723
34736
  // node_modules/@chrischall/mcp-utils/dist/response/index.js
34724
34737
  function textResult(data) {
@@ -37331,6 +37344,7 @@ function classifyBridgeError(err) {
37331
37344
  }
37332
37345
 
37333
37346
  // node_modules/@fetchproxy/server/dist/ws-server.js
37347
+ import { isIP } from "node:net";
37334
37348
  function envWsPort() {
37335
37349
  const raw = process.env.FETCHPROXY_WS_PORT;
37336
37350
  if (raw === void 0 || raw.trim() === "")
@@ -37342,6 +37356,15 @@ function envWsPort() {
37342
37356
  return void 0;
37343
37357
  return port;
37344
37358
  }
37359
+ function envWsHost() {
37360
+ const raw = process.env.FETCHPROXY_WS_HOST;
37361
+ if (raw === void 0)
37362
+ return void 0;
37363
+ const host = raw.trim();
37364
+ if (host === "" || isIP(host) === 0)
37365
+ return void 0;
37366
+ return host;
37367
+ }
37345
37368
  var FetchproxyProtocolError = class extends Error {
37346
37369
  constructor(message) {
37347
37370
  super(message);
@@ -37590,7 +37613,7 @@ var FetchproxyServer = class {
37590
37613
  }
37591
37614
  this.opts = {
37592
37615
  port: opts.port ?? envWsPort() ?? 37149,
37593
- host: opts.host ?? "127.0.0.1",
37616
+ host: opts.host ?? envWsHost() ?? "127.0.0.1",
37594
37617
  serverName: opts.serverName,
37595
37618
  version: opts.version,
37596
37619
  domains: [...opts.domains],
@@ -37876,6 +37899,7 @@ var FetchproxyServer = class {
37876
37899
  return {
37877
37900
  role: this.role,
37878
37901
  port: this.opts.port,
37902
+ host: this.opts.host,
37879
37903
  serverVersion: this.opts.version,
37880
37904
  fetchTimeoutMs: this.opts.fetchTimeoutMs ?? 0,
37881
37905
  bridgeReviveDelayMs: this.opts.bridgeReviveDelayMs ?? 0,
@@ -39366,6 +39390,144 @@ var BootstrapDisabledError = class extends Error {
39366
39390
  }
39367
39391
  };
39368
39392
 
39393
+ // node_modules/@chrischall/mcp-utils/dist/healthcheck/index.js
39394
+ function statusOf(err) {
39395
+ if (typeof err !== "object" || err === null)
39396
+ return void 0;
39397
+ const s = err.status ?? err.statusCode;
39398
+ return typeof s === "number" ? s : void 0;
39399
+ }
39400
+ var CREDENTIAL_ARMS = /* @__PURE__ */ new Set([
39401
+ "ok",
39402
+ "no_credential",
39403
+ "credential_rejected",
39404
+ "timeout",
39405
+ "http",
39406
+ "transport",
39407
+ "unknown"
39408
+ ]);
39409
+ function isArm(kind) {
39410
+ return kind !== void 0 && CREDENTIAL_ARMS.has(kind);
39411
+ }
39412
+ function credentialHint(arm, prefix, hostLabel, source) {
39413
+ switch (arm) {
39414
+ case "ok":
39415
+ return `Credential from '${source}' works: ${hostLabel} accepted an authenticated request. If a real tool still fails, the problem is that tool, not auth.`;
39416
+ case "no_credential":
39417
+ return `No credential resolved. Nothing was available to authenticate with \u2014 sign in and reconnect the connector so ${prefix} receives a token, or set the documented environment variable.`;
39418
+ case "credential_rejected":
39419
+ return `${hostLabel} rejected the credential from '${source}'. It is present but no longer valid \u2014 most often expired or revoked upstream. Re-authenticate and reconnect; retrying will not fix it.`;
39420
+ case "timeout":
39421
+ return `The credential from '${source}' resolved, but ${hostLabel} did not answer in time. Usually transient \u2014 retry. If it persists, ${hostLabel} is slow or unreachable from here.`;
39422
+ case "http":
39423
+ return `${hostLabel} answered with an error status that is not an auth rejection. That is USUALLY a ${hostLabel}-side problem rather than an auth one \u2014 but a 404 here more often means the probe path is wrong than that ${hostLabel} is broken, so check error.message and probe.url before concluding anything about the credential.`;
39424
+ case "transport":
39425
+ return `Could not reach ${hostLabel} at all. Check network egress; the credential itself was never judged.`;
39426
+ default:
39427
+ return `Unexpected failure \u2014 see error.message.`;
39428
+ }
39429
+ }
39430
+ function registerCredentialHealthcheckTool(args) {
39431
+ const { server, prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
39432
+ const probeUrl = probePath ? `https://${hostLabel}${probePath}` : void 0;
39433
+ server.registerTool(`${prefix}_healthcheck`, {
39434
+ title: "Verify credentials and upstream reachability",
39435
+ description: `Resolves the credential the way real tools do, then makes one authenticated request to ${hostLabel}. Reports which source supplied the credential, whether ${hostLabel} accepted it, the round-trip time, and a plain-English hint distinguishing 'no credential' from 'credential rejected' from 'a ${hostLabel}-side problem'. Call this when a real tool fails and you want to know which hop broke. Read-only; never returns the credential itself.`,
39436
+ annotations: {
39437
+ title: "Verify credentials and upstream reachability",
39438
+ readOnlyHint: true,
39439
+ idempotentHint: true,
39440
+ openWorldHint: true
39441
+ },
39442
+ inputSchema: {}
39443
+ }, async () => {
39444
+ let probeStarted = 0;
39445
+ let state;
39446
+ try {
39447
+ state = await resolveCredential();
39448
+ } catch (e) {
39449
+ const classified = classifyThrown?.(e);
39450
+ const result2 = {
39451
+ ok: false,
39452
+ // Still false, and still no source: a classification explains WHY
39453
+ // nothing resolved, it does not invent a credential that did.
39454
+ credential: { source: null, resolved: false },
39455
+ // No `url`: nothing was probed, and naming one implies it was tried.
39456
+ probe: { elapsed_ms: 0 },
39457
+ error: {
39458
+ kind: classified?.kind ?? "no_credential",
39459
+ message: truncateErrorMessage(messageOf(e)),
39460
+ ...classified?.detail !== void 0 ? { detail: classified.detail } : {}
39461
+ },
39462
+ // The hint must follow the KIND beside it. Falling back to
39463
+ // `no_credential`'s copy under a classified kind would state a cause
39464
+ // the kind contradicts — the same disagreement this path exists to
39465
+ // remove. So: an inline hint wins; else the classified arm's own
39466
+ // copy (consumer override first); else, for a kind this module has
39467
+ // no copy for, the neutral `unknown` text rather than one that
39468
+ // asserts a cause; else the unclassified `no_credential` default.
39469
+ hint: classified?.hint ?? (isArm(classified?.kind) ? hints?.[classified.kind] ?? credentialHint(classified.kind, prefix, hostLabel, null) : classified !== void 0 ? credentialHint("unknown", prefix, hostLabel, null) : hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null))
39470
+ };
39471
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
39472
+ }
39473
+ const credential = {
39474
+ source: state.source,
39475
+ resolved: state.source !== null,
39476
+ ...state.detail !== void 0 ? { detail: state.detail } : {}
39477
+ };
39478
+ if (!credential.resolved) {
39479
+ const result2 = {
39480
+ ok: false,
39481
+ credential,
39482
+ probe: { elapsed_ms: 0 },
39483
+ error: { kind: "no_credential", message: "no credential source resolved" },
39484
+ hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
39485
+ };
39486
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
39487
+ }
39488
+ let arm = "ok";
39489
+ let error51;
39490
+ let status;
39491
+ let customHint;
39492
+ probeStarted = Date.now();
39493
+ try {
39494
+ await probeFn();
39495
+ } catch (e) {
39496
+ status = statusOf(e);
39497
+ const aborted2 = e instanceof Error && e.name === "AbortError";
39498
+ arm = status === 401 || status === 403 ? "credential_rejected" : status !== void 0 ? "http" : aborted2 || /timeout|timed out|ETIMEDOUT/i.test(messageOf(e)) ? "timeout" : /fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|network/i.test(messageOf(e)) ? "transport" : "unknown";
39499
+ let kind = arm;
39500
+ let detail;
39501
+ const custom2 = classifyThrown?.(e);
39502
+ if (custom2) {
39503
+ kind = custom2.kind;
39504
+ customHint = custom2.hint;
39505
+ detail = custom2.detail;
39506
+ }
39507
+ error51 = {
39508
+ kind,
39509
+ // Redacted AND bounded before it reaches the result: an upstream
39510
+ // failure routinely quotes what it was sent, and a healthcheck is
39511
+ // the tool people paste into a chat when something is broken.
39512
+ message: truncateErrorMessage(messageOf(e)),
39513
+ ...detail !== void 0 ? { detail } : {}
39514
+ };
39515
+ }
39516
+ const result = {
39517
+ ok: error51 === void 0,
39518
+ credential,
39519
+ probe: {
39520
+ ...probeUrl ? { url: probeUrl } : {},
39521
+ elapsed_ms: Date.now() - probeStarted,
39522
+ ...status !== void 0 ? { status } : {}
39523
+ },
39524
+ ...error51 ? { error: error51 } : {},
39525
+ hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source)
39526
+ };
39527
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
39528
+ });
39529
+ }
39530
+
39369
39531
  // src/protocol.ts
39370
39532
  var BASE_URL = "https://ofw.ourfamilywizard.com";
39371
39533
  var OFW_PROTOCOL_HEADERS = {
@@ -39420,7 +39582,7 @@ async function loginWithPassword(username, password) {
39420
39582
  // package.json
39421
39583
  var package_default = {
39422
39584
  name: "ofw-mcp",
39423
- version: "2.12.0",
39585
+ version: "2.14.0",
39424
39586
  license: "MIT",
39425
39587
  mcpName: "io.github.chrischall/ofw-mcp",
39426
39588
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -39454,8 +39616,8 @@ var package_default = {
39454
39616
  typecheck: "tsc -p tsconfig.json --noEmit"
39455
39617
  },
39456
39618
  dependencies: {
39457
- "@chrischall/mcp-utils": "^0.18.0",
39458
- "@fetchproxy/bootstrap": "^2.0.0",
39619
+ "@chrischall/mcp-utils": "^0.19.3",
39620
+ "@fetchproxy/bootstrap": "^2.2.0",
39459
39621
  "@modelcontextprotocol/sdk": "^1.29.0",
39460
39622
  dotenv: "^17.4.2",
39461
39623
  zod: "^4.4.3"
@@ -39473,6 +39635,14 @@ var package_default = {
39473
39635
  function fetchproxyDisabled() {
39474
39636
  return parseBoolEnv("OFW_DISABLE_FETCHPROXY");
39475
39637
  }
39638
+ var NO_AUTH_CONFIGURED = "OFW auth: set OFW_USERNAME + OFW_PASSWORD, or install the fetchproxy extension and sign into ourfamilywizard.com (unset OFW_DISABLE_FETCHPROXY if it is set).";
39639
+ var BRIDGE_DOWN_PREFIX = "OFW auth: fetchproxy bridge is down";
39640
+ function isBridgeDown(e) {
39641
+ return e instanceof Error && e.message.startsWith(BRIDGE_DOWN_PREFIX);
39642
+ }
39643
+ function isNoAuthConfigured(e) {
39644
+ return e instanceof Error && e.message === NO_AUTH_CONFIGURED;
39645
+ }
39476
39646
  async function resolveAuth() {
39477
39647
  const pattern = {};
39478
39648
  const username = readEnvVar("OFW_USERNAME");
@@ -39520,7 +39690,7 @@ async function resolveAuth() {
39520
39690
  if (classifyBridgeError(e) === "bridge_down") {
39521
39691
  const downErr = e;
39522
39692
  throw new Error(
39523
- `OFW auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
39693
+ `${BRIDGE_DOWN_PREFIX} (extension service worker unreachable after retry). ${downErr.hint}`
39524
39694
  );
39525
39695
  }
39526
39696
  const msg = e instanceof Error ? e.message : String(e);
@@ -39531,9 +39701,7 @@ async function resolveAuth() {
39531
39701
  };
39532
39702
  }
39533
39703
  if (!pattern.sessionScrape && !pattern.fetchproxy) {
39534
- throw new Error(
39535
- "OFW auth: set OFW_USERNAME + OFW_PASSWORD, or install the fetchproxy extension and sign into ourfamilywizard.com (unset OFW_DISABLE_FETCHPROXY if it is set)."
39536
- );
39704
+ throw new Error(NO_AUTH_CONFIGURED);
39537
39705
  }
39538
39706
  const { credential, source, expiresAt } = await resolveAuthPattern(pattern);
39539
39707
  return {
@@ -40057,6 +40225,51 @@ function registerUserTools(server, client2) {
40057
40225
  });
40058
40226
  }
40059
40227
 
40228
+ // src/tools/healthcheck.ts
40229
+ function registerHealthcheckTools(server, client2, resolve3 = resolveAuth) {
40230
+ registerCredentialHealthcheckTool({
40231
+ server,
40232
+ prefix: "ofw",
40233
+ hostLabel: "ourfamilywizard.com",
40234
+ // The same read `ofw_get_profile` makes: authenticated, cheap, and it
40235
+ // changes nothing. A healthcheck that marked a message read would be
40236
+ // co-parent-visible and irreversible.
40237
+ probePath: "/pub/v2/profiles",
40238
+ resolveCredential: async () => {
40239
+ try {
40240
+ const auth = await resolve3();
40241
+ return {
40242
+ source: auth.source,
40243
+ // Never the token. Expiry is the fact that explains a connector
40244
+ // that worked an hour ago and does not now.
40245
+ detail: auth.expiresAt ? { expires_at: auth.expiresAt.toISOString() } : void 0
40246
+ };
40247
+ } catch (e) {
40248
+ if (isNoAuthConfigured(e)) return { source: null };
40249
+ throw e;
40250
+ }
40251
+ },
40252
+ probeFn: () => client2.request("GET", "/pub/v2/profiles"),
40253
+ // A downed bridge is not a missing credential, and since mcp-utils 0.19.3
40254
+ // the helper consults this for a `resolveCredential` failure too — so it
40255
+ // gets its own arm instead of the `no_credential` copy. That copy could
40256
+ // previously only hedge across both cases and point at `error.message`;
40257
+ // now each answer names one cause and one fix.
40258
+ classifyThrown: (err) => isBridgeDown(err) ? {
40259
+ kind: "transport",
40260
+ // The upstream `.hint` rides along in `error.message` — it carries
40261
+ // the actionable "click the toolbar icon" copy this cannot know.
40262
+ hint: "The fetchproxy bridge is down, so the browser path could not be tried. This is not a credential problem: OFW_USERNAME/OFW_PASSWORD, if set, were not reached either. See error.message for the extension-specific fix."
40263
+ } : void 0,
40264
+ hints: {
40265
+ // Now means exactly what it says: nothing is set up. A configured path
40266
+ // that was tried and failed no longer lands here.
40267
+ no_credential: "No OFW credential is configured. Either set OFW_USERNAME + OFW_PASSWORD, or install the fetchproxy extension and sign in to ourfamilywizard.com in a tab (unsetting OFW_DISABLE_FETCHPROXY if you set it).",
40268
+ credential_rejected: "OurFamilyWizard rejected the credential. If it came from `env`, the password changed or the account is locked; if from `fetchproxy`, the browser session expired \u2014 sign in again in the tab. Retrying will not fix either."
40269
+ }
40270
+ });
40271
+ }
40272
+
40060
40273
  // src/sync.ts
40061
40274
  var FileMetaSchema = external_exports.looseObject({
40062
40275
  fileId: external_exports.number(),
@@ -44233,10 +44446,11 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
44233
44446
  var nodeAttachmentIO = new NodeAttachmentIO();
44234
44447
  await runMcp({
44235
44448
  name: "ofw",
44236
- version: "2.12.0",
44449
+ version: "2.14.0",
44237
44450
  // x-release-please-version
44238
44451
  deps: client,
44239
44452
  tools: [
44453
+ registerHealthcheckTools,
44240
44454
  registerUserTools,
44241
44455
  (server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO),
44242
44456
  registerCalendarTools,
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ process.emit = function (event, ...args) {
12
12
  import { runMcp } from '@chrischall/mcp-utils';
13
13
  import { client } from './client.js';
14
14
  import { registerUserTools } from './tools/user.js';
15
+ import { registerHealthcheckTools } from './tools/healthcheck.js';
15
16
  import { registerMessageTools } from './tools/messages.js';
16
17
  import { registerCalendarTools } from './tools/calendar.js';
17
18
  import { registerExpenseTools } from './tools/expenses.js';
@@ -35,9 +36,10 @@ const nodeAttachmentIO = new NodeAttachmentIO();
35
36
  // always succeeds before any credential check runs.
36
37
  await runMcp({
37
38
  name: 'ofw',
38
- version: '2.12.0', // x-release-please-version
39
+ version: '2.14.0', // x-release-please-version
39
40
  deps: client,
40
41
  tools: [
42
+ registerHealthcheckTools,
41
43
  registerUserTools,
42
44
  (server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO),
43
45
  registerCalendarTools,
@@ -0,0 +1,81 @@
1
+ import { registerCredentialHealthcheckTool } from '@chrischall/mcp-utils/healthcheck';
2
+ import { resolveAuth, isNoAuthConfigured, isBridgeDown } from '../auth.js';
3
+ /**
4
+ * `ofw_healthcheck` — the one call that answers "is this connector working?".
5
+ *
6
+ * OFW had no such tool. `ofw_status` looks like one and is not: it is a
7
+ * heavyweight draft-inventory call, `readOnlyHint: false`, that answers "where
8
+ * do my drafts stand?". Asking it whether auth works spends a drafts sync and
9
+ * still cannot separate "no credential" from "OFW rejected it".
10
+ *
11
+ * The distinction matters most for the two-path auth here: the token comes
12
+ * from either OFW_USERNAME/OFW_PASSWORD or a signed-in browser tab via
13
+ * fetchproxy, and "which of those actually supplied it" is the first thing
14
+ * anyone needs when the connector misbehaves. That is why `source` is
15
+ * reported.
16
+ */
17
+ export function registerHealthcheckTools(server, client,
18
+ /** Seam: the auth resolver, injectable so tests need no network. */
19
+ resolve = resolveAuth) {
20
+ registerCredentialHealthcheckTool({
21
+ server,
22
+ prefix: 'ofw',
23
+ hostLabel: 'ourfamilywizard.com',
24
+ // The same read `ofw_get_profile` makes: authenticated, cheap, and it
25
+ // changes nothing. A healthcheck that marked a message read would be
26
+ // co-parent-visible and irreversible.
27
+ probePath: '/pub/v2/profiles',
28
+ resolveCredential: async () => {
29
+ try {
30
+ const auth = await resolve();
31
+ return {
32
+ source: auth.source,
33
+ // Never the token. Expiry is the fact that explains a connector
34
+ // that worked an hour ago and does not now.
35
+ detail: auth.expiresAt ? { expires_at: auth.expiresAt.toISOString() } : undefined,
36
+ };
37
+ }
38
+ catch (e) {
39
+ // "Nothing is configured" is a CREDENTIAL state, not a failure to
40
+ // check — it earns the `no_credential` arm and its advice. Every
41
+ // other error (a rejected password, a bridge that is down) is a real
42
+ // failure and must keep its own message rather than being flattened
43
+ // into "no credential", which would send someone to set variables
44
+ // that are already set.
45
+ // `isNoAuthConfigured` rather than a prefix match on a copy of the
46
+ // message: the copy would pass this module's own test while silently
47
+ // stopping matching the day auth.ts reworded it, and the failure mode
48
+ // is giving a rejected password the advice meant for a blank setup.
49
+ if (isNoAuthConfigured(e))
50
+ return { source: null };
51
+ throw e;
52
+ }
53
+ },
54
+ probeFn: () => client.request('GET', '/pub/v2/profiles'),
55
+ // A downed bridge is not a missing credential, and since mcp-utils 0.19.3
56
+ // the helper consults this for a `resolveCredential` failure too — so it
57
+ // gets its own arm instead of the `no_credential` copy. That copy could
58
+ // previously only hedge across both cases and point at `error.message`;
59
+ // now each answer names one cause and one fix.
60
+ classifyThrown: (err) => isBridgeDown(err)
61
+ ? {
62
+ kind: 'transport',
63
+ // The upstream `.hint` rides along in `error.message` — it carries
64
+ // the actionable "click the toolbar icon" copy this cannot know.
65
+ hint: 'The fetchproxy bridge is down, so the browser path could not be tried. This is ' +
66
+ 'not a credential problem: OFW_USERNAME/OFW_PASSWORD, if set, were not reached ' +
67
+ 'either. See error.message for the extension-specific fix.',
68
+ }
69
+ : undefined,
70
+ hints: {
71
+ // Now means exactly what it says: nothing is set up. A configured path
72
+ // that was tried and failed no longer lands here.
73
+ no_credential: 'No OFW credential is configured. Either set OFW_USERNAME + OFW_PASSWORD, or install ' +
74
+ 'the fetchproxy extension and sign in to ourfamilywizard.com in a tab (unsetting ' +
75
+ 'OFW_DISABLE_FETCHPROXY if you set it).',
76
+ credential_rejected: 'OurFamilyWizard rejected the credential. If it came from `env`, the password changed or ' +
77
+ 'the account is locked; if from `fetchproxy`, the browser session expired — sign in again ' +
78
+ 'in the tab. Retrying will not fix either.',
79
+ },
80
+ });
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "license": "MIT",
5
5
  "mcpName": "io.github.chrischall/ofw-mcp",
6
6
  "description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
@@ -34,8 +34,8 @@
34
34
  "typecheck": "tsc -p tsconfig.json --noEmit"
35
35
  },
36
36
  "dependencies": {
37
- "@chrischall/mcp-utils": "^0.18.0",
38
- "@fetchproxy/bootstrap": "^2.0.0",
37
+ "@chrischall/mcp-utils": "^0.19.3",
38
+ "@fetchproxy/bootstrap": "^2.2.0",
39
39
  "@modelcontextprotocol/sdk": "^1.29.0",
40
40
  "dotenv": "^17.4.2",
41
41
  "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.12.0",
9
+ "version": "2.14.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.12.0",
14
+ "version": "2.14.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },