ofw-mcp 2.3.1 → 2.3.2

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.1"
9
+ "version": "2.3.2"
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.1",
17
+ "version": "2.3.2",
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.1",
4
+ "version": "2.3.2",
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,9 +69,9 @@ function fetchproxyDisabled() {
69
69
  * Resolve OFW auth using the three-path priority described at the top of
70
70
  * this file. Throws with an actionable error message when no path succeeds.
71
71
  *
72
- * Callers (i.e. `OFWClient.login()`) treat the return value as opaque
73
- * credentials — they should not branch on `source`. The field exists for
74
- * logging / future cache-keying only.
72
+ * Callers (i.e. the `OFWClient` TokenManager refresh callback) treat the
73
+ * return value as opaque credentials — they should not branch on `source`.
74
+ * The field exists for logging / future cache-keying only.
75
75
  */
76
76
  export async function resolveAuth() {
77
77
  // ── Path 1: env-var credentials (unchanged from pre-fetchproxy behavior).
package/dist/bundle.js CHANGED
@@ -34681,6 +34681,22 @@ async function runMcp(opts) {
34681
34681
  return server;
34682
34682
  }
34683
34683
 
34684
+ // node_modules/@chrischall/mcp-utils/dist/errors/index.js
34685
+ var API_KEY_RE = new RegExp([
34686
+ "sk-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])",
34687
+ // OpenAI / Anthropic (incl. sk-ant-…)
34688
+ "gh[pousr]_[A-Za-z0-9]{36,}\\b",
34689
+ // GitHub ghp_/gho_/ghu_/ghs_/ghr_
34690
+ "xox[baprs]-[A-Za-z0-9-]{10,}(?![A-Za-z0-9-])",
34691
+ // Slack
34692
+ "AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])",
34693
+ // Google API key (39 chars total)
34694
+ "AKIA[0-9A-Z]{16}\\b",
34695
+ // AWS access key id (20 chars total)
34696
+ "whsec_[A-Za-z0-9]{16,}\\b"
34697
+ // webhook signing secret (Stripe-style)
34698
+ ].map((p) => `\\b${p}`).join("|"), "g");
34699
+
34684
34700
  // node_modules/@chrischall/mcp-utils/dist/response/index.js
34685
34701
  function textResult(data) {
34686
34702
  return {
@@ -34767,6 +34783,8 @@ var NonNegInt = external_exports.number().int().nonnegative();
34767
34783
  var NonEmptyString = external_exports.string().min(1);
34768
34784
  var IsoDate = external_exports.iso.date();
34769
34785
  var IsoTime = external_exports.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, "must be HH:MM (24h), e.g. 19:30");
34786
+ var NumericIdString = external_exports.string().regex(/^\d+$/, "must be a numeric id (digits only)").describe("A numeric id string (digits only), safe to interpolate into a URL path.");
34787
+ var SafePathSegment = external_exports.string().min(1).regex(/^[^/?#\s]+$/, 'must not contain "/", "?", "#", or whitespace').refine((v) => !v.includes(".."), 'must not contain ".."').describe('A single URL path segment, safe to interpolate: no "/", "..", "?", "#", or whitespace.');
34770
34788
  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
34789
  var schemaConfirm = external_exports.boolean().optional().describe("Must be true to proceed. Without this, the tool returns a preview.");
34772
34790
  var paginationSchema = {
@@ -34778,6 +34796,83 @@ var pageSchema = {
34778
34796
  page_size: external_exports.number().int().min(1).max(200).default(50).describe("Number of items per page (1-200).")
34779
34797
  };
34780
34798
 
34799
+ // node_modules/@chrischall/mcp-utils/dist/session/index.js
34800
+ var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
34801
+ var TokenManager = class {
34802
+ accessToken;
34803
+ refreshToken;
34804
+ expiresAt;
34805
+ refreshFn;
34806
+ skewMs;
34807
+ inFlight;
34808
+ constructor(opts) {
34809
+ this.accessToken = opts.initial.accessToken;
34810
+ this.refreshToken = opts.initial.refreshToken;
34811
+ this.expiresAt = opts.initial.expiresAt;
34812
+ this.refreshFn = opts.refresh;
34813
+ this.skewMs = opts.skewMs ?? TOKEN_REFRESH_SKEW_MS;
34814
+ }
34815
+ /** Whether the token is within the skew window of (or past) expiry. */
34816
+ needsRefresh() {
34817
+ return Date.now() >= this.expiresAt - this.skewMs;
34818
+ }
34819
+ /**
34820
+ * Single-flight refresh. Concurrent callers share one in-flight promise; it is
34821
+ * cleared on settle (success or failure) so a subsequent refresh can proceed.
34822
+ */
34823
+ refreshNow() {
34824
+ if (!this.inFlight) {
34825
+ const rt = this.refreshToken;
34826
+ if (rt === void 0) {
34827
+ return Promise.reject(new Error("TokenManager: cannot refresh \u2014 no refresh token is available."));
34828
+ }
34829
+ this.inFlight = (async () => {
34830
+ const tok = await this.refreshFn(rt);
34831
+ this.accessToken = tok.accessToken;
34832
+ if (tok.refreshToken !== void 0 && tok.refreshToken !== "") {
34833
+ this.refreshToken = tok.refreshToken;
34834
+ }
34835
+ this.expiresAt = tok.expiresAt;
34836
+ })().finally(() => {
34837
+ this.inFlight = void 0;
34838
+ });
34839
+ }
34840
+ return this.inFlight;
34841
+ }
34842
+ /** Get a valid access token, refreshing proactively inside the skew window. */
34843
+ async getAccessToken() {
34844
+ if (this.needsRefresh())
34845
+ await this.refreshNow();
34846
+ return this.accessToken;
34847
+ }
34848
+ /** Current absolute expiry (epoch ms). */
34849
+ getExpiresAt() {
34850
+ return this.expiresAt;
34851
+ }
34852
+ /**
34853
+ * Run an authenticated request with reactive 401-replay. `call` receives a
34854
+ * valid access token and returns a `Response`. On `401`, the token is
34855
+ * refreshed once and `call` is invoked again exactly once.
34856
+ *
34857
+ * Guarded against double-refresh: if a concurrent caller already rotated the
34858
+ * token while this request was in flight (single-flight settled and cleared),
34859
+ * a late `401` from a request sent under the OLD token does NOT trigger a
34860
+ * second refresh — under refresh-token rotation that would consume and
34861
+ * invalidate the freshly-issued refresh token. It just replays with the
34862
+ * current token.
34863
+ */
34864
+ async withAuth(call) {
34865
+ const usedToken = await this.getAccessToken();
34866
+ let res = await call(usedToken);
34867
+ if (res.status === 401) {
34868
+ if (this.accessToken === usedToken)
34869
+ await this.refreshNow();
34870
+ res = await call(this.accessToken);
34871
+ }
34872
+ return res;
34873
+ }
34874
+ };
34875
+
34781
34876
  // src/client.ts
34782
34877
  import { dirname, join as join4 } from "path";
34783
34878
  import { fileURLToPath } from "url";
@@ -34791,7 +34886,9 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34791
34886
  "read_local_storage",
34792
34887
  "read_session_storage",
34793
34888
  "capture_request_header",
34794
- "read_indexed_db"
34889
+ "capture_redirect",
34890
+ "read_indexed_db",
34891
+ "download"
34795
34892
  ]);
34796
34893
 
34797
34894
  // node_modules/@fetchproxy/protocol/dist/mcp-id.js
@@ -34938,6 +35035,16 @@ function assertScopeKeyArray(value, label) {
34938
35035
  seen.add(k);
34939
35036
  }
34940
35037
  }
35038
+ var CAPTURE_PATH_RE = /^\/[A-Za-z0-9._~%\-/]*\*?$/;
35039
+ function hostMatchesAnyDomain(host, domains) {
35040
+ const h = host.toLowerCase();
35041
+ for (const d of domains) {
35042
+ const dom = d.toLowerCase();
35043
+ if (h === dom || h.endsWith("." + dom))
35044
+ return true;
35045
+ }
35046
+ return false;
35047
+ }
34941
35048
  function assertCaptureHeadersArray(value, label) {
34942
35049
  if (!Array.isArray(value)) {
34943
35050
  throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
@@ -34946,14 +35053,25 @@ function assertCaptureHeadersArray(value, label) {
34946
35053
  for (let i = 0; i < value.length; i++) {
34947
35054
  const entry = value[i];
34948
35055
  assertObject(entry, `${label}[${i}]`);
34949
- if (entry.urlPattern === void 0) {
34950
- throw new ProtocolError(`${label}[${i}].urlPattern: missing`);
35056
+ if (entry.host === void 0) {
35057
+ throw new ProtocolError(`${label}[${i}].host: missing`);
34951
35058
  }
34952
35059
  if (entry.headerName === void 0) {
34953
35060
  throw new ProtocolError(`${label}[${i}].headerName: missing`);
34954
35061
  }
34955
- if (typeof entry.urlPattern !== "string") {
34956
- throw new ProtocolError(`${label}[${i}].urlPattern: expected string, got ${typeof entry.urlPattern}`);
35062
+ if (typeof entry.host !== "string") {
35063
+ throw new ProtocolError(`${label}[${i}].host: expected string, got ${typeof entry.host}`);
35064
+ }
35065
+ if (!HOSTNAME_RE.test(entry.host)) {
35066
+ throw new ProtocolError(`${label}[${i}].host: invalid hostname ${JSON.stringify(entry.host)}`);
35067
+ }
35068
+ if (entry.path !== void 0) {
35069
+ if (typeof entry.path !== "string") {
35070
+ throw new ProtocolError(`${label}[${i}].path: expected string, got ${typeof entry.path}`);
35071
+ }
35072
+ if (!CAPTURE_PATH_RE.test(entry.path)) {
35073
+ throw new ProtocolError(`${label}[${i}].path: must start with '/' ${JSON.stringify(entry.path)}`);
35074
+ }
34957
35075
  }
34958
35076
  if (typeof entry.headerName !== "string") {
34959
35077
  throw new ProtocolError(`${label}[${i}].headerName: expected string, got ${typeof entry.headerName}`);
@@ -34961,19 +35079,29 @@ function assertCaptureHeadersArray(value, label) {
34961
35079
  if (!HEADER_NAME_RE.test(entry.headerName)) {
34962
35080
  throw new ProtocolError(`${label}[${i}].headerName: invalid name ${JSON.stringify(entry.headerName)}`);
34963
35081
  }
34964
- assertCaptureUrlPattern(entry.urlPattern, `${label}[${i}].urlPattern`);
34965
- const key = `${entry.urlPattern}\0${entry.headerName}`;
35082
+ const normalizedPath = entry.path ?? "/*";
35083
+ const key = `${entry.host}\0${normalizedPath}\0${entry.headerName}`;
34966
35084
  if (seen.has(key)) {
34967
- throw new ProtocolError(`${label}: duplicate ${JSON.stringify({ urlPattern: entry.urlPattern, headerName: entry.headerName })}`);
35085
+ throw new ProtocolError(`${label}: duplicate ${JSON.stringify({ host: entry.host, path: normalizedPath, headerName: entry.headerName })}`);
34968
35086
  }
34969
35087
  seen.add(key);
34970
35088
  for (const k of Object.keys(entry)) {
34971
- if (k !== "urlPattern" && k !== "headerName") {
35089
+ if (k !== "host" && k !== "path" && k !== "headerName") {
34972
35090
  throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
34973
35091
  }
34974
35092
  }
34975
35093
  }
34976
35094
  }
35095
+ function validateCaptureHeaderDecls(value, domains, label = "captureHeaders") {
35096
+ assertCaptureHeadersArray(value, label);
35097
+ const arr = value;
35098
+ for (let i = 0; i < arr.length; i++) {
35099
+ const host = arr[i].host;
35100
+ if (!hostMatchesAnyDomain(host, domains)) {
35101
+ throw new ProtocolError(`${label}[${i}].host: ${JSON.stringify(host)} is not a declared domain or subdomain of [${domains.join(", ")}]`);
35102
+ }
35103
+ }
35104
+ }
34977
35105
  function assertStoragePointersArray(value, label, declaredKeys) {
34978
35106
  if (!Array.isArray(value)) {
34979
35107
  throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
@@ -35061,23 +35189,6 @@ function assertIndexedDbScopesArray(value, label) {
35061
35189
  }
35062
35190
  }
35063
35191
  }
35064
- function assertCaptureUrlPattern(pattern, label) {
35065
- if (!pattern.startsWith("https://")) {
35066
- throw new ProtocolError(`${label}: must start with https:// (got ${JSON.stringify(pattern)})`);
35067
- }
35068
- const afterScheme = pattern.slice("https://".length);
35069
- const slash = afterScheme.indexOf("/");
35070
- const host = slash === -1 ? afterScheme : afterScheme.slice(0, slash);
35071
- if (host.length === 0) {
35072
- throw new ProtocolError(`${label}: missing host (got ${JSON.stringify(pattern)})`);
35073
- }
35074
- if (host.includes("*")) {
35075
- throw new ProtocolError(`${label}: wildcards not permitted in host (got ${JSON.stringify(pattern)})`);
35076
- }
35077
- if (!HOSTNAME_RE.test(host)) {
35078
- throw new ProtocolError(`${label}: invalid host ${JSON.stringify(host)} in ${JSON.stringify(pattern)}`);
35079
- }
35080
- }
35081
35192
  function validateFrame(raw) {
35082
35193
  assertObject(raw, "frame");
35083
35194
  const t = raw.type;
@@ -35142,7 +35253,7 @@ function validateHello(raw) {
35142
35253
  assertScopeKeyArray(raw.sessionStorageKeys, "hello.sessionStorageKeys");
35143
35254
  }
35144
35255
  if (raw.captureHeaders !== void 0) {
35145
- assertCaptureHeadersArray(raw.captureHeaders, "hello.captureHeaders");
35256
+ validateCaptureHeaderDecls(raw.captureHeaders, raw.domains, "hello.captureHeaders");
35146
35257
  }
35147
35258
  if (raw.indexedDbScopes !== void 0) {
35148
35259
  assertIndexedDbScopesArray(raw.indexedDbScopes, "hello.indexedDbScopes");
@@ -35310,24 +35421,58 @@ function validateInnerRequest(raw) {
35310
35421
  }
35311
35422
  if (raw.op === "capture_request_header") {
35312
35423
  assertObject(raw.init, "inner.init");
35313
- if (raw.init.urlPattern === void 0) {
35314
- throw new ProtocolError("inner.init.urlPattern: missing");
35424
+ if (raw.init.host === void 0) {
35425
+ throw new ProtocolError("inner.init.host: missing");
35315
35426
  }
35316
35427
  if (raw.init.headerName === void 0) {
35317
35428
  throw new ProtocolError("inner.init.headerName: missing");
35318
35429
  }
35319
- assertString(raw.init.urlPattern, "inner.init.urlPattern");
35430
+ assertString(raw.init.host, "inner.init.host");
35431
+ if (!HOSTNAME_RE.test(raw.init.host)) {
35432
+ throw new ProtocolError(`inner.init.host: invalid hostname ${JSON.stringify(raw.init.host)}`);
35433
+ }
35434
+ if (raw.init.path !== void 0) {
35435
+ assertString(raw.init.path, "inner.init.path");
35436
+ if (!CAPTURE_PATH_RE.test(raw.init.path)) {
35437
+ throw new ProtocolError(`inner.init.path: must start with '/' ${JSON.stringify(raw.init.path)}`);
35438
+ }
35439
+ }
35320
35440
  assertString(raw.init.headerName, "inner.init.headerName");
35321
35441
  if (raw.init.timeoutMs !== void 0) {
35322
35442
  assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
35323
35443
  }
35324
35444
  for (const k of Object.keys(raw.init)) {
35325
- if (k !== "urlPattern" && k !== "headerName" && k !== "timeoutMs") {
35445
+ if (k !== "host" && k !== "path" && k !== "headerName" && k !== "timeoutMs") {
35326
35446
  throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on capture_request_header`);
35327
35447
  }
35328
35448
  }
35329
35449
  return raw;
35330
35450
  }
35451
+ if (raw.op === "capture_redirect") {
35452
+ assertObject(raw.init, "inner.init");
35453
+ if (raw.init.host === void 0) {
35454
+ throw new ProtocolError("inner.init.host: missing");
35455
+ }
35456
+ assertString(raw.init.host, "inner.init.host");
35457
+ if (!HOSTNAME_RE.test(raw.init.host)) {
35458
+ throw new ProtocolError(`inner.init.host: invalid hostname ${JSON.stringify(raw.init.host)}`);
35459
+ }
35460
+ if (raw.init.path !== void 0) {
35461
+ assertString(raw.init.path, "inner.init.path");
35462
+ if (!CAPTURE_PATH_RE.test(raw.init.path)) {
35463
+ throw new ProtocolError(`inner.init.path: must start with '/' ${JSON.stringify(raw.init.path)}`);
35464
+ }
35465
+ }
35466
+ if (raw.init.timeoutMs !== void 0) {
35467
+ assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
35468
+ }
35469
+ for (const k of Object.keys(raw.init)) {
35470
+ if (k !== "host" && k !== "path" && k !== "timeoutMs") {
35471
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on capture_redirect`);
35472
+ }
35473
+ }
35474
+ return raw;
35475
+ }
35331
35476
  if (raw.op === "read_indexed_db") {
35332
35477
  assertObject(raw.init, "inner.init");
35333
35478
  if (raw.init.origin === void 0)
@@ -35353,7 +35498,32 @@ function validateInnerRequest(raw) {
35353
35498
  }
35354
35499
  return raw;
35355
35500
  }
35356
- throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "read_indexed_db"; got ${JSON.stringify(raw.op)}`);
35501
+ if (raw.op === "download") {
35502
+ assertObject(raw.init, "inner.init");
35503
+ if (raw.init.url === void 0) {
35504
+ throw new ProtocolError("inner.init.url: missing");
35505
+ }
35506
+ assertHttpUrl(raw.init.url, "inner.init.url");
35507
+ if (raw.init.filename !== void 0) {
35508
+ assertString(raw.init.filename, "inner.init.filename");
35509
+ if (/^([/\\]|[A-Za-z]:)/.test(raw.init.filename)) {
35510
+ throw new ProtocolError(`inner.init.filename: must be relative ${JSON.stringify(raw.init.filename)}`);
35511
+ }
35512
+ if (raw.init.filename.split(/[/\\]/).includes("..")) {
35513
+ throw new ProtocolError(`inner.init.filename: must not contain '..' ${JSON.stringify(raw.init.filename)}`);
35514
+ }
35515
+ }
35516
+ if (raw.init.timeoutMs !== void 0) {
35517
+ assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
35518
+ }
35519
+ for (const k of Object.keys(raw.init)) {
35520
+ if (k !== "url" && k !== "filename" && k !== "timeoutMs") {
35521
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on download`);
35522
+ }
35523
+ }
35524
+ return raw;
35525
+ }
35526
+ throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "download"; got ${JSON.stringify(raw.op)}`);
35357
35527
  }
35358
35528
  function assertNonEmptyKeyArray(value, label) {
35359
35529
  if (!Array.isArray(value)) {
@@ -35424,6 +35594,13 @@ function validateInnerResponse(raw) {
35424
35594
  assertString(raw.value, "inner.value");
35425
35595
  return raw;
35426
35596
  }
35597
+ if (op === "capture_redirect") {
35598
+ if (raw.value === void 0) {
35599
+ throw new ProtocolError("inner.value: missing on capture_redirect response");
35600
+ }
35601
+ assertString(raw.value, "inner.value");
35602
+ return raw;
35603
+ }
35427
35604
  if (op === "read_indexed_db") {
35428
35605
  if (raw.values === void 0) {
35429
35606
  throw new ProtocolError("inner.values: missing on read_indexed_db response");
@@ -35431,6 +35608,25 @@ function validateInnerResponse(raw) {
35431
35608
  assertObject(raw.values, "inner.values");
35432
35609
  return raw;
35433
35610
  }
35611
+ if (op === "download") {
35612
+ assertObject(raw.value, "inner.value");
35613
+ assertString(raw.value.path, "inner.value.path");
35614
+ if (typeof raw.value.bytes !== "number" || !Number.isInteger(raw.value.bytes) || raw.value.bytes < 0) {
35615
+ throw new ProtocolError("inner.value.bytes: expected non-negative integer");
35616
+ }
35617
+ if (raw.value.mime !== void 0) {
35618
+ assertString(raw.value.mime, "inner.value.mime");
35619
+ }
35620
+ if (raw.value.finalUrl !== void 0) {
35621
+ assertString(raw.value.finalUrl, "inner.value.finalUrl");
35622
+ }
35623
+ for (const k of Object.keys(raw.value)) {
35624
+ if (k !== "path" && k !== "bytes" && k !== "mime" && k !== "finalUrl") {
35625
+ throw new ProtocolError(`inner.value: unexpected field ${JSON.stringify(k)} on download response`);
35626
+ }
35627
+ }
35628
+ return raw;
35629
+ }
35434
35630
  throw new ProtocolError(`inner.op: unknown success-response op ${JSON.stringify(raw.op)}`);
35435
35631
  }
35436
35632
  if (raw.ok === false) {
@@ -35702,7 +35898,8 @@ async function buildServerHello(opts) {
35702
35898
  }
35703
35899
  if (opts.captureHeaders && opts.captureHeaders.length > 0) {
35704
35900
  hello.captureHeaders = opts.captureHeaders.map((d) => ({
35705
- urlPattern: d.urlPattern,
35901
+ host: d.host,
35902
+ ...d.path !== void 0 ? { path: d.path } : {},
35706
35903
  headerName: d.headerName
35707
35904
  }));
35708
35905
  }
@@ -35940,7 +36137,9 @@ async function startHost(opts) {
35940
36137
  } catch {
35941
36138
  }
35942
36139
  }
35943
- wss.close(() => resolve2());
36140
+ wss.close(() => {
36141
+ opts.httpServer.close(() => resolve2());
36142
+ });
35944
36143
  }),
35945
36144
  sendOwnInner: async (inner) => {
35946
36145
  const session = await ownSessionReady;
@@ -35990,6 +36189,7 @@ async function startPeer(opts) {
35990
36189
  const innerListeners = [];
35991
36190
  const renegotiateListeners = [];
35992
36191
  const pendingPairListeners = [];
36192
+ const closeListeners = [];
35993
36193
  let session = null;
35994
36194
  let pendingPairCode = null;
35995
36195
  let resolveFirstReady;
@@ -36039,6 +36239,7 @@ async function startPeer(opts) {
36039
36239
  ws.on("message", onMessage);
36040
36240
  ws.once("close", () => {
36041
36241
  rejectFirstReady(new Error("peer WS closed before ready"));
36242
+ closeListeners.forEach((cb) => cb());
36042
36243
  });
36043
36244
  sessionPromise.catch(() => {
36044
36245
  });
@@ -36061,6 +36262,9 @@ async function startPeer(opts) {
36061
36262
  pendingPairListeners.push(cb);
36062
36263
  },
36063
36264
  pendingPairCode: () => pendingPairCode,
36265
+ onClose: (cb) => {
36266
+ closeListeners.push(cb);
36267
+ },
36064
36268
  close: () => ws.close()
36065
36269
  };
36066
36270
  return handle;
@@ -36262,6 +36466,12 @@ var FetchproxyServer = class {
36262
36466
  opts;
36263
36467
  hostHandle = null;
36264
36468
  peerHandle = null;
36469
+ // 0.13.0+: true from the start of `close()` until the next `doConnect()`.
36470
+ // Distinguishes an intentional shutdown (whose WS close we must ignore)
36471
+ // from the host process dying (which strands a peer and must trigger
36472
+ // re-election). The WS `close` event fires asynchronously, so this stays
36473
+ // latched across `close()` rather than being reset in its tail.
36474
+ closing = false;
36265
36475
  nextRequestId = 1;
36266
36476
  // 0.8.0+: process-wide freshness counters surfaced via bridgeHealth().
36267
36477
  // Replaces the local copies every downstream MCP was rolling on top
@@ -36287,8 +36497,12 @@ var FetchproxyServer = class {
36287
36497
  pendingStorage = /* @__PURE__ */ new Map();
36288
36498
  // 0.3.0+: capture-header awaiters resolve a single string.
36289
36499
  pendingCapture = /* @__PURE__ */ new Map();
36500
+ // capture_redirect awaiters resolve the captured redirect URL string.
36501
+ pendingRedirect = /* @__PURE__ */ new Map();
36290
36502
  // 0.4.0+: read_indexed_db awaiters resolve a JSON-typed values map.
36291
36503
  pendingIdb = /* @__PURE__ */ new Map();
36504
+ // download awaiters resolve the saved-file metadata (path + size + mime).
36505
+ pendingDownload = /* @__PURE__ */ new Map();
36292
36506
  mcpId = null;
36293
36507
  identity = null;
36294
36508
  // 0.5.3+: in-flight role-election / handle-start promise. Set the
@@ -36334,6 +36548,14 @@ var FetchproxyServer = class {
36334
36548
  }
36335
36549
  capabilities = [...opts.capabilities];
36336
36550
  }
36551
+ if (opts.captureHeaders !== void 0) {
36552
+ try {
36553
+ validateCaptureHeaderDecls(opts.captureHeaders, opts.domains);
36554
+ } catch (err) {
36555
+ const message = err instanceof Error ? err.message : String(err);
36556
+ throw new Error("FetchproxyServer: invalid captureHeaders \u2014 " + message);
36557
+ }
36558
+ }
36337
36559
  this.opts = {
36338
36560
  port: opts.port ?? 37149,
36339
36561
  host: opts.host ?? "127.0.0.1",
@@ -36345,7 +36567,8 @@ var FetchproxyServer = class {
36345
36567
  localStorageKeys: [...opts.localStorageKeys ?? []],
36346
36568
  sessionStorageKeys: [...opts.sessionStorageKeys ?? []],
36347
36569
  captureHeaders: (opts.captureHeaders ?? []).map((d) => ({
36348
- urlPattern: d.urlPattern,
36570
+ host: d.host,
36571
+ ...d.path !== void 0 ? { path: d.path } : {},
36349
36572
  headerName: d.headerName
36350
36573
  })),
36351
36574
  indexedDbScopes: (opts.indexedDbScopes ?? []).map((d) => ({
@@ -36463,6 +36686,7 @@ var FetchproxyServer = class {
36463
36686
  async doConnect() {
36464
36687
  const identity = this.identity;
36465
36688
  const mcpId = this.mcpId;
36689
+ this.closing = false;
36466
36690
  const el = await electRole({ host: this.opts.host, port: this.opts.port });
36467
36691
  if (el.role === "host") {
36468
36692
  this.role = "host";
@@ -36522,6 +36746,14 @@ var FetchproxyServer = class {
36522
36746
  const cb = this.opts.onPairCode;
36523
36747
  this.peerHandle.onPendingPair((code) => cb(code));
36524
36748
  }
36749
+ this.peerHandle.onClose(() => {
36750
+ if (this.closing || this.peerHandle === null)
36751
+ return;
36752
+ this.stopKeepalive();
36753
+ this.rejectAllPending();
36754
+ this.peerHandle = null;
36755
+ this.role = null;
36756
+ });
36525
36757
  }
36526
36758
  }
36527
36759
  pairingErrorMessage(code) {
@@ -37096,12 +37328,12 @@ var FetchproxyServer = class {
37096
37328
  /**
37097
37329
  * 0.3.0+: snapshot the next outgoing request's named header. Single-
37098
37330
  * shot: the extension registers a one-time `webRequest` listener
37099
- * filtered on `urlPattern`, captures the named header on the first
37100
- * match, removes itself, and resolves with the value. Times out
37101
- * after `timeoutMs` (default 30s on the extension).
37331
+ * filtered on `https://${host}${path ?? '/*'}`, captures the named
37332
+ * header on the first match, removes itself, and resolves with the
37333
+ * value. Times out after `timeoutMs` (default 30s on the extension).
37102
37334
  *
37103
- * `(urlPattern, headerName)` must exactly match a declared entry in
37104
- * `FetchproxyServerOpts.captureHeaders`.
37335
+ * `(host, path?, headerName)` must match a declared entry in
37336
+ * `FetchproxyServerOpts.captureHeaders` (omitted path ≡ `/*`).
37105
37337
  */
37106
37338
  async captureRequestHeader(opts) {
37107
37339
  if (!this.opts.capabilities.includes("capture_request_header")) {
@@ -37110,24 +37342,25 @@ var FetchproxyServer = class {
37110
37342
  await this.ensureConnected();
37111
37343
  this.throwIfPendingPair();
37112
37344
  const decls = this.opts.captureHeaders;
37345
+ const normPath = (p) => p ?? "/*";
37113
37346
  let resolved;
37114
- if (opts?.urlPattern !== void 0 && opts?.headerName !== void 0) {
37115
- const found = decls.find((d) => d.urlPattern === opts.urlPattern && d.headerName === opts.headerName);
37347
+ if (opts?.host !== void 0 && opts?.headerName !== void 0) {
37348
+ const found = decls.find((d) => d.host === opts.host && normPath(d.path) === normPath(opts.path) && d.headerName === opts.headerName);
37116
37349
  if (!found) {
37117
- throw new Error(`FetchproxyServer.captureRequestHeader: (urlPattern=${JSON.stringify(opts.urlPattern)}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
37350
+ throw new Error(`FetchproxyServer.captureRequestHeader: (host=${JSON.stringify(opts.host)}, path=${JSON.stringify(normPath(opts.path))}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
37118
37351
  }
37119
37352
  resolved = found;
37120
- } else if (opts?.urlPattern === void 0 && opts?.headerName === void 0) {
37353
+ } else if (opts?.host === void 0 && opts?.headerName === void 0) {
37121
37354
  if (decls.length === 0) {
37122
- throw new Error("FetchproxyServer.captureRequestHeader: no captureHeaders declared on this server \u2014 declare at least one entry in FetchproxyServerOpts.captureHeaders, or pass {urlPattern, headerName} explicitly");
37355
+ throw new Error("FetchproxyServer.captureRequestHeader: no captureHeaders declared on this server \u2014 declare at least one entry in FetchproxyServerOpts.captureHeaders, or pass {host, headerName} explicitly");
37123
37356
  }
37124
37357
  if (decls.length > 1) {
37125
- const list = decls.map((d) => `${JSON.stringify(d.urlPattern)}/${JSON.stringify(d.headerName)}`).join(", ");
37126
- throw new Error(`FetchproxyServer.captureRequestHeader: multiple captureHeaders declared (${decls.length}: ${list}); pass {urlPattern, headerName} to disambiguate`);
37358
+ const list = decls.map((d) => `${JSON.stringify(d.host)}${JSON.stringify(normPath(d.path))}/${JSON.stringify(d.headerName)}`).join(", ");
37359
+ throw new Error(`FetchproxyServer.captureRequestHeader: multiple captureHeaders declared (${decls.length}: ${list}); pass {host, headerName} to disambiguate`);
37127
37360
  }
37128
37361
  resolved = decls[0];
37129
37362
  } else {
37130
- throw new Error("FetchproxyServer.captureRequestHeader: pass both urlPattern AND headerName, or neither (which defaults to the single declared entry)");
37363
+ throw new Error("FetchproxyServer.captureRequestHeader: pass both host AND headerName, or neither (which defaults to the single declared entry)");
37131
37364
  }
37132
37365
  const callOpts = { ...resolved, ...opts?.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {} };
37133
37366
  try {
@@ -37161,7 +37394,7 @@ var FetchproxyServer = class {
37161
37394
  originalError: retryErr.message,
37162
37395
  retryAttempted: true,
37163
37396
  op: "capture_request_header",
37164
- url: resolved.urlPattern,
37397
+ url: `https://${resolved.host}${resolved.path ?? "/*"}`,
37165
37398
  role: this.role,
37166
37399
  port: this.opts.port
37167
37400
  });
@@ -37172,7 +37405,7 @@ var FetchproxyServer = class {
37172
37405
  originalError: err.message,
37173
37406
  retryAttempted: false,
37174
37407
  op: "capture_request_header",
37175
- url: resolved.urlPattern,
37408
+ url: `https://${resolved.host}${resolved.path ?? "/*"}`,
37176
37409
  role: this.role,
37177
37410
  port: this.opts.port
37178
37411
  });
@@ -37185,7 +37418,8 @@ var FetchproxyServer = class {
37185
37418
  id,
37186
37419
  op: "capture_request_header",
37187
37420
  init: {
37188
- urlPattern: opts.urlPattern,
37421
+ host: opts.host,
37422
+ ...opts.path !== void 0 ? { path: opts.path } : {},
37189
37423
  headerName: opts.headerName,
37190
37424
  ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
37191
37425
  }
@@ -37200,6 +37434,181 @@ var FetchproxyServer = class {
37200
37434
  }
37201
37435
  return pending;
37202
37436
  }
37437
+ /**
37438
+ * Snapshot the redirect target URL of the next request the browser
37439
+ * makes to `(host, path?)`. Single-shot: the extension registers a
37440
+ * one-time `chrome.webRequest.onBeforeRedirect` listener filtered on
37441
+ * `https://${host}${path ?? '/*'}`, captures `details.redirectUrl` on
37442
+ * the first match, removes itself, and resolves with the URL. Times out
37443
+ * after `timeoutMs` (default 30s on the extension).
37444
+ *
37445
+ * Use case: a Cloudflare-walled endpoint that 302-redirects cross-origin
37446
+ * to a presigned URL — a page-level fetch sees only an opaque redirect,
37447
+ * but `onBeforeRedirect` exposes the target. Capture is limited to the
37448
+ * MCP's own declared `domains`; no per-entry declared scope is required.
37449
+ */
37450
+ async captureRedirect(opts) {
37451
+ if (!this.opts.capabilities.includes("capture_redirect")) {
37452
+ throw new Error('FetchproxyServer.captureRedirect(): MCP did not declare "capture_redirect" in capabilities');
37453
+ }
37454
+ await this.ensureConnected();
37455
+ this.throwIfPendingPair();
37456
+ try {
37457
+ const result = await this._captureRedirectOnce(opts);
37458
+ this.recordSuccess();
37459
+ return result;
37460
+ } catch (err) {
37461
+ const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
37462
+ if (!swDown) {
37463
+ this.recordFailure(`capture_redirect: ${err.message ?? String(err)}`);
37464
+ throw err;
37465
+ }
37466
+ this.lastEvictionDetectedAt = Date.now();
37467
+ const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
37468
+ if (reviveMs > 0) {
37469
+ this.lazyReviveAttempts += 1;
37470
+ await new Promise((r) => setTimeout(r, reviveMs));
37471
+ try {
37472
+ const result = await this._captureRedirectOnce(opts);
37473
+ this.lazyReviveSuccesses += 1;
37474
+ this.recordSuccess();
37475
+ return result;
37476
+ } catch (retryErr) {
37477
+ const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
37478
+ if (!stillDown) {
37479
+ this.recordFailure(`capture_redirect: ${retryErr.message ?? String(retryErr)}`);
37480
+ throw retryErr;
37481
+ }
37482
+ this.recordFailure(`capture_redirect bridge-down: ${retryErr.message}`);
37483
+ throw new FetchproxyBridgeDownError({
37484
+ originalError: retryErr.message,
37485
+ retryAttempted: true,
37486
+ op: "capture_redirect",
37487
+ url: `https://${opts.host}${opts.path ?? "/*"}`,
37488
+ role: this.role,
37489
+ port: this.opts.port
37490
+ });
37491
+ }
37492
+ }
37493
+ this.recordFailure(`capture_redirect bridge-down: ${err.message}`);
37494
+ throw new FetchproxyBridgeDownError({
37495
+ originalError: err.message,
37496
+ retryAttempted: false,
37497
+ op: "capture_redirect",
37498
+ url: `https://${opts.host}${opts.path ?? "/*"}`,
37499
+ role: this.role,
37500
+ port: this.opts.port
37501
+ });
37502
+ }
37503
+ }
37504
+ async _captureRedirectOnce(opts) {
37505
+ const id = this.nextRequestId++;
37506
+ const inner = {
37507
+ type: "request",
37508
+ id,
37509
+ op: "capture_redirect",
37510
+ init: {
37511
+ host: opts.host,
37512
+ ...opts.path !== void 0 ? { path: opts.path } : {},
37513
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
37514
+ }
37515
+ };
37516
+ const pending = new Promise((resolve2, reject) => {
37517
+ this.pendingRedirect.set(id, { resolve: resolve2, reject });
37518
+ });
37519
+ if (this.hostHandle) {
37520
+ await this.hostHandle.sendOwnInner(inner);
37521
+ } else if (this.peerHandle) {
37522
+ await this.peerHandle.sendInner(inner);
37523
+ }
37524
+ return pending;
37525
+ }
37526
+ /**
37527
+ * Download `url` through the BROWSER's own network stack via
37528
+ * `chrome.downloads` (real cookies + TLS/JA3 fingerprint). Unlike a
37529
+ * page-level `fetch()` (cors mode), this clears a Cloudflare bot-challenge
37530
+ * on the endpoint and follows the cross-origin redirect to the final file.
37531
+ * Resolves the saved local file path + size; the bridge is loopback-only /
37532
+ * single-host, so the MCP reads the bytes from the same disk. Requires
37533
+ * `'download'` in capabilities and the `url` host to be a declared `domain`.
37534
+ */
37535
+ async download(opts) {
37536
+ if (!this.opts.capabilities.includes("download")) {
37537
+ throw new Error('FetchproxyServer.download(): MCP did not declare "download" in capabilities');
37538
+ }
37539
+ assertUrlInDomains("download url", opts.url, this.opts.domains);
37540
+ await this.ensureConnected();
37541
+ this.throwIfPendingPair();
37542
+ try {
37543
+ const result = await this._downloadOnce(opts);
37544
+ this.recordSuccess();
37545
+ return result;
37546
+ } catch (err) {
37547
+ const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
37548
+ if (!swDown) {
37549
+ this.recordFailure(`download: ${err.message ?? String(err)}`);
37550
+ throw err;
37551
+ }
37552
+ this.lastEvictionDetectedAt = Date.now();
37553
+ const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
37554
+ if (reviveMs > 0) {
37555
+ this.lazyReviveAttempts += 1;
37556
+ await new Promise((r) => setTimeout(r, reviveMs));
37557
+ try {
37558
+ const result = await this._downloadOnce(opts);
37559
+ this.lazyReviveSuccesses += 1;
37560
+ this.recordSuccess();
37561
+ return result;
37562
+ } catch (retryErr) {
37563
+ const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
37564
+ if (!stillDown) {
37565
+ this.recordFailure(`download: ${retryErr.message ?? String(retryErr)}`);
37566
+ throw retryErr;
37567
+ }
37568
+ this.recordFailure(`download bridge-down: ${retryErr.message}`);
37569
+ throw new FetchproxyBridgeDownError({
37570
+ originalError: retryErr.message,
37571
+ retryAttempted: true,
37572
+ op: "download",
37573
+ url: opts.url,
37574
+ role: this.role,
37575
+ port: this.opts.port
37576
+ });
37577
+ }
37578
+ }
37579
+ this.recordFailure(`download bridge-down: ${err.message}`);
37580
+ throw new FetchproxyBridgeDownError({
37581
+ originalError: err.message,
37582
+ retryAttempted: false,
37583
+ op: "download",
37584
+ url: opts.url,
37585
+ role: this.role,
37586
+ port: this.opts.port
37587
+ });
37588
+ }
37589
+ }
37590
+ async _downloadOnce(opts) {
37591
+ const id = this.nextRequestId++;
37592
+ const inner = {
37593
+ type: "request",
37594
+ id,
37595
+ op: "download",
37596
+ init: {
37597
+ url: opts.url,
37598
+ ...opts.filename !== void 0 ? { filename: opts.filename } : {},
37599
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
37600
+ }
37601
+ };
37602
+ const pending = new Promise((resolve2, reject) => {
37603
+ this.pendingDownload.set(id, { resolve: resolve2, reject });
37604
+ });
37605
+ if (this.hostHandle) {
37606
+ await this.hostHandle.sendOwnInner(inner);
37607
+ } else if (this.peerHandle) {
37608
+ await this.peerHandle.sendInner(inner);
37609
+ }
37610
+ return pending;
37611
+ }
37203
37612
  /**
37204
37613
  * 0.4.0+: read declared IndexedDB keys from the user's signed-in
37205
37614
  * tab. Requires `'read_indexed_db'` in capabilities AND the
@@ -37347,6 +37756,20 @@ var FetchproxyServer = class {
37347
37756
  }
37348
37757
  return;
37349
37758
  }
37759
+ const redirectCb = this.pendingRedirect.get(inner.id);
37760
+ if (redirectCb) {
37761
+ this.pendingRedirect.delete(inner.id);
37762
+ if (inner.ok) {
37763
+ if (inner.op === "capture_redirect" && typeof inner.value === "string") {
37764
+ redirectCb.resolve(inner.value);
37765
+ } else {
37766
+ redirectCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on capture_redirect awaiter`));
37767
+ }
37768
+ } else {
37769
+ redirectCb.reject(new FetchproxyProtocolError(inner.error));
37770
+ }
37771
+ return;
37772
+ }
37350
37773
  const idbCb = this.pendingIdb.get(inner.id);
37351
37774
  if (idbCb) {
37352
37775
  this.pendingIdb.delete(inner.id);
@@ -37361,6 +37784,20 @@ var FetchproxyServer = class {
37361
37784
  }
37362
37785
  return;
37363
37786
  }
37787
+ const downloadCb = this.pendingDownload.get(inner.id);
37788
+ if (downloadCb) {
37789
+ this.pendingDownload.delete(inner.id);
37790
+ if (inner.ok) {
37791
+ if (inner.op === "download" && inner.value && typeof inner.value === "object") {
37792
+ downloadCb.resolve({ ...inner.value });
37793
+ } else {
37794
+ downloadCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on download awaiter`));
37795
+ }
37796
+ } else {
37797
+ downloadCb.reject(new FetchproxyProtocolError(inner.error));
37798
+ }
37799
+ return;
37800
+ }
37364
37801
  const cookiesCb = this.pendingReadCookies.get(inner.id);
37365
37802
  if (cookiesCb) {
37366
37803
  this.pendingReadCookies.delete(inner.id);
@@ -37403,9 +37840,15 @@ var FetchproxyServer = class {
37403
37840
  for (const { reject } of this.pendingCapture.values())
37404
37841
  reject(err);
37405
37842
  this.pendingCapture.clear();
37843
+ for (const { reject } of this.pendingRedirect.values())
37844
+ reject(err);
37845
+ this.pendingRedirect.clear();
37406
37846
  for (const { reject } of this.pendingIdb.values())
37407
37847
  reject(err);
37408
37848
  this.pendingIdb.clear();
37849
+ for (const { reject } of this.pendingDownload.values())
37850
+ reject(err);
37851
+ this.pendingDownload.clear();
37409
37852
  }
37410
37853
  /**
37411
37854
  * 0.5.2+: read the current pair-pending pair code from whichever handle
@@ -37440,6 +37883,7 @@ var FetchproxyServer = class {
37440
37883
  * twice in a row.
37441
37884
  */
37442
37885
  async close() {
37886
+ this.closing = true;
37443
37887
  this.stopKeepalive();
37444
37888
  this.rejectAllPending();
37445
37889
  if (this.connectingPromise) {
@@ -37567,15 +38011,11 @@ async function bootstrap(opts) {
37567
38011
  const capturedHeaders = {};
37568
38012
  for (const h of opts.declare.captureHeaders) {
37569
38013
  if (opts.onWaiting) {
37570
- try {
37571
- const url2 = new URL(h.urlPattern.replace(/\*+/g, "placeholder"));
37572
- opts.onWaiting(`waiting for next request to ${url2.host} to capture ${h.headerName} \u2014 interact with the page in your browser`);
37573
- } catch {
37574
- opts.onWaiting(`waiting to capture ${h.headerName} \u2014 interact with the page in your browser`);
37575
- }
38014
+ opts.onWaiting(`waiting for next request to ${h.host} to capture ${h.headerName} \u2014 interact with the page in your browser`);
37576
38015
  }
37577
38016
  capturedHeaders[h.headerName] = await server.captureRequestHeader({
37578
- urlPattern: h.urlPattern,
38017
+ host: h.host,
38018
+ ...h.path !== void 0 ? { path: h.path } : {},
37579
38019
  headerName: h.headerName
37580
38020
  });
37581
38021
  }
@@ -37697,7 +38137,7 @@ function getDefaultInlineAttachments() {
37697
38137
  // package.json
37698
38138
  var package_default = {
37699
38139
  name: "ofw-mcp",
37700
- version: "2.3.1",
38140
+ version: "2.3.2",
37701
38141
  mcpName: "io.github.chrischall/ofw-mcp",
37702
38142
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
37703
38143
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -37725,8 +38165,8 @@ var package_default = {
37725
38165
  "test:watch": "vitest"
37726
38166
  },
37727
38167
  dependencies: {
37728
- "@chrischall/mcp-utils": "^0.4.0",
37729
- "@fetchproxy/bootstrap": "^0.11.0",
38168
+ "@chrischall/mcp-utils": "^0.9.0",
38169
+ "@fetchproxy/bootstrap": "^1.3.0",
37730
38170
  "@modelcontextprotocol/sdk": "^1.29.0",
37731
38171
  dotenv: "^17.4.2",
37732
38172
  zod: "^4.4.3"
@@ -37835,12 +38275,39 @@ function getRequestTimeoutMs() {
37835
38275
  const n = Number(raw.trim());
37836
38276
  return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
37837
38277
  }
38278
+ var OFW_REFRESH_SENTINEL = "ofw";
37838
38279
  var OFWClient = class {
37839
- token = null;
37840
- tokenExpiry = null;
38280
+ // Bearer-token lifecycle is delegated to the shared, race-safe TokenManager
38281
+ // (proactive refresh inside the skew window, single-flight refresh so a burst
38282
+ // of concurrent callers coalesces onto ONE `resolveAuth()`, and a 401-replay
38283
+ // guarded against double-refresh). It is created lazily, seeded with an
38284
+ // already-expired placeholder token so the first request drives the refresh
38285
+ // callback — i.e. the original "log in on first request" behavior.
38286
+ tokenManager;
38287
+ getTokenManager() {
38288
+ if (!this.tokenManager) {
38289
+ this.tokenManager = new TokenManager({
38290
+ initial: { accessToken: "", refreshToken: OFW_REFRESH_SENTINEL, expiresAt: 0 },
38291
+ skewMs: OFW_TOKEN_EXPIRY_SKEW_MS,
38292
+ // Map OFW's mint/refresh onto the refresh callback. `resolveAuth()`
38293
+ // returns a token and a best-effort expiry; when the fetchproxy path
38294
+ // can't supply one we fall back to the same 6h estimate the password
38295
+ // path uses (the 401-replay covers a wrong guess). We re-arm the
38296
+ // sentinel so the manager can refresh again later.
38297
+ refresh: async () => {
38298
+ const { token, expiresAt } = await resolveAuth();
38299
+ return {
38300
+ accessToken: token,
38301
+ refreshToken: OFW_REFRESH_SENTINEL,
38302
+ expiresAt: (expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS)).getTime()
38303
+ };
38304
+ }
38305
+ });
38306
+ }
38307
+ return this.tokenManager;
38308
+ }
37841
38309
  async request(method, path, body) {
37842
- await this.ensureAuthenticated();
37843
- const response = await this.fetchWithRetry(method, path, body, "application/json", false);
38310
+ const response = await this.fetchAuthed(method, path, body, "application/json");
37844
38311
  const text = await response.text();
37845
38312
  if (debugLogEnabled()) {
37846
38313
  console.error(`[ofw-debug] response body: ${text || "<empty>"}`);
@@ -37849,23 +38316,45 @@ var OFWClient = class {
37849
38316
  }
37850
38317
  /** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
37851
38318
  async requestBinary(method, path) {
37852
- await this.ensureAuthenticated();
37853
- const response = await this.fetchWithRetry(method, path, void 0, "application/octet-stream", false);
38319
+ const response = await this.fetchAuthed(method, path, void 0, "application/octet-stream");
37854
38320
  return {
37855
38321
  body: Buffer.from(await response.arrayBuffer()),
37856
38322
  contentType: response.headers.get("content-type"),
37857
38323
  suggestedFileName: parseContentDispositionFilename(response.headers.get("content-disposition") ?? "")
37858
38324
  };
37859
38325
  }
37860
- // Single fetch+retry scaffold for both JSON and binary callers. Handles
37861
- // 401 (re-auth and replay once), 429 (wait 2s and replay once), and
37862
- // turns any other non-2xx into a thrown Error.
37863
- async fetchWithRetry(method, path, body, accept, isRetry) {
38326
+ // Authenticated fetch for both JSON and binary callers. Auth (proactive
38327
+ // refresh inside the skew window + one 401-replay, guarded against a
38328
+ // double-refresh under concurrency) is delegated to the shared TokenManager's
38329
+ // `withAuth`. The 429 wait-and-replay and the non-2xx → throw remain here.
38330
+ async fetchAuthed(method, path, body, accept) {
38331
+ let attempt = 0;
38332
+ let response = await this.getTokenManager().withAuth(
38333
+ (token) => this.fetchOnce(method, path, body, accept, token, attempt++ > 0)
38334
+ );
38335
+ if (response.status === 429) {
38336
+ await new Promise((r) => setTimeout(r, 2e3));
38337
+ response = await this.getTokenManager().withAuth(
38338
+ (token) => this.fetchOnce(method, path, body, accept, token, true)
38339
+ );
38340
+ if (response.status === 429) throw new Error("Rate limited by OFW API");
38341
+ }
38342
+ if (!response.ok) {
38343
+ throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
38344
+ }
38345
+ return response;
38346
+ }
38347
+ // A single OFW API fetch with the bearer token supplied by `withAuth`.
38348
+ // Carries the per-request timeout (AbortController + setTimeout so vitest
38349
+ // fake timers can drive it and we attach a clear error message) and the
38350
+ // OFW_DEBUG_LOG instrumentation. Returns the raw Response — 401/429/non-2xx
38351
+ // handling lives in the callers (`withAuth` and `fetchAuthed`).
38352
+ async fetchOnce(method, path, body, accept, token, isRetry = false) {
37864
38353
  const isFormData = body instanceof FormData;
37865
38354
  const headers = {
37866
38355
  ...OFW_PROTOCOL_HEADERS,
37867
38356
  Accept: accept,
37868
- Authorization: `Bearer ${this.token}`
38357
+ Authorization: `Bearer ${token}`
37869
38358
  };
37870
38359
  if (body !== void 0 && !isFormData) headers["Content-Type"] = "application/json";
37871
38360
  const url2 = `${BASE_URL}${path}`;
@@ -37907,45 +38396,8 @@ var OFWClient = class {
37907
38396
  if (debugLogEnabled()) {
37908
38397
  console.error(`[ofw-debug] \u2190 ${response.status} ${response.statusText} (${Date.now() - startedAt}ms)`);
37909
38398
  }
37910
- if (response.status === 401 && !isRetry) {
37911
- this.token = null;
37912
- this.tokenExpiry = null;
37913
- await this.ensureAuthenticated();
37914
- return this.fetchWithRetry(method, path, body, accept, true);
37915
- }
37916
- if (response.status === 429) {
37917
- if (!isRetry) {
37918
- await new Promise((r) => setTimeout(r, 2e3));
37919
- return this.fetchWithRetry(method, path, body, accept, true);
37920
- }
37921
- throw new Error("Rate limited by OFW API");
37922
- }
37923
- if (!response.ok) {
37924
- throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
37925
- }
37926
38399
  return response;
37927
38400
  }
37928
- async ensureAuthenticated() {
37929
- if (!this.isTokenExpiredSoon()) return;
37930
- await this.login();
37931
- }
37932
- // Auth resolution is delegated to `./auth.ts`. This client doesn't care
37933
- // whether the token came from a password POST or from a one-shot
37934
- // fetchproxy session-snapshot — it just consumes the result.
37935
- //
37936
- // If `expiresAt` is missing (the fetchproxy path on a tab whose
37937
- // browser didn't persist tokenExpiry), we fall back to the same 6h
37938
- // estimate the password path uses. The 401-replay path covers us if
37939
- // the estimate is wrong.
37940
- async login() {
37941
- const { token, expiresAt } = await resolveAuth();
37942
- this.token = token;
37943
- this.tokenExpiry = expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS);
37944
- }
37945
- isTokenExpiredSoon() {
37946
- if (!this.token || !this.tokenExpiry) return true;
37947
- return this.tokenExpiry.getTime() - Date.now() < OFW_TOKEN_EXPIRY_SKEW_MS;
37948
- }
37949
38401
  };
37950
38402
  var client = new OFWClient();
37951
38403
 
@@ -37992,7 +38444,7 @@ function registerUserTools(server, client2) {
37992
38444
 
37993
38445
  // src/cache.ts
37994
38446
  import { DatabaseSync } from "node:sqlite";
37995
- import { mkdirSync } from "node:fs";
38447
+ import { mkdirSync, chmodSync, existsSync } from "node:fs";
37996
38448
  import { dirname as dirname2 } from "node:path";
37997
38449
  var instance = null;
37998
38450
  var SCHEMA_V1 = `
@@ -38055,14 +38507,23 @@ function migrate(db) {
38055
38507
  "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
38056
38508
  ).run("schema_version", "2");
38057
38509
  }
38510
+ function enforceCachePermissions(dbPath) {
38511
+ chmodSync(dirname2(dbPath), 448);
38512
+ chmodSync(dbPath, 384);
38513
+ for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
38514
+ if (existsSync(sibling)) chmodSync(sibling, 384);
38515
+ }
38516
+ }
38058
38517
  function openCache() {
38059
38518
  if (instance) return instance;
38060
38519
  const path = getCacheDbPath();
38061
38520
  mkdirSync(dirname2(path), { recursive: true });
38062
38521
  const db = new DatabaseSync(path);
38522
+ enforceCachePermissions(path);
38063
38523
  db.exec("PRAGMA journal_mode = WAL");
38064
38524
  db.exec("PRAGMA foreign_keys = ON");
38065
38525
  migrate(db);
38526
+ enforceCachePermissions(path);
38066
38527
  instance = { db };
38067
38528
  return instance;
38068
38529
  }
@@ -39143,7 +39604,7 @@ process.emit = function(event, ...args) {
39143
39604
  };
39144
39605
  await runMcp({
39145
39606
  name: "ofw",
39146
- version: "2.3.1",
39607
+ version: "2.3.2",
39147
39608
  // x-release-please-version
39148
39609
  deps: client,
39149
39610
  tools: [
package/dist/cache.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import { mkdirSync } from 'node:fs';
2
+ import { mkdirSync, chmodSync, existsSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { getCacheDbPath } from './config.js';
5
5
  let instance = null;
@@ -62,15 +62,33 @@ function migrate(db) {
62
62
  db.exec(SCHEMA_V2);
63
63
  db.prepare('INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run('schema_version', '2');
64
64
  }
65
+ // The cache holds full co-parenting message history — keep it private to the
66
+ // owning user. Modes are asserted on every open (not just creation): mkdirSync
67
+ // `mode` and SQLite's default file mode only apply when the path is first
68
+ // created, so a pre-existing dir/db keeps whatever (world-readable) mode it
69
+ // had. The -wal/-shm siblings appear and disappear with WAL checkpoints, hence
70
+ // the existence check.
71
+ function enforceCachePermissions(dbPath) {
72
+ chmodSync(dirname(dbPath), 0o700);
73
+ chmodSync(dbPath, 0o600);
74
+ for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
75
+ if (existsSync(sibling))
76
+ chmodSync(sibling, 0o600);
77
+ }
78
+ }
65
79
  export function openCache() {
66
80
  if (instance)
67
81
  return instance;
68
82
  const path = getCacheDbPath();
69
83
  mkdirSync(dirname(path), { recursive: true });
70
84
  const db = new DatabaseSync(path);
85
+ // First pass: lock down dir + db before WAL siblings exist.
86
+ enforceCachePermissions(path);
71
87
  db.exec('PRAGMA journal_mode = WAL');
72
88
  db.exec('PRAGMA foreign_keys = ON');
73
89
  migrate(db);
90
+ // Second pass: the migration writes created -wal/-shm — lock those down too.
91
+ enforceCachePermissions(path);
74
92
  instance = { db };
75
93
  return instance;
76
94
  }
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { loadDotenvSafely } from '@chrischall/mcp-utils';
2
+ import { TokenManager } from '@chrischall/mcp-utils/session';
2
3
  import { dirname, join } from 'path';
3
4
  import { fileURLToPath } from 'url';
4
5
  import { resolveAuth } from './auth.js';
@@ -51,12 +52,44 @@ function getRequestTimeoutMs() {
51
52
  const n = Number(raw.trim());
52
53
  return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
53
54
  }
55
+ // Sentinel "refresh token" handed to the shared TokenManager. OFW has no
56
+ // OAuth-style refresh token — every renewal re-runs the full `resolveAuth()`
57
+ // (password POST or fetchproxy snapshot). The TokenManager only refuses to
58
+ // refresh when its refresh token is `undefined`, so a non-empty placeholder
59
+ // keeps the single-flight refresh path live; the refresh callback ignores it.
60
+ const OFW_REFRESH_SENTINEL = 'ofw';
54
61
  export class OFWClient {
55
- token = null;
56
- tokenExpiry = null;
62
+ // Bearer-token lifecycle is delegated to the shared, race-safe TokenManager
63
+ // (proactive refresh inside the skew window, single-flight refresh so a burst
64
+ // of concurrent callers coalesces onto ONE `resolveAuth()`, and a 401-replay
65
+ // guarded against double-refresh). It is created lazily, seeded with an
66
+ // already-expired placeholder token so the first request drives the refresh
67
+ // callback — i.e. the original "log in on first request" behavior.
68
+ tokenManager;
69
+ getTokenManager() {
70
+ if (!this.tokenManager) {
71
+ this.tokenManager = new TokenManager({
72
+ initial: { accessToken: '', refreshToken: OFW_REFRESH_SENTINEL, expiresAt: 0 },
73
+ skewMs: OFW_TOKEN_EXPIRY_SKEW_MS,
74
+ // Map OFW's mint/refresh onto the refresh callback. `resolveAuth()`
75
+ // returns a token and a best-effort expiry; when the fetchproxy path
76
+ // can't supply one we fall back to the same 6h estimate the password
77
+ // path uses (the 401-replay covers a wrong guess). We re-arm the
78
+ // sentinel so the manager can refresh again later.
79
+ refresh: async () => {
80
+ const { token, expiresAt } = await resolveAuth();
81
+ return {
82
+ accessToken: token,
83
+ refreshToken: OFW_REFRESH_SENTINEL,
84
+ expiresAt: (expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS)).getTime(),
85
+ };
86
+ },
87
+ });
88
+ }
89
+ return this.tokenManager;
90
+ }
57
91
  async request(method, path, body) {
58
- await this.ensureAuthenticated();
59
- const response = await this.fetchWithRetry(method, path, body, 'application/json', false);
92
+ const response = await this.fetchAuthed(method, path, body, 'application/json');
60
93
  const text = await response.text();
61
94
  if (debugLogEnabled()) {
62
95
  console.error(`[ofw-debug] response body: ${text || '<empty>'}`);
@@ -65,23 +98,45 @@ export class OFWClient {
65
98
  }
66
99
  /** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
67
100
  async requestBinary(method, path) {
68
- await this.ensureAuthenticated();
69
- const response = await this.fetchWithRetry(method, path, undefined, 'application/octet-stream', false);
101
+ const response = await this.fetchAuthed(method, path, undefined, 'application/octet-stream');
70
102
  return {
71
103
  body: Buffer.from(await response.arrayBuffer()),
72
104
  contentType: response.headers.get('content-type'),
73
105
  suggestedFileName: parseContentDispositionFilename(response.headers.get('content-disposition') ?? ''),
74
106
  };
75
107
  }
76
- // Single fetch+retry scaffold for both JSON and binary callers. Handles
77
- // 401 (re-auth and replay once), 429 (wait 2s and replay once), and
78
- // turns any other non-2xx into a thrown Error.
79
- async fetchWithRetry(method, path, body, accept, isRetry) {
108
+ // Authenticated fetch for both JSON and binary callers. Auth (proactive
109
+ // refresh inside the skew window + one 401-replay, guarded against a
110
+ // double-refresh under concurrency) is delegated to the shared TokenManager's
111
+ // `withAuth`. The 429 wait-and-replay and the non-2xx → throw remain here.
112
+ async fetchAuthed(method, path, body, accept) {
113
+ // `withAuth` invokes `call` once, and again after a refresh on a 401. The
114
+ // second invocation is the replay — mark it `(retry)` in the debug log,
115
+ // preserving the prior bespoke-loop diagnostic.
116
+ let attempt = 0;
117
+ let response = await this.getTokenManager().withAuth((token) => this.fetchOnce(method, path, body, accept, token, attempt++ > 0));
118
+ if (response.status === 429) {
119
+ await new Promise((r) => setTimeout(r, 2000));
120
+ response = await this.getTokenManager().withAuth((token) => this.fetchOnce(method, path, body, accept, token, true));
121
+ if (response.status === 429)
122
+ throw new Error('Rate limited by OFW API');
123
+ }
124
+ if (!response.ok) {
125
+ throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
126
+ }
127
+ return response;
128
+ }
129
+ // A single OFW API fetch with the bearer token supplied by `withAuth`.
130
+ // Carries the per-request timeout (AbortController + setTimeout so vitest
131
+ // fake timers can drive it and we attach a clear error message) and the
132
+ // OFW_DEBUG_LOG instrumentation. Returns the raw Response — 401/429/non-2xx
133
+ // handling lives in the callers (`withAuth` and `fetchAuthed`).
134
+ async fetchOnce(method, path, body, accept, token, isRetry = false) {
80
135
  const isFormData = body instanceof FormData;
81
136
  const headers = {
82
137
  ...OFW_PROTOCOL_HEADERS,
83
138
  Accept: accept,
84
- Authorization: `Bearer ${this.token}`,
139
+ Authorization: `Bearer ${token}`,
85
140
  };
86
141
  if (body !== undefined && !isFormData)
87
142
  headers['Content-Type'] = 'application/json';
@@ -131,46 +186,7 @@ export class OFWClient {
131
186
  if (debugLogEnabled()) {
132
187
  console.error(`[ofw-debug] ← ${response.status} ${response.statusText} (${Date.now() - startedAt}ms)`);
133
188
  }
134
- if (response.status === 401 && !isRetry) {
135
- this.token = null;
136
- this.tokenExpiry = null;
137
- await this.ensureAuthenticated();
138
- return this.fetchWithRetry(method, path, body, accept, true);
139
- }
140
- if (response.status === 429) {
141
- if (!isRetry) {
142
- await new Promise((r) => setTimeout(r, 2000));
143
- return this.fetchWithRetry(method, path, body, accept, true);
144
- }
145
- throw new Error('Rate limited by OFW API');
146
- }
147
- if (!response.ok) {
148
- throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
149
- }
150
189
  return response;
151
190
  }
152
- async ensureAuthenticated() {
153
- if (!this.isTokenExpiredSoon())
154
- return;
155
- await this.login();
156
- }
157
- // Auth resolution is delegated to `./auth.ts`. This client doesn't care
158
- // whether the token came from a password POST or from a one-shot
159
- // fetchproxy session-snapshot — it just consumes the result.
160
- //
161
- // If `expiresAt` is missing (the fetchproxy path on a tab whose
162
- // browser didn't persist tokenExpiry), we fall back to the same 6h
163
- // estimate the password path uses. The 401-replay path covers us if
164
- // the estimate is wrong.
165
- async login() {
166
- const { token, expiresAt } = await resolveAuth();
167
- this.token = token;
168
- this.tokenExpiry = expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS);
169
- }
170
- isTokenExpiredSoon() {
171
- if (!this.token || !this.tokenExpiry)
172
- return true;
173
- return this.tokenExpiry.getTime() - Date.now() < OFW_TOKEN_EXPIRY_SKEW_MS;
174
- }
175
191
  }
176
192
  export const client = new OFWClient();
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.3.1', // x-release-please-version
27
+ version: '2.3.2', // x-release-please-version
28
28
  deps: client,
29
29
  tools: [
30
30
  registerUserTools,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.3.1",
3
+ "version": "2.3.2",
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>",
@@ -28,8 +28,8 @@
28
28
  "test:watch": "vitest"
29
29
  },
30
30
  "dependencies": {
31
- "@chrischall/mcp-utils": "^0.4.0",
32
- "@fetchproxy/bootstrap": "^0.11.0",
31
+ "@chrischall/mcp-utils": "^0.9.0",
32
+ "@fetchproxy/bootstrap": "^1.3.0",
33
33
  "@modelcontextprotocol/sdk": "^1.29.0",
34
34
  "dotenv": "^17.4.2",
35
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.1",
9
+ "version": "2.3.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.3.1",
14
+ "version": "2.3.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },