ofw-mcp 2.0.18 → 2.1.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.0.18"
9
+ "version": "2.1.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.0.18",
17
+ "version": "2.1.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.0.18",
4
+ "version": "2.1.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
@@ -46,6 +46,7 @@
46
46
  // specifically so it can be mocked here too. This keeps the
47
47
  // selection logic independent of either implementation.
48
48
  import { bootstrap } from '@fetchproxy/bootstrap';
49
+ import { classifyBridgeError } from '@fetchproxy/server';
49
50
  import { loginWithPassword } from './auth-password.js';
50
51
  import { parseBoolEnv } from './config.js';
51
52
  import pkg from '../package.json' with { type: 'json' };
@@ -121,6 +122,11 @@ export async function resolveAuth() {
121
122
  };
122
123
  }
123
124
  catch (e) {
125
+ // FetchproxyBridgeDownError only escapes bootstrap() after the lazy-revive retry fails — surface .hint verbatim (actionable "click toolbar icon" copy).
126
+ if (classifyBridgeError(e) === 'bridge_down') {
127
+ const downErr = e;
128
+ throw new Error(`OFW auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`);
129
+ }
124
130
  const msg = e instanceof Error ? e.message : String(e);
125
131
  throw new Error(`OFW auth: no OFW_USERNAME/OFW_PASSWORD set, and fetchproxy fallback failed: ${msg}`);
126
132
  }
package/dist/bundle.js CHANGED
@@ -7572,6 +7572,10 @@ var require_receiver = __commonJS({
7572
7572
  * extensions
7573
7573
  * @param {Boolean} [options.isServer=false] Specifies whether to operate in
7574
7574
  * client or server mode
7575
+ * @param {Number} [options.maxBufferedChunks=0] The maximum number of
7576
+ * buffered data chunks
7577
+ * @param {Number} [options.maxFragments=0] The maximum number of message
7578
+ * fragments
7575
7579
  * @param {Number} [options.maxPayload=0] The maximum allowed message length
7576
7580
  * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
7577
7581
  * not to skip UTF-8 validation for text and close messages
@@ -7582,6 +7586,8 @@ var require_receiver = __commonJS({
7582
7586
  this._binaryType = options.binaryType || BINARY_TYPES[0];
7583
7587
  this._extensions = options.extensions || {};
7584
7588
  this._isServer = !!options.isServer;
7589
+ this._maxBufferedChunks = options.maxBufferedChunks | 0;
7590
+ this._maxFragments = options.maxFragments | 0;
7585
7591
  this._maxPayload = options.maxPayload | 0;
7586
7592
  this._skipUTF8Validation = !!options.skipUTF8Validation;
7587
7593
  this[kWebSocket] = void 0;
@@ -7611,6 +7617,18 @@ var require_receiver = __commonJS({
7611
7617
  */
7612
7618
  _write(chunk, encoding, cb) {
7613
7619
  if (this._opcode === 8 && this._state == GET_INFO) return cb();
7620
+ if (this._maxBufferedChunks > 0 && this._buffers.length >= this._maxBufferedChunks) {
7621
+ cb(
7622
+ this.createError(
7623
+ RangeError,
7624
+ "Too many buffered chunks",
7625
+ false,
7626
+ 1008,
7627
+ "WS_ERR_TOO_MANY_BUFFERED_PARTS"
7628
+ )
7629
+ );
7630
+ return;
7631
+ }
7614
7632
  this._bufferedBytes += chunk.length;
7615
7633
  this._buffers.push(chunk);
7616
7634
  this.startLoop(cb);
@@ -7940,6 +7958,17 @@ var require_receiver = __commonJS({
7940
7958
  return;
7941
7959
  }
7942
7960
  if (data.length) {
7961
+ if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
7962
+ const error51 = this.createError(
7963
+ RangeError,
7964
+ "Too many message fragments",
7965
+ false,
7966
+ 1008,
7967
+ "WS_ERR_TOO_MANY_BUFFERED_PARTS"
7968
+ );
7969
+ cb(error51);
7970
+ return;
7971
+ }
7943
7972
  this._messageLength = this._totalPayloadLength;
7944
7973
  this._fragments.push(data);
7945
7974
  }
@@ -7969,6 +7998,17 @@ var require_receiver = __commonJS({
7969
7998
  cb(error51);
7970
7999
  return;
7971
8000
  }
8001
+ if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
8002
+ const error51 = this.createError(
8003
+ RangeError,
8004
+ "Too many message fragments",
8005
+ false,
8006
+ 1008,
8007
+ "WS_ERR_TOO_MANY_BUFFERED_PARTS"
8008
+ );
8009
+ cb(error51);
8010
+ return;
8011
+ }
7972
8012
  this._fragments.push(buf);
7973
8013
  }
7974
8014
  this.dataMessage(cb);
@@ -9175,6 +9215,10 @@ var require_websocket = __commonJS({
9175
9215
  * multiple times in the same tick
9176
9216
  * @param {Function} [options.generateMask] The function used to generate the
9177
9217
  * masking key
9218
+ * @param {Number} [options.maxBufferedChunks=0] The maximum number of
9219
+ * buffered data chunks
9220
+ * @param {Number} [options.maxFragments=0] The maximum number of message
9221
+ * fragments
9178
9222
  * @param {Number} [options.maxPayload=0] The maximum allowed message size
9179
9223
  * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
9180
9224
  * not to skip UTF-8 validation for text and close messages
@@ -9186,6 +9230,8 @@ var require_websocket = __commonJS({
9186
9230
  binaryType: this.binaryType,
9187
9231
  extensions: this._extensions,
9188
9232
  isServer: this._isServer,
9233
+ maxBufferedChunks: options.maxBufferedChunks,
9234
+ maxFragments: options.maxFragments,
9189
9235
  maxPayload: options.maxPayload,
9190
9236
  skipUTF8Validation: options.skipUTF8Validation
9191
9237
  });
@@ -9485,6 +9531,8 @@ var require_websocket = __commonJS({
9485
9531
  autoPong: true,
9486
9532
  closeTimeout: CLOSE_TIMEOUT,
9487
9533
  protocolVersion: protocolVersions[1],
9534
+ maxBufferedChunks: 1024 * 1024,
9535
+ maxFragments: 128 * 1024,
9488
9536
  maxPayload: 100 * 1024 * 1024,
9489
9537
  skipUTF8Validation: false,
9490
9538
  perMessageDeflate: true,
@@ -9727,6 +9775,8 @@ var require_websocket = __commonJS({
9727
9775
  websocket.setSocket(socket, head, {
9728
9776
  allowSynchronousEvents: opts.allowSynchronousEvents,
9729
9777
  generateMask: opts.generateMask,
9778
+ maxBufferedChunks: opts.maxBufferedChunks,
9779
+ maxFragments: opts.maxFragments,
9730
9780
  maxPayload: opts.maxPayload,
9731
9781
  skipUTF8Validation: opts.skipUTF8Validation
9732
9782
  });
@@ -10069,6 +10119,10 @@ var require_websocket_server = __commonJS({
10069
10119
  * called
10070
10120
  * @param {Function} [options.handleProtocols] A hook to handle protocols
10071
10121
  * @param {String} [options.host] The hostname where to bind the server
10122
+ * @param {Number} [options.maxBufferedChunks=1048576] The maximum number of
10123
+ * buffered data chunks
10124
+ * @param {Number} [options.maxFragments=131072] The maximum number of message
10125
+ * fragments
10072
10126
  * @param {Number} [options.maxPayload=104857600] The maximum allowed message
10073
10127
  * size
10074
10128
  * @param {Boolean} [options.noServer=false] Enable no server mode
@@ -10090,6 +10144,8 @@ var require_websocket_server = __commonJS({
10090
10144
  options = {
10091
10145
  allowSynchronousEvents: true,
10092
10146
  autoPong: true,
10147
+ maxBufferedChunks: 1024 * 1024,
10148
+ maxFragments: 128 * 1024,
10093
10149
  maxPayload: 100 * 1024 * 1024,
10094
10150
  skipUTF8Validation: false,
10095
10151
  perMessageDeflate: false,
@@ -10369,6 +10425,8 @@ var require_websocket_server = __commonJS({
10369
10425
  socket.removeListener("error", socketOnError);
10370
10426
  ws.setSocket(socket, head, {
10371
10427
  allowSynchronousEvents: this.options.allowSynchronousEvents,
10428
+ maxBufferedChunks: this.options.maxBufferedChunks,
10429
+ maxFragments: this.options.maxFragments,
10372
10430
  maxPayload: this.options.maxPayload,
10373
10431
  skipUTF8Validation: this.options.skipUTF8Validation
10374
10432
  });
@@ -34495,6 +34553,7 @@ import { fileURLToPath } from "url";
34495
34553
 
34496
34554
  // node_modules/@fetchproxy/protocol/dist/frames.js
34497
34555
  var PROTOCOL_VERSION = 2;
34556
+ var HKDF_SESSION_INFO = "fetchproxy/1.0.0/session";
34498
34557
  var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34499
34558
  "fetch",
34500
34559
  "read_cookies",
@@ -34569,7 +34628,7 @@ var ProtocolError = class extends Error {
34569
34628
  var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
34570
34629
  var BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
34571
34630
  var SCOPE_KEY_RE = /^[A-Za-z0-9_.\-]{1,256}$/;
34572
- var SCOPE_KEY_GLOB_RE = /^[A-Za-z0-9_.\-]{1,255}\*?$/;
34631
+ var SCOPE_KEY_GLOB_RE = /^[A-Za-z0-9_.\-]{1,256}\*?$/;
34573
34632
  var HEADER_NAME_RE = /^[A-Za-z0-9_\-]{1,128}$/;
34574
34633
  var HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i;
34575
34634
  function assertObject(x, label) {
@@ -34698,7 +34757,7 @@ function assertStoragePointersArray(value, label, declaredKeys) {
34698
34757
  if (entry.jsonPointer === void 0) {
34699
34758
  throw new ProtocolError(`${label}[${i}].jsonPointer: missing`);
34700
34759
  }
34701
- if (typeof entry.key !== "string" || !SCOPE_KEY_GLOB_RE.test(entry.key)) {
34760
+ if (typeof entry.key !== "string" || !SCOPE_KEY_RE.test(entry.key)) {
34702
34761
  throw new ProtocolError(`${label}[${i}].key: invalid key ${JSON.stringify(entry.key)}`);
34703
34762
  }
34704
34763
  if (typeof entry.jsonPointer !== "string" || !isValidJsonPointer(entry.jsonPointer)) {
@@ -34797,6 +34856,8 @@ function validateFrame(raw) {
34797
34856
  return validateReady(raw);
34798
34857
  if (t === "frame")
34799
34858
  return validateEncrypted(raw);
34859
+ if (t === "pair-pending")
34860
+ return validatePairPending(raw);
34800
34861
  throw new ProtocolError(`unknown frame type: ${String(t)}`);
34801
34862
  }
34802
34863
  function validateHello(raw) {
@@ -34898,6 +34959,17 @@ function validateEncrypted(raw) {
34898
34959
  assertBase64(raw.ciphertext, "frame.ciphertext");
34899
34960
  return raw;
34900
34961
  }
34962
+ var PAIR_CODE_RE = /^\d{3}-\d{3}$/;
34963
+ function validatePairPending(raw) {
34964
+ assertString(raw.mcpId, "pair-pending.mcpId");
34965
+ if (!isValidMcpId(raw.mcpId))
34966
+ throw new ProtocolError("pair-pending.mcpId: invalid format");
34967
+ assertString(raw.pairCode, "pair-pending.pairCode");
34968
+ if (!PAIR_CODE_RE.test(raw.pairCode)) {
34969
+ throw new ProtocolError(`pair-pending.pairCode: must match XXX-XXX, got ${String(raw.pairCode)}`);
34970
+ }
34971
+ return { type: "pair-pending", mcpId: raw.mcpId, pairCode: raw.pairCode };
34972
+ }
34901
34973
  function validateInnerFrame(raw) {
34902
34974
  assertObject(raw, "inner");
34903
34975
  const t = raw.type;
@@ -35480,15 +35552,22 @@ async function startHost(opts) {
35480
35552
  let extensionWs = null;
35481
35553
  const peers = /* @__PURE__ */ new Map();
35482
35554
  const ownInnerListeners = [];
35555
+ const disconnectListeners = [];
35556
+ const pendingPairListeners = [];
35483
35557
  let ownSession = null;
35558
+ let ownPendingPairCode = null;
35484
35559
  let resolveOwnSession;
35485
35560
  let rejectOwnSession;
35486
- const ownSessionReady = new Promise((resolve2, reject) => {
35487
- resolveOwnSession = resolve2;
35488
- rejectOwnSession = reject;
35489
- });
35490
- ownSessionReady.catch(() => {
35491
- });
35561
+ let ownSessionReady;
35562
+ function resetSessionPromise() {
35563
+ ownSessionReady = new Promise((resolve2, reject) => {
35564
+ resolveOwnSession = resolve2;
35565
+ rejectOwnSession = reject;
35566
+ });
35567
+ ownSessionReady.catch(() => {
35568
+ });
35569
+ }
35570
+ resetSessionPromise();
35492
35571
  let extensionHello = null;
35493
35572
  wss.on("connection", (ws) => {
35494
35573
  let identified = null;
@@ -35557,11 +35636,12 @@ async function startHost(opts) {
35557
35636
  }
35558
35637
  const extPub = fromB64(frame.extensionSessionPub);
35559
35638
  const shared = await ecdhX25519(opts.ownIdentity.x25519Priv, extPub);
35560
- const key = await hkdfSha256(shared, ownSessionNonce, enc2.encode("fetchproxy/0.1.0/session"), 32);
35561
- if (!ownSession) {
35562
- ownSession = new SessionState(key);
35563
- resolveOwnSession(ownSession);
35564
- }
35639
+ const key = await hkdfSha256(shared, ownSessionNonce, enc2.encode(HKDF_SESSION_INFO), 32);
35640
+ if (extensionWs !== ws)
35641
+ return;
35642
+ ownSession = new SessionState(key);
35643
+ ownPendingPairCode = null;
35644
+ resolveOwnSession(ownSession);
35565
35645
  } else {
35566
35646
  const slot = peers.get(frame.mcpId);
35567
35647
  if (slot)
@@ -35588,6 +35668,16 @@ async function startHost(opts) {
35588
35668
  extensionWs.send(JSON.stringify(frame));
35589
35669
  }
35590
35670
  }
35671
+ if (frame.type === "pair-pending" && identified === "extension") {
35672
+ if (frame.mcpId === opts.ownMcpId) {
35673
+ ownPendingPairCode = frame.pairCode;
35674
+ pendingPairListeners.forEach((cb) => cb(frame.pairCode));
35675
+ } else {
35676
+ const slot = peers.get(frame.mcpId);
35677
+ if (slot)
35678
+ slot.ws.send(JSON.stringify(frame));
35679
+ }
35680
+ }
35591
35681
  } catch (e) {
35592
35682
  console.error("[fetchproxy] host: message handler error:", e);
35593
35683
  try {
@@ -35603,6 +35693,9 @@ async function startHost(opts) {
35603
35693
  if (!ownSession) {
35604
35694
  rejectOwnSession(new Error("extension disconnected before ready"));
35605
35695
  }
35696
+ ownSession = null;
35697
+ resetSessionPromise();
35698
+ disconnectListeners.forEach((cb) => cb());
35606
35699
  }
35607
35700
  if (identified === "peer" && peerMcpId)
35608
35701
  peers.delete(peerMcpId);
@@ -35627,7 +35720,14 @@ async function startHost(opts) {
35627
35720
  },
35628
35721
  onOwnInner: (cb) => {
35629
35722
  ownInnerListeners.push(cb);
35630
- }
35723
+ },
35724
+ onExtensionDisconnect: (cb) => {
35725
+ disconnectListeners.push(cb);
35726
+ },
35727
+ onPendingPair: (cb) => {
35728
+ pendingPairListeners.push(cb);
35729
+ },
35730
+ pendingPairCode: () => ownPendingPairCode
35631
35731
  };
35632
35732
  }
35633
35733
 
@@ -35657,36 +35757,57 @@ async function startPeer(opts) {
35657
35757
  const sessionNonce = fromB64(hello.sessionNonce);
35658
35758
  ws.send(JSON.stringify(hello));
35659
35759
  const innerListeners = [];
35760
+ const renegotiateListeners = [];
35761
+ const pendingPairListeners = [];
35660
35762
  let session = null;
35763
+ let pendingPairCode = null;
35764
+ let resolveFirstReady;
35765
+ let rejectFirstReady;
35661
35766
  const sessionPromise = new Promise((resolve2, reject) => {
35662
- const onMessage = async (data) => {
35663
- try {
35664
- const raw = JSON.parse(data.toString());
35665
- const frame = validateFrame(raw);
35666
- if (frame.type === "ready" && frame.mcpId === opts.mcpId) {
35667
- const extPub = fromB64(frame.extensionSessionPub);
35668
- const shared = await ecdhX25519(opts.identity.x25519Priv, extPub);
35669
- const sessionKey = await hkdfSha256(shared, sessionNonce, enc3.encode("fetchproxy/0.1.0/session"), 32);
35670
- session = new SessionState(sessionKey);
35671
- resolve2(session);
35672
- return;
35767
+ resolveFirstReady = resolve2;
35768
+ rejectFirstReady = reject;
35769
+ });
35770
+ const onMessage = async (data) => {
35771
+ try {
35772
+ const raw = JSON.parse(data.toString());
35773
+ const frame = validateFrame(raw);
35774
+ if (frame.type === "ready" && frame.mcpId === opts.mcpId) {
35775
+ const extPub = fromB64(frame.extensionSessionPub);
35776
+ const shared = await ecdhX25519(opts.identity.x25519Priv, extPub);
35777
+ const sessionKey = await hkdfSha256(shared, sessionNonce, enc3.encode(HKDF_SESSION_INFO), 32);
35778
+ const isRenegotiation = session !== null;
35779
+ session = new SessionState(sessionKey);
35780
+ pendingPairCode = null;
35781
+ if (isRenegotiation) {
35782
+ renegotiateListeners.forEach((cb) => cb());
35783
+ } else {
35784
+ resolveFirstReady(session);
35673
35785
  }
35674
- if (frame.type === "frame" && frame.mcpId === opts.mcpId) {
35675
- if (!session)
35676
- return;
35677
- if (!session.acceptInboundSeq(frame.seq))
35678
- return;
35786
+ return;
35787
+ }
35788
+ if (frame.type === "pair-pending" && frame.mcpId === opts.mcpId) {
35789
+ pendingPairCode = frame.pairCode;
35790
+ pendingPairListeners.forEach((cb) => cb(frame.pairCode));
35791
+ return;
35792
+ }
35793
+ if (frame.type === "frame" && frame.mcpId === opts.mcpId) {
35794
+ if (!session)
35795
+ return;
35796
+ if (!session.acceptInboundSeq(frame.seq))
35797
+ return;
35798
+ try {
35679
35799
  const inner = await openEncryptedFrame(session.sessionKey, frame);
35680
35800
  innerListeners.forEach((cb) => cb(inner));
35801
+ } catch {
35681
35802
  }
35682
- } catch (e) {
35683
- reject(e instanceof Error ? e : new Error(String(e)));
35684
35803
  }
35685
- };
35686
- ws.on("message", onMessage);
35687
- ws.once("close", () => {
35688
- reject(new Error("peer WS closed before ready"));
35689
- });
35804
+ } catch (e) {
35805
+ rejectFirstReady(e instanceof Error ? e : new Error(String(e)));
35806
+ }
35807
+ };
35808
+ ws.on("message", onMessage);
35809
+ ws.once("close", () => {
35810
+ rejectFirstReady(new Error("peer WS closed before ready"));
35690
35811
  });
35691
35812
  sessionPromise.catch(() => {
35692
35813
  });
@@ -35694,13 +35815,21 @@ async function startPeer(opts) {
35694
35815
  ws,
35695
35816
  session: sessionPromise,
35696
35817
  sendInner: async (inner) => {
35697
- const s = await sessionPromise;
35818
+ await sessionPromise;
35819
+ const s = session;
35698
35820
  const sealed = await sealInnerFrame(s.sessionKey, opts.mcpId, s.nextOutboundSeq(), inner);
35699
35821
  ws.send(JSON.stringify(sealed));
35700
35822
  },
35701
35823
  onInner: (cb) => {
35702
35824
  innerListeners.push(cb);
35703
35825
  },
35826
+ onRenegotiate: (cb) => {
35827
+ renegotiateListeners.push(cb);
35828
+ },
35829
+ onPendingPair: (cb) => {
35830
+ pendingPairListeners.push(cb);
35831
+ },
35832
+ pendingPairCode: () => pendingPairCode,
35704
35833
  close: () => ws.close()
35705
35834
  };
35706
35835
  return handle;
@@ -35757,6 +35886,32 @@ async function loadOrCreateIdentity(serverName, dir = defaultIdentityDir()) {
35757
35886
  return id;
35758
35887
  }
35759
35888
 
35889
+ // node_modules/@fetchproxy/server/dist/error-kind.js
35890
+ function classifyFetchError(error51) {
35891
+ if (/Could not establish connection/i.test(error51) || /Receiving end does not exist/i.test(error51)) {
35892
+ return "content_script_unreachable";
35893
+ }
35894
+ if (/^tab fetch failed:/.test(error51)) {
35895
+ return "tab_fetch_failed";
35896
+ }
35897
+ if (/^fetch threw:/.test(error51)) {
35898
+ return "tab_fetch_failed";
35899
+ }
35900
+ if (/^no tab matching /.test(error51)) {
35901
+ return "no_tab";
35902
+ }
35903
+ if (/not in domains \[/.test(error51)) {
35904
+ return "domain_denied";
35905
+ }
35906
+ if (/^capability .+ not granted/.test(error51)) {
35907
+ return "capability_denied";
35908
+ }
35909
+ if (/^(request|response) body too large:/.test(error51)) {
35910
+ return "body_too_large";
35911
+ }
35912
+ return "other";
35913
+ }
35914
+
35760
35915
  // node_modules/@fetchproxy/server/dist/ws-server.js
35761
35916
  var FetchproxyProtocolError = class extends Error {
35762
35917
  constructor(message) {
@@ -35772,6 +35927,52 @@ var FetchproxyHttpError = class extends Error {
35772
35927
  this.name = "FetchproxyHttpError";
35773
35928
  }
35774
35929
  };
35930
+ var FetchproxyBridgeDownError = class extends FetchproxyProtocolError {
35931
+ originalError;
35932
+ retryAttempted;
35933
+ op;
35934
+ url;
35935
+ /** 0.8.0+: bridge role at throw time; `null` if listen() hadn't bound yet. */
35936
+ role;
35937
+ /** 0.8.0+: bridge port at throw time (the same port `listen()` bound to). */
35938
+ port;
35939
+ hint;
35940
+ constructor(args) {
35941
+ const retryAttempted = args.retryAttempted ?? false;
35942
+ const op = args.op ?? "fetch";
35943
+ const retryClause = retryAttempted ? `Server already burned a one-shot lazy-revive retry; SW is still down. ` : `Server lazy-revive retry was disabled (bridgeReviveDelayMs unset/0). `;
35944
+ const hint = `the fetchproxy extension's service worker is not responding ("${args.originalError}"). Chrome evicts extension service workers after ~30s idle by default. ${retryClause}Wake it by clicking the fetchproxy extension toolbar icon, then retry. If it keeps happening, reload the extension from chrome://extensions.`;
35945
+ super(`fetchproxy bridge down during ${op}${args.url ? ` (${args.url})` : ""}. ${hint}`);
35946
+ this.name = "FetchproxyBridgeDownError";
35947
+ this.originalError = args.originalError;
35948
+ this.retryAttempted = retryAttempted;
35949
+ this.op = op;
35950
+ if (args.url !== void 0)
35951
+ this.url = args.url;
35952
+ this.role = args.role ?? null;
35953
+ this.port = args.port ?? 0;
35954
+ this.hint = hint;
35955
+ }
35956
+ };
35957
+ var FetchproxyTimeoutError = class extends FetchproxyProtocolError {
35958
+ url;
35959
+ timeoutMs;
35960
+ /** 0.8.0+: bridge role at throw time; `null` if listen() hadn't bound yet. */
35961
+ role;
35962
+ /** 0.8.0+: bridge port at throw time. */
35963
+ port;
35964
+ /** 0.8.0+: actual elapsed milliseconds when the timer won the race. */
35965
+ elapsedMs;
35966
+ constructor(args) {
35967
+ super(`fetchproxy: ${args.url} did not respond within ${args.timeoutMs}ms`);
35968
+ this.name = "FetchproxyTimeoutError";
35969
+ this.url = args.url;
35970
+ this.timeoutMs = args.timeoutMs;
35971
+ this.role = args.role ?? null;
35972
+ this.port = args.port ?? 0;
35973
+ this.elapsedMs = args.elapsedMs ?? args.timeoutMs;
35974
+ }
35975
+ };
35775
35976
  var SUBDOMAIN_LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
35776
35977
  function assertSubdomainLabel(label) {
35777
35978
  if (!SUBDOMAIN_LABEL_RE.test(label)) {
@@ -35799,12 +36000,28 @@ function assertUrlInDomains(field, url2, domains) {
35799
36000
  }
35800
36001
  var DEFAULT_JSON_OK_STATUSES = [200, 201, 202, 204];
35801
36002
  var FetchproxyServer = class {
35802
- /** Set after `listen()` succeeds. Null while not listening. */
36003
+ /**
36004
+ * Bridge role. `null` until the first verb call (or an explicit
36005
+ * `connect()`) — `listen()` no longer triggers the role election
36006
+ * as of 0.5.3+. Reset to `null` on `close()`.
36007
+ */
35803
36008
  role = null;
35804
36009
  opts;
35805
36010
  hostHandle = null;
35806
36011
  peerHandle = null;
35807
36012
  nextRequestId = 1;
36013
+ // 0.8.0+: process-wide freshness counters surfaced via bridgeHealth().
36014
+ // Replaces the local copies every downstream MCP was rolling on top
36015
+ // of its own transport adapter — see realty-mcp cohort drift notes.
36016
+ // Updated by recordSuccess / recordFailure from fetch + capture paths.
36017
+ // `lastExtensionMessageAt` (#23 ask 4) is updated whenever any inner
36018
+ // frame from the extension arrives — gives extension-side liveness
36019
+ // distinct from per-call success/failure.
36020
+ lastSuccessAt = null;
36021
+ lastFailureAt = null;
36022
+ lastFailureReason = null;
36023
+ consecutiveFailures = 0;
36024
+ lastExtensionMessageAt = null;
35808
36025
  pending = /* @__PURE__ */ new Map();
35809
36026
  // Separate pending map for read_cookies so the response shape (cookies
35810
36027
  // string vs status/body) doesn't have to share a union type with fetch.
@@ -35821,6 +36038,12 @@ var FetchproxyServer = class {
35821
36038
  pendingIdb = /* @__PURE__ */ new Map();
35822
36039
  mcpId = null;
35823
36040
  identity = null;
36041
+ // 0.5.3+: in-flight role-election / handle-start promise. Set the
36042
+ // first time a verb call runs `ensureConnected`, awaited by concurrent
36043
+ // callers, cleared once the connection is up. Single source of truth
36044
+ // for "we're connecting right now" so two parallel first-calls don't
36045
+ // race the port bind.
36046
+ connectingPromise = null;
35824
36047
  constructor(opts) {
35825
36048
  if (!Array.isArray(opts.domains) || opts.domains.length === 0) {
35826
36049
  throw new Error("FetchproxyServer: opts.domains must be a non-empty array of hostnames");
@@ -35867,28 +36090,98 @@ var FetchproxyServer = class {
35867
36090
  key: d.key,
35868
36091
  jsonPointer: d.jsonPointer
35869
36092
  })),
36093
+ // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
36094
+ // adapter was about to set these to the same numbers anyway; the
36095
+ // back-door is `0` (explicit opt-out) if a caller genuinely wants
36096
+ // the legacy hang-forever / fail-once-on-SW-eviction behavior.
36097
+ fetchTimeoutMs: opts.fetchTimeoutMs ?? 3e4,
36098
+ bridgeReviveDelayMs: opts.bridgeReviveDelayMs ?? 2e3,
35870
36099
  identityDir: opts.identityDir,
35871
36100
  onPairCode: opts.onPairCode
35872
36101
  };
35873
36102
  }
35874
36103
  /**
35875
- * Start the WebSocket bridge. Loads the long-term identity keypair
35876
- * from disk (creating it on first call), elects the host-vs-peer
35877
- * role by attempting to bind the configured port, and stands up the
35878
- * matching handshake machinery. Idempotent only insofar as it leaves
35879
- * `role` non-null on success; calling `listen()` twice without an
35880
- * intervening `close()` is a programming error.
36104
+ * Prepare the bridge for use. Loads the long-term identity keypair
36105
+ * from disk (creating it on first call) and computes this instance's
36106
+ * `mcpId`. Does NOT bind the bridge port or dial any WebSocket — the
36107
+ * connection is established lazily on the first verb call (see
36108
+ * `ensureConnected` / `getOrConnect`).
36109
+ *
36110
+ * Pre-0.5.3 behavior: `listen()` also did role election and started
36111
+ * the host/peer immediately, which meant every configured-but-unused
36112
+ * MCP claimed bridge resources at MCP-client boot. Several MCPs
36113
+ * starting in parallel under Claude Desktop also produced noisy
36114
+ * `ERR_CONNECTION_REFUSED` errors in the extension if it raced ahead
36115
+ * of the first MCP's port bind. Deferring keeps boot quiet and
36116
+ * leaves the port unowned until something actually needs it.
36117
+ *
36118
+ * Calling `listen()` twice without an intervening `close()` is a
36119
+ * no-op (the second call's identity load is idempotent).
35881
36120
  */
35882
36121
  async listen() {
35883
- this.identity = await loadOrCreateIdentity(this.opts.serverName, this.opts.identityDir);
35884
- this.mcpId = generateMcpId(this.opts.serverName, this.opts.version);
36122
+ if (!this.identity) {
36123
+ this.identity = await loadOrCreateIdentity(this.opts.serverName, this.opts.identityDir);
36124
+ }
36125
+ if (!this.mcpId) {
36126
+ this.mcpId = generateMcpId(this.opts.serverName, this.opts.version);
36127
+ }
36128
+ }
36129
+ /**
36130
+ * Force an eager bridge connection (role-election + host/peer handle
36131
+ * start + listener wiring) without waiting for the first verb call.
36132
+ * Useful for callers that want to surface the role / connection
36133
+ * outcome at boot, or for tests whose harness dials a mock extension
36134
+ * immediately after server construction. Production MCPs that just
36135
+ * answer tool calls should NOT call this — the lazy connect via
36136
+ * `ensureConnected` will do the right thing on first use, keeping
36137
+ * boot cheap and avoiding port-bind contention for MCPs that never
36138
+ * actually get invoked.
36139
+ *
36140
+ * Idempotent: a second call after the first has resolved is a no-op
36141
+ * (the existing handle is reused). Throws if `listen()` was never
36142
+ * called.
36143
+ */
36144
+ async connect() {
36145
+ await this.ensureConnected();
36146
+ }
36147
+ /**
36148
+ * Establish the bridge connection (role-election + host/peer handle
36149
+ * start + listener wiring) the first time a verb is invoked.
36150
+ * Idempotent after the connection is up; concurrent first-callers
36151
+ * share the same in-flight promise so only one election happens.
36152
+ *
36153
+ * Throws if `listen()` was never called — the contract is that the
36154
+ * MCP author still must wire `transport.start()` at boot to load
36155
+ * identity / set mcpId, even though the WS doesn't open until a
36156
+ * verb runs.
36157
+ */
36158
+ async ensureConnected() {
36159
+ if (this.hostHandle || this.peerHandle)
36160
+ return;
36161
+ if (this.connectingPromise) {
36162
+ await this.connectingPromise;
36163
+ return;
36164
+ }
36165
+ if (!this.identity || !this.mcpId) {
36166
+ throw new Error("FetchproxyServer: ensureConnected called before listen() \u2014 call listen() at MCP boot to load identity");
36167
+ }
36168
+ this.connectingPromise = this.doConnect();
36169
+ try {
36170
+ await this.connectingPromise;
36171
+ } finally {
36172
+ this.connectingPromise = null;
36173
+ }
36174
+ }
36175
+ async doConnect() {
36176
+ const identity = this.identity;
36177
+ const mcpId = this.mcpId;
35885
36178
  const el = await electRole({ host: this.opts.host, port: this.opts.port });
35886
36179
  if (el.role === "host") {
35887
36180
  this.role = "host";
35888
36181
  this.hostHandle = await startHost({
35889
36182
  httpServer: el.server,
35890
- ownIdentity: this.identity,
35891
- ownMcpId: this.mcpId,
36183
+ ownIdentity: identity,
36184
+ ownMcpId: mcpId,
35892
36185
  ownServerName: this.opts.serverName,
35893
36186
  ownVersion: this.opts.version,
35894
36187
  ownDomains: this.opts.domains,
@@ -35903,13 +36196,17 @@ var FetchproxyServer = class {
35903
36196
  onPairCode: this.opts.onPairCode
35904
36197
  });
35905
36198
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
36199
+ this.hostHandle.onExtensionDisconnect(() => this.rejectAllPending());
36200
+ this.hostHandle.onPendingPair((code) => {
36201
+ this.rejectAllPending(this.pairingErrorMessage(code));
36202
+ });
35906
36203
  } else {
35907
36204
  this.role = "peer";
35908
36205
  this.peerHandle = await startPeer({
35909
36206
  host: this.opts.host,
35910
36207
  port: this.opts.port,
35911
- identity: this.identity,
35912
- mcpId: this.mcpId,
36208
+ identity,
36209
+ mcpId,
35913
36210
  serverName: this.opts.serverName,
35914
36211
  version: this.opts.version,
35915
36212
  domains: this.opts.domains,
@@ -35923,8 +36220,19 @@ var FetchproxyServer = class {
35923
36220
  sessionStoragePointers: this.opts.sessionStoragePointers
35924
36221
  });
35925
36222
  this.peerHandle.onInner((inner) => this.onInner(inner));
36223
+ this.peerHandle.onRenegotiate(() => this.rejectAllPending());
36224
+ this.peerHandle.onPendingPair((code) => {
36225
+ this.rejectAllPending(this.pairingErrorMessage(code));
36226
+ });
36227
+ if (this.opts.onPairCode) {
36228
+ const cb = this.opts.onPairCode;
36229
+ this.peerHandle.onPendingPair((code) => cb(code));
36230
+ }
35926
36231
  }
35927
36232
  }
36233
+ pairingErrorMessage(code) {
36234
+ return `fetchproxy transport error: pairing required for ${this.opts.serverName}. Tell the user to open the Transporter browser extension popup and approve the pair request. The pair code is: ${code} \u2014 display this code to the user so they can verify it matches.`;
36235
+ }
35928
36236
  /**
35929
36237
  * Raw single-shot fetch through the bridge. Most callers should prefer
35930
36238
  * the verb shortcuts (`get` / `post` / `getJson` / `postJson` / `getHtml`)
@@ -35940,9 +36248,75 @@ var FetchproxyServer = class {
35940
36248
  * offline, etc.).
35941
36249
  */
35942
36250
  async fetch(init) {
35943
- if (!this.hostHandle && !this.peerHandle) {
35944
- throw new Error("FetchproxyServer.fetch called before listen() \u2014 not listening");
36251
+ await this.ensureConnected();
36252
+ const pendingCode = this.currentPendingPairCode();
36253
+ if (pendingCode !== null) {
36254
+ const error51 = this.pairingErrorMessage(pendingCode);
36255
+ return {
36256
+ ok: false,
36257
+ error: error51,
36258
+ kind: classifyFetchError(error51),
36259
+ retryAttempted: false
36260
+ };
35945
36261
  }
36262
+ const first = await this._fetchOnceWithTimeout(init);
36263
+ const reviveMs = this.opts.bridgeReviveDelayMs;
36264
+ let final = first;
36265
+ if (!first.ok && first.kind === "content_script_unreachable" && reviveMs !== void 0 && reviveMs > 0) {
36266
+ await new Promise((r) => setTimeout(r, reviveMs));
36267
+ const second = await this._fetchOnceWithTimeout(init);
36268
+ if (second.ok)
36269
+ this.recordSuccess();
36270
+ else
36271
+ this.recordFailure(`${second.kind ?? "other"}: ${second.error}`);
36272
+ return { ...second, retryAttempted: true };
36273
+ }
36274
+ if (first.ok)
36275
+ this.recordSuccess();
36276
+ else
36277
+ this.recordFailure(`${first.kind ?? "other"}: ${first.error}`);
36278
+ return { ...first, retryAttempted: false };
36279
+ }
36280
+ /**
36281
+ * 0.8.0+: snapshot of the bridge's process-wide freshness counters,
36282
+ * suitable for surfacing through a downstream MCP's healthcheck tool.
36283
+ * Counters reset on a success (consecutiveFailures), accumulate
36284
+ * across the process lifetime otherwise. Replaces the per-MCP
36285
+ * duplication the realty cohort had been rolling in their adapters.
36286
+ * `lastExtensionMessageAt` is updated whenever ANY inner frame
36287
+ * arrives from the extension — gives extension-side liveness
36288
+ * distinct from server-side success/failure of the user-visible
36289
+ * call (addresses #23 ask 4).
36290
+ */
36291
+ bridgeHealth() {
36292
+ return {
36293
+ role: this.role,
36294
+ port: this.opts.port,
36295
+ serverVersion: this.opts.version,
36296
+ fetchTimeoutMs: this.opts.fetchTimeoutMs ?? 0,
36297
+ bridgeReviveDelayMs: this.opts.bridgeReviveDelayMs ?? 0,
36298
+ lastSuccessAt: this.lastSuccessAt,
36299
+ lastFailureAt: this.lastFailureAt,
36300
+ lastFailureReason: this.lastFailureReason,
36301
+ consecutiveFailures: this.consecutiveFailures,
36302
+ lastExtensionMessageAt: this.lastExtensionMessageAt
36303
+ };
36304
+ }
36305
+ recordSuccess() {
36306
+ this.lastSuccessAt = Date.now();
36307
+ this.consecutiveFailures = 0;
36308
+ }
36309
+ recordFailure(reason) {
36310
+ this.lastFailureAt = Date.now();
36311
+ this.lastFailureReason = reason;
36312
+ this.consecutiveFailures += 1;
36313
+ }
36314
+ /**
36315
+ * Single bridge round-trip, wrapped by `fetchTimeoutMs` when set.
36316
+ * On timeout returns the `{ok:false, kind:'timeout'}` envelope —
36317
+ * the throwing surface is the convenience methods.
36318
+ */
36319
+ async _fetchOnceWithTimeout(init) {
35946
36320
  const id = this.nextRequestId++;
35947
36321
  const inner = { type: "request", id, op: "fetch", init };
35948
36322
  const pending = new Promise((resolve2) => {
@@ -35953,7 +36327,61 @@ var FetchproxyServer = class {
35953
36327
  } else if (this.peerHandle) {
35954
36328
  await this.peerHandle.sendInner(inner);
35955
36329
  }
35956
- return pending;
36330
+ const timeoutMs = this.opts.fetchTimeoutMs;
36331
+ if (timeoutMs === void 0 || timeoutMs <= 0)
36332
+ return pending;
36333
+ let timer;
36334
+ const start = Date.now();
36335
+ try {
36336
+ return await Promise.race([
36337
+ pending,
36338
+ new Promise((resolve2) => {
36339
+ timer = setTimeout(() => {
36340
+ this.pending.delete(id);
36341
+ const elapsedMs = Date.now() - start;
36342
+ const error51 = `fetchproxy: ${init.url} did not respond within ${timeoutMs}ms`;
36343
+ resolve2({
36344
+ ok: false,
36345
+ error: error51,
36346
+ kind: "timeout",
36347
+ retryAttempted: false,
36348
+ elapsedMs
36349
+ });
36350
+ }, timeoutMs);
36351
+ })
36352
+ ]);
36353
+ } finally {
36354
+ if (timer)
36355
+ clearTimeout(timer);
36356
+ }
36357
+ }
36358
+ /**
36359
+ * Map an `ok:false` fetch result to its typed throwable. Centralizes
36360
+ * the kind-to-error-class switch so `request()` and (via the same
36361
+ * logic re-implemented inline) `captureRequestHeader()` agree on what
36362
+ * to throw.
36363
+ */
36364
+ _typedErrorFor(result, url2, op, retryAttempted) {
36365
+ if (result.kind === "timeout") {
36366
+ return new FetchproxyTimeoutError({
36367
+ url: url2,
36368
+ timeoutMs: this.opts.fetchTimeoutMs ?? 0,
36369
+ role: this.role,
36370
+ port: this.opts.port,
36371
+ elapsedMs: result.elapsedMs
36372
+ });
36373
+ }
36374
+ if (result.kind === "content_script_unreachable") {
36375
+ return new FetchproxyBridgeDownError({
36376
+ originalError: result.error,
36377
+ retryAttempted,
36378
+ op,
36379
+ url: url2,
36380
+ role: this.role,
36381
+ port: this.opts.port
36382
+ });
36383
+ }
36384
+ return new FetchproxyProtocolError(result.error);
35957
36385
  }
35958
36386
  /**
35959
36387
  * Convenience wrapper around `fetch()`. Builds the URL from a path
@@ -35976,8 +36404,18 @@ var FetchproxyServer = class {
35976
36404
  if (opts.subdomain !== void 0)
35977
36405
  assertSubdomainLabel(opts.subdomain);
35978
36406
  const baseDomain = this.resolveBaseDomain(opts.domain);
35979
- const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
35980
- const url2 = path.startsWith("http://") || path.startsWith("https://") ? path : `https://${host}${path}`;
36407
+ const isAbsolute2 = path.startsWith("http://") || path.startsWith("https://");
36408
+ let host;
36409
+ if (isAbsolute2) {
36410
+ try {
36411
+ host = new URL(path).host;
36412
+ } catch {
36413
+ throw new Error(`FetchproxyServer.request: absolute path is not a valid URL: ${JSON.stringify(path)}`);
36414
+ }
36415
+ } else {
36416
+ host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
36417
+ }
36418
+ const url2 = isAbsolute2 ? path : `https://${host}${path}`;
35981
36419
  assertUrlInDomains("request url", url2, this.opts.domains);
35982
36420
  const init = {
35983
36421
  url: url2,
@@ -35988,7 +36426,7 @@ var FetchproxyServer = class {
35988
36426
  };
35989
36427
  const result = await this.fetch(init);
35990
36428
  if (!result.ok) {
35991
- throw new FetchproxyProtocolError(result.error);
36429
+ throw this._typedErrorFor(result, init.url, "fetch", result.retryAttempted ?? false);
35992
36430
  }
35993
36431
  const response = {
35994
36432
  status: result.status,
@@ -36082,9 +36520,8 @@ var FetchproxyServer = class {
36082
36520
  if (!this.opts.capabilities.includes("read_cookies")) {
36083
36521
  throw new Error('FetchproxyServer.readCookies(): MCP did not declare "read_cookies" in capabilities \u2014 add it to FetchproxyServerOpts.capabilities to enable this verb');
36084
36522
  }
36085
- if (!this.hostHandle && !this.peerHandle) {
36086
- throw new Error("FetchproxyServer.readCookies called before listen() \u2014 not listening");
36087
- }
36523
+ await this.ensureConnected();
36524
+ this.throwIfPendingPair();
36088
36525
  if (opts.subdomain !== void 0)
36089
36526
  assertSubdomainLabel(opts.subdomain);
36090
36527
  const baseDomain = this.resolveBaseDomain(opts.domain);
@@ -36141,9 +36578,8 @@ var FetchproxyServer = class {
36141
36578
  if (!this.opts.capabilities.includes(op)) {
36142
36579
  throw new Error(`FetchproxyServer.${op === "read_local_storage" ? "readLocalStorage" : "readSessionStorage"}(): MCP did not declare ${JSON.stringify(op)} in capabilities`);
36143
36580
  }
36144
- if (!this.hostHandle && !this.peerHandle) {
36145
- throw new Error(`FetchproxyServer.${op} called before listen() \u2014 not listening`);
36146
- }
36581
+ await this.ensureConnected();
36582
+ this.throwIfPendingPair();
36147
36583
  if (!Array.isArray(opts.keys) || opts.keys.length === 0) {
36148
36584
  throw new Error(`FetchproxyServer.${op}: opts.keys must be a non-empty array`);
36149
36585
  }
@@ -36198,13 +36634,75 @@ var FetchproxyServer = class {
36198
36634
  if (!this.opts.capabilities.includes("capture_request_header")) {
36199
36635
  throw new Error('FetchproxyServer.captureRequestHeader(): MCP did not declare "capture_request_header" in capabilities');
36200
36636
  }
36201
- if (!this.hostHandle && !this.peerHandle) {
36202
- throw new Error("FetchproxyServer.captureRequestHeader called before listen() \u2014 not listening");
36637
+ await this.ensureConnected();
36638
+ this.throwIfPendingPair();
36639
+ const decls = this.opts.captureHeaders;
36640
+ let resolved;
36641
+ if (opts?.urlPattern !== void 0 && opts?.headerName !== void 0) {
36642
+ const found = decls.find((d) => d.urlPattern === opts.urlPattern && d.headerName === opts.headerName);
36643
+ if (!found) {
36644
+ throw new Error(`FetchproxyServer.captureRequestHeader: (urlPattern=${JSON.stringify(opts.urlPattern)}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
36645
+ }
36646
+ resolved = found;
36647
+ } else if (opts?.urlPattern === void 0 && opts?.headerName === void 0) {
36648
+ if (decls.length === 0) {
36649
+ 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");
36650
+ }
36651
+ if (decls.length > 1) {
36652
+ const list = decls.map((d) => `${JSON.stringify(d.urlPattern)}/${JSON.stringify(d.headerName)}`).join(", ");
36653
+ throw new Error(`FetchproxyServer.captureRequestHeader: multiple captureHeaders declared (${decls.length}: ${list}); pass {urlPattern, headerName} to disambiguate`);
36654
+ }
36655
+ resolved = decls[0];
36656
+ } else {
36657
+ throw new Error("FetchproxyServer.captureRequestHeader: pass both urlPattern AND headerName, or neither (which defaults to the single declared entry)");
36203
36658
  }
36204
- const declared = this.opts.captureHeaders.find((d) => d.urlPattern === opts.urlPattern && d.headerName === opts.headerName);
36205
- if (!declared) {
36206
- throw new Error(`FetchproxyServer.captureRequestHeader: (urlPattern=${JSON.stringify(opts.urlPattern)}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
36659
+ const callOpts = { ...resolved, ...opts?.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {} };
36660
+ try {
36661
+ const result = await this._captureRequestHeaderOnce(callOpts);
36662
+ this.recordSuccess();
36663
+ return result;
36664
+ } catch (err) {
36665
+ const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
36666
+ if (!swDown) {
36667
+ this.recordFailure(`capture_request_header: ${err.message ?? String(err)}`);
36668
+ throw err;
36669
+ }
36670
+ const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
36671
+ if (reviveMs > 0) {
36672
+ await new Promise((r) => setTimeout(r, reviveMs));
36673
+ try {
36674
+ const result = await this._captureRequestHeaderOnce(callOpts);
36675
+ this.recordSuccess();
36676
+ return result;
36677
+ } catch (retryErr) {
36678
+ const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
36679
+ if (!stillDown) {
36680
+ this.recordFailure(`capture_request_header: ${retryErr.message ?? String(retryErr)}`);
36681
+ throw retryErr;
36682
+ }
36683
+ this.recordFailure(`capture_request_header bridge-down: ${retryErr.message}`);
36684
+ throw new FetchproxyBridgeDownError({
36685
+ originalError: retryErr.message,
36686
+ retryAttempted: true,
36687
+ op: "capture_request_header",
36688
+ url: resolved.urlPattern,
36689
+ role: this.role,
36690
+ port: this.opts.port
36691
+ });
36692
+ }
36693
+ }
36694
+ this.recordFailure(`capture_request_header bridge-down: ${err.message}`);
36695
+ throw new FetchproxyBridgeDownError({
36696
+ originalError: err.message,
36697
+ retryAttempted: false,
36698
+ op: "capture_request_header",
36699
+ url: resolved.urlPattern,
36700
+ role: this.role,
36701
+ port: this.opts.port
36702
+ });
36207
36703
  }
36704
+ }
36705
+ async _captureRequestHeaderOnce(opts) {
36208
36706
  const id = this.nextRequestId++;
36209
36707
  const inner = {
36210
36708
  type: "request",
@@ -36241,9 +36739,8 @@ var FetchproxyServer = class {
36241
36739
  if (!this.opts.capabilities.includes("read_indexed_db")) {
36242
36740
  throw new Error('FetchproxyServer.readIndexedDb(): MCP did not declare "read_indexed_db" in capabilities');
36243
36741
  }
36244
- if (!this.hostHandle && !this.peerHandle) {
36245
- throw new Error("FetchproxyServer.readIndexedDb called before listen() \u2014 not listening");
36246
- }
36742
+ await this.ensureConnected();
36743
+ this.throwIfPendingPair();
36247
36744
  if (!Array.isArray(opts.keys) || opts.keys.length === 0) {
36248
36745
  throw new Error("FetchproxyServer.readIndexedDb: opts.keys must be a non-empty array");
36249
36746
  }
@@ -36314,17 +36811,35 @@ var FetchproxyServer = class {
36314
36811
  onInner(inner) {
36315
36812
  if (inner.type !== "response")
36316
36813
  return;
36814
+ this.lastExtensionMessageAt = Date.now();
36317
36815
  const fetchCb = this.pending.get(inner.id);
36318
36816
  if (fetchCb) {
36319
36817
  this.pending.delete(inner.id);
36320
36818
  if (inner.ok) {
36321
36819
  if (inner.op === void 0 || inner.op === "fetch") {
36322
- fetchCb({ ok: true, status: inner.status, url: inner.url, body: inner.body });
36820
+ fetchCb({
36821
+ ok: true,
36822
+ status: inner.status,
36823
+ url: inner.url,
36824
+ body: inner.body,
36825
+ retryAttempted: false
36826
+ });
36323
36827
  } else {
36324
- fetchCb({ ok: false, error: `unexpected ${inner.op} response on fetch awaiter` });
36828
+ const error51 = `unexpected ${inner.op} response on fetch awaiter`;
36829
+ fetchCb({
36830
+ ok: false,
36831
+ error: error51,
36832
+ kind: classifyFetchError(error51),
36833
+ retryAttempted: false
36834
+ });
36325
36835
  }
36326
36836
  } else {
36327
- fetchCb({ ok: false, error: inner.error });
36837
+ fetchCb({
36838
+ ok: false,
36839
+ error: inner.error,
36840
+ kind: classifyFetchError(inner.error),
36841
+ retryAttempted: false
36842
+ });
36328
36843
  }
36329
36844
  return;
36330
36845
  }
@@ -36391,6 +36906,57 @@ var FetchproxyServer = class {
36391
36906
  }
36392
36907
  }
36393
36908
  }
36909
+ rejectAllPending(reason = "extension disconnected") {
36910
+ const err = new FetchproxyProtocolError(reason);
36911
+ for (const cb of this.pending.values()) {
36912
+ cb({
36913
+ ok: false,
36914
+ error: err.message,
36915
+ kind: classifyFetchError(err.message),
36916
+ retryAttempted: false
36917
+ });
36918
+ }
36919
+ this.pending.clear();
36920
+ for (const cb of this.pendingReadCookies.values()) {
36921
+ cb({ ok: false, error: err.message });
36922
+ }
36923
+ this.pendingReadCookies.clear();
36924
+ for (const { reject } of this.pendingStorage.values())
36925
+ reject(err);
36926
+ this.pendingStorage.clear();
36927
+ for (const { reject } of this.pendingCapture.values())
36928
+ reject(err);
36929
+ this.pendingCapture.clear();
36930
+ for (const { reject } of this.pendingIdb.values())
36931
+ reject(err);
36932
+ this.pendingIdb.clear();
36933
+ }
36934
+ /**
36935
+ * 0.5.2+: read the current pair-pending pair code from whichever handle
36936
+ * is active, returning null when none is pending. Public verbs call this
36937
+ * at the top so that a tool invoked while the bridge is waiting on user
36938
+ * approval fails fast with the actionable error rather than hanging on a
36939
+ * sealed frame the extension will never process.
36940
+ */
36941
+ currentPendingPairCode() {
36942
+ if (this.hostHandle)
36943
+ return this.hostHandle.pendingPairCode();
36944
+ if (this.peerHandle)
36945
+ return this.peerHandle.pendingPairCode();
36946
+ return null;
36947
+ }
36948
+ /**
36949
+ * 0.5.2+: throw `FetchproxyProtocolError` with the actionable pair-code
36950
+ * message if the bridge is waiting on user approval. Used by the verb
36951
+ * methods (readCookies, readLocalStorage, etc.) that surface errors via
36952
+ * thrown exceptions rather than `ok:false` discriminated unions.
36953
+ */
36954
+ throwIfPendingPair() {
36955
+ const code = this.currentPendingPairCode();
36956
+ if (code !== null) {
36957
+ throw new FetchproxyProtocolError(this.pairingErrorMessage(code));
36958
+ }
36959
+ }
36394
36960
  /**
36395
36961
  * Shut down the bridge. Host: terminates the WebSocket server and any
36396
36962
  * still-attached extension/peer clients. Peer: closes the upstream
@@ -36398,6 +36964,10 @@ var FetchproxyServer = class {
36398
36964
  * twice in a row.
36399
36965
  */
36400
36966
  async close() {
36967
+ this.rejectAllPending();
36968
+ if (this.connectingPromise) {
36969
+ await this.connectingPromise.catch(() => void 0);
36970
+ }
36401
36971
  if (this.hostHandle)
36402
36972
  await this.hostHandle.close();
36403
36973
  if (this.peerHandle)
@@ -36405,9 +36975,23 @@ var FetchproxyServer = class {
36405
36975
  this.hostHandle = null;
36406
36976
  this.peerHandle = null;
36407
36977
  this.role = null;
36978
+ this.connectingPromise = null;
36408
36979
  }
36409
36980
  };
36410
36981
 
36982
+ // node_modules/@fetchproxy/server/dist/classify-bridge-error.js
36983
+ function classifyBridgeError(err) {
36984
+ if (err instanceof FetchproxyTimeoutError)
36985
+ return "timeout";
36986
+ if (err instanceof FetchproxyBridgeDownError)
36987
+ return "bridge_down";
36988
+ if (err instanceof FetchproxyHttpError)
36989
+ return "http";
36990
+ if (err instanceof FetchproxyProtocolError)
36991
+ return "protocol";
36992
+ return "other";
36993
+ }
36994
+
36411
36995
  // node_modules/@fetchproxy/bootstrap/dist/index.js
36412
36996
  var defaultFactory = (opts) => new FetchproxyServer(opts);
36413
36997
  async function bootstrap(opts) {
@@ -36462,7 +37046,11 @@ async function bootstrap(opts) {
36462
37046
  key: p.storageKey,
36463
37047
  jsonPointer: p.jsonPointer
36464
37048
  })),
36465
- onPairCode: opts.onPairCode
37049
+ onPairCode: opts.onPairCode,
37050
+ // 0.8.0+ pass-through. Only forwarded when the caller set them;
37051
+ // unset → server defaults apply (30000 / 2000 in 0.8.0).
37052
+ ...opts.fetchTimeoutMs !== void 0 ? { fetchTimeoutMs: opts.fetchTimeoutMs } : {},
37053
+ ...opts.bridgeReviveDelayMs !== void 0 ? { bridgeReviveDelayMs: opts.bridgeReviveDelayMs } : {}
36466
37054
  });
36467
37055
  const storageDomainOpts = {};
36468
37056
  if (opts.storageDomain !== void 0)
@@ -36493,8 +37081,7 @@ async function bootstrap(opts) {
36493
37081
  for (const p of localStoragePointers) {
36494
37082
  pointers[p.outputKey] = { storageKey: p.storageKey, jsonPointer: p.jsonPointer };
36495
37083
  }
36496
- const stub = server2;
36497
- localStorage = await stub.readLocalStorage({
37084
+ localStorage = await server2.readLocalStorage({
36498
37085
  keys: allKeys,
36499
37086
  ...storageDomainOpts,
36500
37087
  ...localStoragePointers.length > 0 ? { pointers } : {}
@@ -36507,8 +37094,7 @@ async function bootstrap(opts) {
36507
37094
  for (const p of sessionStoragePointers) {
36508
37095
  pointers[p.outputKey] = { storageKey: p.storageKey, jsonPointer: p.jsonPointer };
36509
37096
  }
36510
- const stub = server2;
36511
- sessionStorage = await stub.readSessionStorage({
37097
+ sessionStorage = await server2.readSessionStorage({
36512
37098
  keys: allKeys,
36513
37099
  ...storageDomainOpts,
36514
37100
  ...sessionStoragePointers.length > 0 ? { pointers } : {}
@@ -36531,9 +37117,6 @@ async function bootstrap(opts) {
36531
37117
  }
36532
37118
  const indexedDbBucket = {};
36533
37119
  for (const d of indexedDb) {
36534
- if (!server2.readIndexedDb) {
36535
- throw new Error("bootstrap: server factory does not implement readIndexedDb (declared indexedDb but server stub omits it)");
36536
- }
36537
37120
  const values = await server2.readIndexedDb({
36538
37121
  database: d.database,
36539
37122
  store: d.store,
@@ -36652,7 +37235,7 @@ function getDefaultInlineAttachments() {
36652
37235
  // package.json
36653
37236
  var package_default = {
36654
37237
  name: "ofw-mcp",
36655
- version: "2.0.18",
37238
+ version: "2.1.0",
36656
37239
  mcpName: "io.github.chrischall/ofw-mcp",
36657
37240
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
36658
37241
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -36679,17 +37262,18 @@ var package_default = {
36679
37262
  "test:watch": "vitest"
36680
37263
  },
36681
37264
  dependencies: {
36682
- "@fetchproxy/bootstrap": "^0.4.2",
37265
+ "@fetchproxy/bootstrap": "^0.8.0",
37266
+ "@fetchproxy/server": "^0.8.0",
36683
37267
  "@modelcontextprotocol/sdk": "^1.29.0",
36684
- dotenv: "^17.4.0",
36685
- zod: "^4.4.2"
37268
+ dotenv: "^17.4.2",
37269
+ zod: "^4.4.3"
36686
37270
  },
36687
37271
  devDependencies: {
36688
- "@types/node": "^25.8.0",
36689
- "@vitest/coverage-v8": "^4.1.6",
37272
+ "@types/node": "^25.9.1",
37273
+ "@vitest/coverage-v8": "^4.1.7",
36690
37274
  esbuild: "^0.28.0",
36691
- typescript: "^6.0.2",
36692
- vitest: "^4.1.6"
37275
+ typescript: "^6.0.3",
37276
+ vitest: "^4.1.7"
36693
37277
  }
36694
37278
  };
36695
37279
 
@@ -36746,6 +37330,12 @@ async function resolveAuth() {
36746
37330
  source: "fetchproxy"
36747
37331
  };
36748
37332
  } catch (e) {
37333
+ if (classifyBridgeError(e) === "bridge_down") {
37334
+ const downErr = e;
37335
+ throw new Error(
37336
+ `OFW auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
37337
+ );
37338
+ }
36749
37339
  const msg = e instanceof Error ? e.message : String(e);
36750
37340
  throw new Error(
36751
37341
  `OFW auth: no OFW_USERNAME/OFW_PASSWORD set, and fetchproxy fallback failed: ${msg}`
@@ -38039,7 +38629,7 @@ process.emit = function(event, ...args) {
38039
38629
  }
38040
38630
  return originalEmit(event, ...args);
38041
38631
  };
38042
- var server = new McpServer({ name: "ofw", version: "2.0.18" });
38632
+ var server = new McpServer({ name: "ofw", version: "2.1.0" });
38043
38633
  registerUserTools(server, client);
38044
38634
  registerMessageTools(server, client);
38045
38635
  registerCalendarTools(server, client);
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import { registerMessageTools } from './tools/messages.js';
17
17
  import { registerCalendarTools } from './tools/calendar.js';
18
18
  import { registerExpenseTools } from './tools/expenses.js';
19
19
  import { registerJournalTools } from './tools/journal.js';
20
- const server = new McpServer({ name: 'ofw', version: '2.0.18' }); // x-release-please-version
20
+ const server = new McpServer({ name: 'ofw', version: '2.1.0' }); // x-release-please-version
21
21
  registerUserTools(server, client);
22
22
  registerMessageTools(server, client);
23
23
  registerCalendarTools(server, client);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.0.18",
3
+ "version": "2.1.0",
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>",
@@ -27,16 +27,17 @@
27
27
  "test:watch": "vitest"
28
28
  },
29
29
  "dependencies": {
30
- "@fetchproxy/bootstrap": "^0.4.2",
30
+ "@fetchproxy/bootstrap": "^0.8.0",
31
+ "@fetchproxy/server": "^0.8.0",
31
32
  "@modelcontextprotocol/sdk": "^1.29.0",
32
- "dotenv": "^17.4.0",
33
- "zod": "^4.4.2"
33
+ "dotenv": "^17.4.2",
34
+ "zod": "^4.4.3"
34
35
  },
35
36
  "devDependencies": {
36
- "@types/node": "^25.8.0",
37
- "@vitest/coverage-v8": "^4.1.6",
37
+ "@types/node": "^25.9.1",
38
+ "@vitest/coverage-v8": "^4.1.7",
38
39
  "esbuild": "^0.28.0",
39
- "typescript": "^6.0.2",
40
- "vitest": "^4.1.6"
40
+ "typescript": "^6.0.3",
41
+ "vitest": "^4.1.7"
41
42
  }
42
43
  }
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.0.18",
9
+ "version": "2.1.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.0.18",
14
+ "version": "2.1.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },