ofw-mcp 2.0.19 → 2.2.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.
package/dist/bundle.js CHANGED
@@ -3112,6 +3112,9 @@ var require_utils = __commonJS({
3112
3112
  "use strict";
3113
3113
  var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu);
3114
3114
  var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u);
3115
+ var isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu);
3116
+ var isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu);
3117
+ var isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu);
3115
3118
  function stringArrayToHexStripped(input) {
3116
3119
  let acc = "";
3117
3120
  let code = 0;
@@ -3304,27 +3307,77 @@ var require_utils = __commonJS({
3304
3307
  }
3305
3308
  return output.join("");
3306
3309
  }
3307
- function normalizeComponentEncoding(component, esc2) {
3308
- const func = esc2 !== true ? escape : unescape;
3309
- if (component.scheme !== void 0) {
3310
- component.scheme = func(component.scheme);
3311
- }
3312
- if (component.userinfo !== void 0) {
3313
- component.userinfo = func(component.userinfo);
3314
- }
3315
- if (component.host !== void 0) {
3316
- component.host = func(component.host);
3310
+ var HOST_DELIMS = { "@": "%40", "/": "%2F", "?": "%3F", "#": "%23", ":": "%3A" };
3311
+ var HOST_DELIM_RE = /[@/?#:]/g;
3312
+ var HOST_DELIM_NO_COLON_RE = /[@/?#]/g;
3313
+ function reescapeHostDelimiters(host, isIP) {
3314
+ const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE;
3315
+ re.lastIndex = 0;
3316
+ return host.replace(re, (ch) => HOST_DELIMS[ch]);
3317
+ }
3318
+ function normalizePercentEncoding(input, decodeUnreserved = false) {
3319
+ if (input.indexOf("%") === -1) {
3320
+ return input;
3317
3321
  }
3318
- if (component.path !== void 0) {
3319
- component.path = func(component.path);
3322
+ let output = "";
3323
+ for (let i = 0; i < input.length; i++) {
3324
+ if (input[i] === "%" && i + 2 < input.length) {
3325
+ const hex3 = input.slice(i + 1, i + 3);
3326
+ if (isHexPair(hex3)) {
3327
+ const normalizedHex = hex3.toUpperCase();
3328
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
3329
+ if (decodeUnreserved && isUnreserved(decoded)) {
3330
+ output += decoded;
3331
+ } else {
3332
+ output += "%" + normalizedHex;
3333
+ }
3334
+ i += 2;
3335
+ continue;
3336
+ }
3337
+ }
3338
+ output += input[i];
3320
3339
  }
3321
- if (component.query !== void 0) {
3322
- component.query = func(component.query);
3340
+ return output;
3341
+ }
3342
+ function normalizePathEncoding(input) {
3343
+ let output = "";
3344
+ for (let i = 0; i < input.length; i++) {
3345
+ if (input[i] === "%" && i + 2 < input.length) {
3346
+ const hex3 = input.slice(i + 1, i + 3);
3347
+ if (isHexPair(hex3)) {
3348
+ const normalizedHex = hex3.toUpperCase();
3349
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
3350
+ if (decoded !== "." && isUnreserved(decoded)) {
3351
+ output += decoded;
3352
+ } else {
3353
+ output += "%" + normalizedHex;
3354
+ }
3355
+ i += 2;
3356
+ continue;
3357
+ }
3358
+ }
3359
+ if (isPathCharacter(input[i])) {
3360
+ output += input[i];
3361
+ } else {
3362
+ output += escape(input[i]);
3363
+ }
3323
3364
  }
3324
- if (component.fragment !== void 0) {
3325
- component.fragment = func(component.fragment);
3365
+ return output;
3366
+ }
3367
+ function escapePreservingEscapes(input) {
3368
+ let output = "";
3369
+ for (let i = 0; i < input.length; i++) {
3370
+ if (input[i] === "%" && i + 2 < input.length) {
3371
+ const hex3 = input.slice(i + 1, i + 3);
3372
+ if (isHexPair(hex3)) {
3373
+ output += "%" + hex3.toUpperCase();
3374
+ i += 2;
3375
+ continue;
3376
+ }
3377
+ }
3378
+ output += escape(input[i]);
3326
3379
  }
3327
- return component;
3380
+ return output;
3328
3381
  }
3329
3382
  function recomposeAuthority(component) {
3330
3383
  const uriTokens = [];
@@ -3339,7 +3392,7 @@ var require_utils = __commonJS({
3339
3392
  if (ipV6res.isIPV6 === true) {
3340
3393
  host = `[${ipV6res.escapedHost}]`;
3341
3394
  } else {
3342
- host = component.host;
3395
+ host = reescapeHostDelimiters(host, false);
3343
3396
  }
3344
3397
  }
3345
3398
  uriTokens.push(host);
@@ -3353,7 +3406,10 @@ var require_utils = __commonJS({
3353
3406
  module.exports = {
3354
3407
  nonSimpleDomain,
3355
3408
  recomposeAuthority,
3356
- normalizeComponentEncoding,
3409
+ reescapeHostDelimiters,
3410
+ normalizePercentEncoding,
3411
+ normalizePathEncoding,
3412
+ escapePreservingEscapes,
3357
3413
  removeDotSegments,
3358
3414
  isIPv4,
3359
3415
  isUUID,
@@ -3577,12 +3633,12 @@ var require_schemes = __commonJS({
3577
3633
  var require_fast_uri = __commonJS({
3578
3634
  "node_modules/fast-uri/index.js"(exports, module) {
3579
3635
  "use strict";
3580
- var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require_utils();
3636
+ var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require_utils();
3581
3637
  var { SCHEMES, getSchemeHandler } = require_schemes();
3582
3638
  function normalize(uri, options) {
3583
3639
  if (typeof uri === "string") {
3584
3640
  uri = /** @type {T} */
3585
- serialize(parse3(uri, options), options);
3641
+ normalizeString(uri, options);
3586
3642
  } else if (typeof uri === "object") {
3587
3643
  uri = /** @type {T} */
3588
3644
  parse3(serialize(uri, options), options);
@@ -3649,19 +3705,9 @@ var require_fast_uri = __commonJS({
3649
3705
  return target;
3650
3706
  }
3651
3707
  function equal(uriA, uriB, options) {
3652
- if (typeof uriA === "string") {
3653
- uriA = unescape(uriA);
3654
- uriA = serialize(normalizeComponentEncoding(parse3(uriA, options), true), { ...options, skipEscape: true });
3655
- } else if (typeof uriA === "object") {
3656
- uriA = serialize(normalizeComponentEncoding(uriA, true), { ...options, skipEscape: true });
3657
- }
3658
- if (typeof uriB === "string") {
3659
- uriB = unescape(uriB);
3660
- uriB = serialize(normalizeComponentEncoding(parse3(uriB, options), true), { ...options, skipEscape: true });
3661
- } else if (typeof uriB === "object") {
3662
- uriB = serialize(normalizeComponentEncoding(uriB, true), { ...options, skipEscape: true });
3663
- }
3664
- return uriA.toLowerCase() === uriB.toLowerCase();
3708
+ const normalizedA = normalizeComparableURI(uriA, options);
3709
+ const normalizedB = normalizeComparableURI(uriB, options);
3710
+ return normalizedA !== void 0 && normalizedB !== void 0 && normalizedA.toLowerCase() === normalizedB.toLowerCase();
3665
3711
  }
3666
3712
  function serialize(cmpts, opts) {
3667
3713
  const component = {
@@ -3686,12 +3732,12 @@ var require_fast_uri = __commonJS({
3686
3732
  if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options);
3687
3733
  if (component.path !== void 0) {
3688
3734
  if (!options.skipEscape) {
3689
- component.path = escape(component.path);
3735
+ component.path = escapePreservingEscapes(component.path);
3690
3736
  if (component.scheme !== void 0) {
3691
3737
  component.path = component.path.split("%3A").join(":");
3692
3738
  }
3693
3739
  } else {
3694
- component.path = unescape(component.path);
3740
+ component.path = normalizePercentEncoding(component.path);
3695
3741
  }
3696
3742
  }
3697
3743
  if (options.reference !== "suffix" && component.scheme) {
@@ -3726,7 +3772,16 @@ var require_fast_uri = __commonJS({
3726
3772
  return uriTokens.join("");
3727
3773
  }
3728
3774
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
3729
- function parse3(uri, opts) {
3775
+ function getParseError(parsed, matches) {
3776
+ if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
3777
+ return 'URI path must start with "/" when authority is present.';
3778
+ }
3779
+ if (typeof parsed.port === "number" && (parsed.port < 0 || parsed.port > 65535)) {
3780
+ return "URI port is malformed.";
3781
+ }
3782
+ return void 0;
3783
+ }
3784
+ function parseWithStatus(uri, opts) {
3730
3785
  const options = Object.assign({}, opts);
3731
3786
  const parsed = {
3732
3787
  scheme: void 0,
@@ -3737,6 +3792,7 @@ var require_fast_uri = __commonJS({
3737
3792
  query: void 0,
3738
3793
  fragment: void 0
3739
3794
  };
3795
+ let malformedAuthorityOrPort = false;
3740
3796
  let isIP = false;
3741
3797
  if (options.reference === "suffix") {
3742
3798
  if (options.scheme) {
@@ -3757,6 +3813,11 @@ var require_fast_uri = __commonJS({
3757
3813
  if (isNaN(parsed.port)) {
3758
3814
  parsed.port = matches[5];
3759
3815
  }
3816
+ const parseError = getParseError(parsed, matches);
3817
+ if (parseError !== void 0) {
3818
+ parsed.error = parsed.error || parseError;
3819
+ malformedAuthorityOrPort = true;
3820
+ }
3760
3821
  if (parsed.host) {
3761
3822
  const ipv4result = isIPv4(parsed.host);
3762
3823
  if (ipv4result === false) {
@@ -3795,14 +3856,18 @@ var require_fast_uri = __commonJS({
3795
3856
  parsed.scheme = unescape(parsed.scheme);
3796
3857
  }
3797
3858
  if (parsed.host !== void 0) {
3798
- parsed.host = unescape(parsed.host);
3859
+ parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP);
3799
3860
  }
3800
3861
  }
3801
3862
  if (parsed.path) {
3802
- parsed.path = escape(unescape(parsed.path));
3863
+ parsed.path = normalizePathEncoding(parsed.path);
3803
3864
  }
3804
3865
  if (parsed.fragment) {
3805
- parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
3866
+ try {
3867
+ parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
3868
+ } catch {
3869
+ parsed.error = parsed.error || "URI malformed";
3870
+ }
3806
3871
  }
3807
3872
  }
3808
3873
  if (schemeHandler && schemeHandler.parse) {
@@ -3811,7 +3876,29 @@ var require_fast_uri = __commonJS({
3811
3876
  } else {
3812
3877
  parsed.error = parsed.error || "URI can not be parsed.";
3813
3878
  }
3814
- return parsed;
3879
+ return { parsed, malformedAuthorityOrPort };
3880
+ }
3881
+ function parse3(uri, opts) {
3882
+ return parseWithStatus(uri, opts).parsed;
3883
+ }
3884
+ function normalizeString(uri, opts) {
3885
+ return normalizeStringWithStatus(uri, opts).normalized;
3886
+ }
3887
+ function normalizeStringWithStatus(uri, opts) {
3888
+ const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts);
3889
+ return {
3890
+ normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts),
3891
+ malformedAuthorityOrPort
3892
+ };
3893
+ }
3894
+ function normalizeComparableURI(uri, opts) {
3895
+ if (typeof uri === "string") {
3896
+ const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts);
3897
+ return malformedAuthorityOrPort ? void 0 : normalized;
3898
+ }
3899
+ if (typeof uri === "object") {
3900
+ return serialize(uri, opts);
3901
+ }
3815
3902
  }
3816
3903
  var fastUri = {
3817
3904
  SCHEMES,
@@ -34856,6 +34943,8 @@ function validateFrame(raw) {
34856
34943
  return validateReady(raw);
34857
34944
  if (t === "frame")
34858
34945
  return validateEncrypted(raw);
34946
+ if (t === "pair-pending")
34947
+ return validatePairPending(raw);
34859
34948
  throw new ProtocolError(`unknown frame type: ${String(t)}`);
34860
34949
  }
34861
34950
  function validateHello(raw) {
@@ -34957,6 +35046,17 @@ function validateEncrypted(raw) {
34957
35046
  assertBase64(raw.ciphertext, "frame.ciphertext");
34958
35047
  return raw;
34959
35048
  }
35049
+ var PAIR_CODE_RE = /^\d{3}-\d{3}$/;
35050
+ function validatePairPending(raw) {
35051
+ assertString(raw.mcpId, "pair-pending.mcpId");
35052
+ if (!isValidMcpId(raw.mcpId))
35053
+ throw new ProtocolError("pair-pending.mcpId: invalid format");
35054
+ assertString(raw.pairCode, "pair-pending.pairCode");
35055
+ if (!PAIR_CODE_RE.test(raw.pairCode)) {
35056
+ throw new ProtocolError(`pair-pending.pairCode: must match XXX-XXX, got ${String(raw.pairCode)}`);
35057
+ }
35058
+ return { type: "pair-pending", mcpId: raw.mcpId, pairCode: raw.pairCode };
35059
+ }
34960
35060
  function validateInnerFrame(raw) {
34961
35061
  assertObject(raw, "inner");
34962
35062
  const t = raw.type;
@@ -35540,7 +35640,9 @@ async function startHost(opts) {
35540
35640
  const peers = /* @__PURE__ */ new Map();
35541
35641
  const ownInnerListeners = [];
35542
35642
  const disconnectListeners = [];
35643
+ const pendingPairListeners = [];
35543
35644
  let ownSession = null;
35645
+ let ownPendingPairCode = null;
35544
35646
  let resolveOwnSession;
35545
35647
  let rejectOwnSession;
35546
35648
  let ownSessionReady;
@@ -35625,6 +35727,7 @@ async function startHost(opts) {
35625
35727
  if (extensionWs !== ws)
35626
35728
  return;
35627
35729
  ownSession = new SessionState(key);
35730
+ ownPendingPairCode = null;
35628
35731
  resolveOwnSession(ownSession);
35629
35732
  } else {
35630
35733
  const slot = peers.get(frame.mcpId);
@@ -35652,6 +35755,16 @@ async function startHost(opts) {
35652
35755
  extensionWs.send(JSON.stringify(frame));
35653
35756
  }
35654
35757
  }
35758
+ if (frame.type === "pair-pending" && identified === "extension") {
35759
+ if (frame.mcpId === opts.ownMcpId) {
35760
+ ownPendingPairCode = frame.pairCode;
35761
+ pendingPairListeners.forEach((cb) => cb(frame.pairCode));
35762
+ } else {
35763
+ const slot = peers.get(frame.mcpId);
35764
+ if (slot)
35765
+ slot.ws.send(JSON.stringify(frame));
35766
+ }
35767
+ }
35655
35768
  } catch (e) {
35656
35769
  console.error("[fetchproxy] host: message handler error:", e);
35657
35770
  try {
@@ -35697,7 +35810,11 @@ async function startHost(opts) {
35697
35810
  },
35698
35811
  onExtensionDisconnect: (cb) => {
35699
35812
  disconnectListeners.push(cb);
35700
- }
35813
+ },
35814
+ onPendingPair: (cb) => {
35815
+ pendingPairListeners.push(cb);
35816
+ },
35817
+ pendingPairCode: () => ownPendingPairCode
35701
35818
  };
35702
35819
  }
35703
35820
 
@@ -35727,36 +35844,57 @@ async function startPeer(opts) {
35727
35844
  const sessionNonce = fromB64(hello.sessionNonce);
35728
35845
  ws.send(JSON.stringify(hello));
35729
35846
  const innerListeners = [];
35847
+ const renegotiateListeners = [];
35848
+ const pendingPairListeners = [];
35730
35849
  let session = null;
35850
+ let pendingPairCode = null;
35851
+ let resolveFirstReady;
35852
+ let rejectFirstReady;
35731
35853
  const sessionPromise = new Promise((resolve2, reject) => {
35732
- const onMessage = async (data) => {
35733
- try {
35734
- const raw = JSON.parse(data.toString());
35735
- const frame = validateFrame(raw);
35736
- if (frame.type === "ready" && frame.mcpId === opts.mcpId) {
35737
- const extPub = fromB64(frame.extensionSessionPub);
35738
- const shared = await ecdhX25519(opts.identity.x25519Priv, extPub);
35739
- const sessionKey = await hkdfSha256(shared, sessionNonce, enc3.encode(HKDF_SESSION_INFO), 32);
35740
- session = new SessionState(sessionKey);
35741
- resolve2(session);
35742
- return;
35854
+ resolveFirstReady = resolve2;
35855
+ rejectFirstReady = reject;
35856
+ });
35857
+ const onMessage = async (data) => {
35858
+ try {
35859
+ const raw = JSON.parse(data.toString());
35860
+ const frame = validateFrame(raw);
35861
+ if (frame.type === "ready" && frame.mcpId === opts.mcpId) {
35862
+ const extPub = fromB64(frame.extensionSessionPub);
35863
+ const shared = await ecdhX25519(opts.identity.x25519Priv, extPub);
35864
+ const sessionKey = await hkdfSha256(shared, sessionNonce, enc3.encode(HKDF_SESSION_INFO), 32);
35865
+ const isRenegotiation = session !== null;
35866
+ session = new SessionState(sessionKey);
35867
+ pendingPairCode = null;
35868
+ if (isRenegotiation) {
35869
+ renegotiateListeners.forEach((cb) => cb());
35870
+ } else {
35871
+ resolveFirstReady(session);
35743
35872
  }
35744
- if (frame.type === "frame" && frame.mcpId === opts.mcpId) {
35745
- if (!session)
35746
- return;
35747
- if (!session.acceptInboundSeq(frame.seq))
35748
- return;
35873
+ return;
35874
+ }
35875
+ if (frame.type === "pair-pending" && frame.mcpId === opts.mcpId) {
35876
+ pendingPairCode = frame.pairCode;
35877
+ pendingPairListeners.forEach((cb) => cb(frame.pairCode));
35878
+ return;
35879
+ }
35880
+ if (frame.type === "frame" && frame.mcpId === opts.mcpId) {
35881
+ if (!session)
35882
+ return;
35883
+ if (!session.acceptInboundSeq(frame.seq))
35884
+ return;
35885
+ try {
35749
35886
  const inner = await openEncryptedFrame(session.sessionKey, frame);
35750
35887
  innerListeners.forEach((cb) => cb(inner));
35888
+ } catch {
35751
35889
  }
35752
- } catch (e) {
35753
- reject(e instanceof Error ? e : new Error(String(e)));
35754
35890
  }
35755
- };
35756
- ws.on("message", onMessage);
35757
- ws.once("close", () => {
35758
- reject(new Error("peer WS closed before ready"));
35759
- });
35891
+ } catch (e) {
35892
+ rejectFirstReady(e instanceof Error ? e : new Error(String(e)));
35893
+ }
35894
+ };
35895
+ ws.on("message", onMessage);
35896
+ ws.once("close", () => {
35897
+ rejectFirstReady(new Error("peer WS closed before ready"));
35760
35898
  });
35761
35899
  sessionPromise.catch(() => {
35762
35900
  });
@@ -35764,13 +35902,21 @@ async function startPeer(opts) {
35764
35902
  ws,
35765
35903
  session: sessionPromise,
35766
35904
  sendInner: async (inner) => {
35767
- const s = await sessionPromise;
35905
+ await sessionPromise;
35906
+ const s = session;
35768
35907
  const sealed = await sealInnerFrame(s.sessionKey, opts.mcpId, s.nextOutboundSeq(), inner);
35769
35908
  ws.send(JSON.stringify(sealed));
35770
35909
  },
35771
35910
  onInner: (cb) => {
35772
35911
  innerListeners.push(cb);
35773
35912
  },
35913
+ onRenegotiate: (cb) => {
35914
+ renegotiateListeners.push(cb);
35915
+ },
35916
+ onPendingPair: (cb) => {
35917
+ pendingPairListeners.push(cb);
35918
+ },
35919
+ pendingPairCode: () => pendingPairCode,
35774
35920
  close: () => ws.close()
35775
35921
  };
35776
35922
  return handle;
@@ -35868,6 +36014,52 @@ var FetchproxyHttpError = class extends Error {
35868
36014
  this.name = "FetchproxyHttpError";
35869
36015
  }
35870
36016
  };
36017
+ var FetchproxyBridgeDownError = class extends FetchproxyProtocolError {
36018
+ originalError;
36019
+ retryAttempted;
36020
+ op;
36021
+ url;
36022
+ /** 0.8.0+: bridge role at throw time; `null` if listen() hadn't bound yet. */
36023
+ role;
36024
+ /** 0.8.0+: bridge port at throw time (the same port `listen()` bound to). */
36025
+ port;
36026
+ hint;
36027
+ constructor(args) {
36028
+ const retryAttempted = args.retryAttempted ?? false;
36029
+ const op = args.op ?? "fetch";
36030
+ 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). `;
36031
+ 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.`;
36032
+ super(`fetchproxy bridge down during ${op}${args.url ? ` (${args.url})` : ""}. ${hint}`);
36033
+ this.name = "FetchproxyBridgeDownError";
36034
+ this.originalError = args.originalError;
36035
+ this.retryAttempted = retryAttempted;
36036
+ this.op = op;
36037
+ if (args.url !== void 0)
36038
+ this.url = args.url;
36039
+ this.role = args.role ?? null;
36040
+ this.port = args.port ?? 0;
36041
+ this.hint = hint;
36042
+ }
36043
+ };
36044
+ var FetchproxyTimeoutError = class extends FetchproxyProtocolError {
36045
+ url;
36046
+ timeoutMs;
36047
+ /** 0.8.0+: bridge role at throw time; `null` if listen() hadn't bound yet. */
36048
+ role;
36049
+ /** 0.8.0+: bridge port at throw time. */
36050
+ port;
36051
+ /** 0.8.0+: actual elapsed milliseconds when the timer won the race. */
36052
+ elapsedMs;
36053
+ constructor(args) {
36054
+ super(`fetchproxy: ${args.url} did not respond within ${args.timeoutMs}ms`);
36055
+ this.name = "FetchproxyTimeoutError";
36056
+ this.url = args.url;
36057
+ this.timeoutMs = args.timeoutMs;
36058
+ this.role = args.role ?? null;
36059
+ this.port = args.port ?? 0;
36060
+ this.elapsedMs = args.elapsedMs ?? args.timeoutMs;
36061
+ }
36062
+ };
35871
36063
  var SUBDOMAIN_LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
35872
36064
  function assertSubdomainLabel(label) {
35873
36065
  if (!SUBDOMAIN_LABEL_RE.test(label)) {
@@ -35895,12 +36087,28 @@ function assertUrlInDomains(field, url2, domains) {
35895
36087
  }
35896
36088
  var DEFAULT_JSON_OK_STATUSES = [200, 201, 202, 204];
35897
36089
  var FetchproxyServer = class {
35898
- /** Set after `listen()` succeeds. Null while not listening. */
36090
+ /**
36091
+ * Bridge role. `null` until the first verb call (or an explicit
36092
+ * `connect()`) — `listen()` no longer triggers the role election
36093
+ * as of 0.5.3+. Reset to `null` on `close()`.
36094
+ */
35899
36095
  role = null;
35900
36096
  opts;
35901
36097
  hostHandle = null;
35902
36098
  peerHandle = null;
35903
36099
  nextRequestId = 1;
36100
+ // 0.8.0+: process-wide freshness counters surfaced via bridgeHealth().
36101
+ // Replaces the local copies every downstream MCP was rolling on top
36102
+ // of its own transport adapter — see realty-mcp cohort drift notes.
36103
+ // Updated by recordSuccess / recordFailure from fetch + capture paths.
36104
+ // `lastExtensionMessageAt` (#23 ask 4) is updated whenever any inner
36105
+ // frame from the extension arrives — gives extension-side liveness
36106
+ // distinct from per-call success/failure.
36107
+ lastSuccessAt = null;
36108
+ lastFailureAt = null;
36109
+ lastFailureReason = null;
36110
+ consecutiveFailures = 0;
36111
+ lastExtensionMessageAt = null;
35904
36112
  pending = /* @__PURE__ */ new Map();
35905
36113
  // Separate pending map for read_cookies so the response shape (cookies
35906
36114
  // string vs status/body) doesn't have to share a union type with fetch.
@@ -35917,6 +36125,12 @@ var FetchproxyServer = class {
35917
36125
  pendingIdb = /* @__PURE__ */ new Map();
35918
36126
  mcpId = null;
35919
36127
  identity = null;
36128
+ // 0.5.3+: in-flight role-election / handle-start promise. Set the
36129
+ // first time a verb call runs `ensureConnected`, awaited by concurrent
36130
+ // callers, cleared once the connection is up. Single source of truth
36131
+ // for "we're connecting right now" so two parallel first-calls don't
36132
+ // race the port bind.
36133
+ connectingPromise = null;
35920
36134
  constructor(opts) {
35921
36135
  if (!Array.isArray(opts.domains) || opts.domains.length === 0) {
35922
36136
  throw new Error("FetchproxyServer: opts.domains must be a non-empty array of hostnames");
@@ -35963,28 +36177,98 @@ var FetchproxyServer = class {
35963
36177
  key: d.key,
35964
36178
  jsonPointer: d.jsonPointer
35965
36179
  })),
36180
+ // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
36181
+ // adapter was about to set these to the same numbers anyway; the
36182
+ // back-door is `0` (explicit opt-out) if a caller genuinely wants
36183
+ // the legacy hang-forever / fail-once-on-SW-eviction behavior.
36184
+ fetchTimeoutMs: opts.fetchTimeoutMs ?? 3e4,
36185
+ bridgeReviveDelayMs: opts.bridgeReviveDelayMs ?? 2e3,
35966
36186
  identityDir: opts.identityDir,
35967
36187
  onPairCode: opts.onPairCode
35968
36188
  };
35969
36189
  }
35970
36190
  /**
35971
- * Start the WebSocket bridge. Loads the long-term identity keypair
35972
- * from disk (creating it on first call), elects the host-vs-peer
35973
- * role by attempting to bind the configured port, and stands up the
35974
- * matching handshake machinery. Idempotent only insofar as it leaves
35975
- * `role` non-null on success; calling `listen()` twice without an
35976
- * intervening `close()` is a programming error.
36191
+ * Prepare the bridge for use. Loads the long-term identity keypair
36192
+ * from disk (creating it on first call) and computes this instance's
36193
+ * `mcpId`. Does NOT bind the bridge port or dial any WebSocket — the
36194
+ * connection is established lazily on the first verb call (see
36195
+ * `ensureConnected` / `getOrConnect`).
36196
+ *
36197
+ * Pre-0.5.3 behavior: `listen()` also did role election and started
36198
+ * the host/peer immediately, which meant every configured-but-unused
36199
+ * MCP claimed bridge resources at MCP-client boot. Several MCPs
36200
+ * starting in parallel under Claude Desktop also produced noisy
36201
+ * `ERR_CONNECTION_REFUSED` errors in the extension if it raced ahead
36202
+ * of the first MCP's port bind. Deferring keeps boot quiet and
36203
+ * leaves the port unowned until something actually needs it.
36204
+ *
36205
+ * Calling `listen()` twice without an intervening `close()` is a
36206
+ * no-op (the second call's identity load is idempotent).
35977
36207
  */
35978
36208
  async listen() {
35979
- this.identity = await loadOrCreateIdentity(this.opts.serverName, this.opts.identityDir);
35980
- this.mcpId = generateMcpId(this.opts.serverName, this.opts.version);
36209
+ if (!this.identity) {
36210
+ this.identity = await loadOrCreateIdentity(this.opts.serverName, this.opts.identityDir);
36211
+ }
36212
+ if (!this.mcpId) {
36213
+ this.mcpId = generateMcpId(this.opts.serverName, this.opts.version);
36214
+ }
36215
+ }
36216
+ /**
36217
+ * Force an eager bridge connection (role-election + host/peer handle
36218
+ * start + listener wiring) without waiting for the first verb call.
36219
+ * Useful for callers that want to surface the role / connection
36220
+ * outcome at boot, or for tests whose harness dials a mock extension
36221
+ * immediately after server construction. Production MCPs that just
36222
+ * answer tool calls should NOT call this — the lazy connect via
36223
+ * `ensureConnected` will do the right thing on first use, keeping
36224
+ * boot cheap and avoiding port-bind contention for MCPs that never
36225
+ * actually get invoked.
36226
+ *
36227
+ * Idempotent: a second call after the first has resolved is a no-op
36228
+ * (the existing handle is reused). Throws if `listen()` was never
36229
+ * called.
36230
+ */
36231
+ async connect() {
36232
+ await this.ensureConnected();
36233
+ }
36234
+ /**
36235
+ * Establish the bridge connection (role-election + host/peer handle
36236
+ * start + listener wiring) the first time a verb is invoked.
36237
+ * Idempotent after the connection is up; concurrent first-callers
36238
+ * share the same in-flight promise so only one election happens.
36239
+ *
36240
+ * Throws if `listen()` was never called — the contract is that the
36241
+ * MCP author still must wire `transport.start()` at boot to load
36242
+ * identity / set mcpId, even though the WS doesn't open until a
36243
+ * verb runs.
36244
+ */
36245
+ async ensureConnected() {
36246
+ if (this.hostHandle || this.peerHandle)
36247
+ return;
36248
+ if (this.connectingPromise) {
36249
+ await this.connectingPromise;
36250
+ return;
36251
+ }
36252
+ if (!this.identity || !this.mcpId) {
36253
+ throw new Error("FetchproxyServer: ensureConnected called before listen() \u2014 call listen() at MCP boot to load identity");
36254
+ }
36255
+ this.connectingPromise = this.doConnect();
36256
+ try {
36257
+ await this.connectingPromise;
36258
+ } finally {
36259
+ this.connectingPromise = null;
36260
+ }
36261
+ }
36262
+ async doConnect() {
36263
+ const identity = this.identity;
36264
+ const mcpId = this.mcpId;
35981
36265
  const el = await electRole({ host: this.opts.host, port: this.opts.port });
35982
36266
  if (el.role === "host") {
35983
36267
  this.role = "host";
35984
36268
  this.hostHandle = await startHost({
35985
36269
  httpServer: el.server,
35986
- ownIdentity: this.identity,
35987
- ownMcpId: this.mcpId,
36270
+ ownIdentity: identity,
36271
+ ownMcpId: mcpId,
35988
36272
  ownServerName: this.opts.serverName,
35989
36273
  ownVersion: this.opts.version,
35990
36274
  ownDomains: this.opts.domains,
@@ -36000,13 +36284,16 @@ var FetchproxyServer = class {
36000
36284
  });
36001
36285
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
36002
36286
  this.hostHandle.onExtensionDisconnect(() => this.rejectAllPending());
36287
+ this.hostHandle.onPendingPair((code) => {
36288
+ this.rejectAllPending(this.pairingErrorMessage(code));
36289
+ });
36003
36290
  } else {
36004
36291
  this.role = "peer";
36005
36292
  this.peerHandle = await startPeer({
36006
36293
  host: this.opts.host,
36007
36294
  port: this.opts.port,
36008
- identity: this.identity,
36009
- mcpId: this.mcpId,
36295
+ identity,
36296
+ mcpId,
36010
36297
  serverName: this.opts.serverName,
36011
36298
  version: this.opts.version,
36012
36299
  domains: this.opts.domains,
@@ -36020,8 +36307,19 @@ var FetchproxyServer = class {
36020
36307
  sessionStoragePointers: this.opts.sessionStoragePointers
36021
36308
  });
36022
36309
  this.peerHandle.onInner((inner) => this.onInner(inner));
36310
+ this.peerHandle.onRenegotiate(() => this.rejectAllPending());
36311
+ this.peerHandle.onPendingPair((code) => {
36312
+ this.rejectAllPending(this.pairingErrorMessage(code));
36313
+ });
36314
+ if (this.opts.onPairCode) {
36315
+ const cb = this.opts.onPairCode;
36316
+ this.peerHandle.onPendingPair((code) => cb(code));
36317
+ }
36023
36318
  }
36024
36319
  }
36320
+ pairingErrorMessage(code) {
36321
+ 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.`;
36322
+ }
36025
36323
  /**
36026
36324
  * Raw single-shot fetch through the bridge. Most callers should prefer
36027
36325
  * the verb shortcuts (`get` / `post` / `getJson` / `postJson` / `getHtml`)
@@ -36037,9 +36335,75 @@ var FetchproxyServer = class {
36037
36335
  * offline, etc.).
36038
36336
  */
36039
36337
  async fetch(init) {
36040
- if (!this.hostHandle && !this.peerHandle) {
36041
- throw new Error("FetchproxyServer.fetch called before listen() \u2014 not listening");
36338
+ await this.ensureConnected();
36339
+ const pendingCode = this.currentPendingPairCode();
36340
+ if (pendingCode !== null) {
36341
+ const error51 = this.pairingErrorMessage(pendingCode);
36342
+ return {
36343
+ ok: false,
36344
+ error: error51,
36345
+ kind: classifyFetchError(error51),
36346
+ retryAttempted: false
36347
+ };
36042
36348
  }
36349
+ const first = await this._fetchOnceWithTimeout(init);
36350
+ const reviveMs = this.opts.bridgeReviveDelayMs;
36351
+ let final = first;
36352
+ if (!first.ok && first.kind === "content_script_unreachable" && reviveMs !== void 0 && reviveMs > 0) {
36353
+ await new Promise((r) => setTimeout(r, reviveMs));
36354
+ const second = await this._fetchOnceWithTimeout(init);
36355
+ if (second.ok)
36356
+ this.recordSuccess();
36357
+ else
36358
+ this.recordFailure(`${second.kind ?? "other"}: ${second.error}`);
36359
+ return { ...second, retryAttempted: true };
36360
+ }
36361
+ if (first.ok)
36362
+ this.recordSuccess();
36363
+ else
36364
+ this.recordFailure(`${first.kind ?? "other"}: ${first.error}`);
36365
+ return { ...first, retryAttempted: false };
36366
+ }
36367
+ /**
36368
+ * 0.8.0+: snapshot of the bridge's process-wide freshness counters,
36369
+ * suitable for surfacing through a downstream MCP's healthcheck tool.
36370
+ * Counters reset on a success (consecutiveFailures), accumulate
36371
+ * across the process lifetime otherwise. Replaces the per-MCP
36372
+ * duplication the realty cohort had been rolling in their adapters.
36373
+ * `lastExtensionMessageAt` is updated whenever ANY inner frame
36374
+ * arrives from the extension — gives extension-side liveness
36375
+ * distinct from server-side success/failure of the user-visible
36376
+ * call (addresses #23 ask 4).
36377
+ */
36378
+ bridgeHealth() {
36379
+ return {
36380
+ role: this.role,
36381
+ port: this.opts.port,
36382
+ serverVersion: this.opts.version,
36383
+ fetchTimeoutMs: this.opts.fetchTimeoutMs ?? 0,
36384
+ bridgeReviveDelayMs: this.opts.bridgeReviveDelayMs ?? 0,
36385
+ lastSuccessAt: this.lastSuccessAt,
36386
+ lastFailureAt: this.lastFailureAt,
36387
+ lastFailureReason: this.lastFailureReason,
36388
+ consecutiveFailures: this.consecutiveFailures,
36389
+ lastExtensionMessageAt: this.lastExtensionMessageAt
36390
+ };
36391
+ }
36392
+ recordSuccess() {
36393
+ this.lastSuccessAt = Date.now();
36394
+ this.consecutiveFailures = 0;
36395
+ }
36396
+ recordFailure(reason) {
36397
+ this.lastFailureAt = Date.now();
36398
+ this.lastFailureReason = reason;
36399
+ this.consecutiveFailures += 1;
36400
+ }
36401
+ /**
36402
+ * Single bridge round-trip, wrapped by `fetchTimeoutMs` when set.
36403
+ * On timeout returns the `{ok:false, kind:'timeout'}` envelope —
36404
+ * the throwing surface is the convenience methods.
36405
+ */
36406
+ async _fetchOnceWithTimeout(init) {
36043
36407
  const id = this.nextRequestId++;
36044
36408
  const inner = { type: "request", id, op: "fetch", init };
36045
36409
  const pending = new Promise((resolve2) => {
@@ -36050,7 +36414,61 @@ var FetchproxyServer = class {
36050
36414
  } else if (this.peerHandle) {
36051
36415
  await this.peerHandle.sendInner(inner);
36052
36416
  }
36053
- return pending;
36417
+ const timeoutMs = this.opts.fetchTimeoutMs;
36418
+ if (timeoutMs === void 0 || timeoutMs <= 0)
36419
+ return pending;
36420
+ let timer;
36421
+ const start = Date.now();
36422
+ try {
36423
+ return await Promise.race([
36424
+ pending,
36425
+ new Promise((resolve2) => {
36426
+ timer = setTimeout(() => {
36427
+ this.pending.delete(id);
36428
+ const elapsedMs = Date.now() - start;
36429
+ const error51 = `fetchproxy: ${init.url} did not respond within ${timeoutMs}ms`;
36430
+ resolve2({
36431
+ ok: false,
36432
+ error: error51,
36433
+ kind: "timeout",
36434
+ retryAttempted: false,
36435
+ elapsedMs
36436
+ });
36437
+ }, timeoutMs);
36438
+ })
36439
+ ]);
36440
+ } finally {
36441
+ if (timer)
36442
+ clearTimeout(timer);
36443
+ }
36444
+ }
36445
+ /**
36446
+ * Map an `ok:false` fetch result to its typed throwable. Centralizes
36447
+ * the kind-to-error-class switch so `request()` and (via the same
36448
+ * logic re-implemented inline) `captureRequestHeader()` agree on what
36449
+ * to throw.
36450
+ */
36451
+ _typedErrorFor(result, url2, op, retryAttempted) {
36452
+ if (result.kind === "timeout") {
36453
+ return new FetchproxyTimeoutError({
36454
+ url: url2,
36455
+ timeoutMs: this.opts.fetchTimeoutMs ?? 0,
36456
+ role: this.role,
36457
+ port: this.opts.port,
36458
+ elapsedMs: result.elapsedMs
36459
+ });
36460
+ }
36461
+ if (result.kind === "content_script_unreachable") {
36462
+ return new FetchproxyBridgeDownError({
36463
+ originalError: result.error,
36464
+ retryAttempted,
36465
+ op,
36466
+ url: url2,
36467
+ role: this.role,
36468
+ port: this.opts.port
36469
+ });
36470
+ }
36471
+ return new FetchproxyProtocolError(result.error);
36054
36472
  }
36055
36473
  /**
36056
36474
  * Convenience wrapper around `fetch()`. Builds the URL from a path
@@ -36073,8 +36491,18 @@ var FetchproxyServer = class {
36073
36491
  if (opts.subdomain !== void 0)
36074
36492
  assertSubdomainLabel(opts.subdomain);
36075
36493
  const baseDomain = this.resolveBaseDomain(opts.domain);
36076
- const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
36077
- const url2 = path.startsWith("http://") || path.startsWith("https://") ? path : `https://${host}${path}`;
36494
+ const isAbsolute2 = path.startsWith("http://") || path.startsWith("https://");
36495
+ let host;
36496
+ if (isAbsolute2) {
36497
+ try {
36498
+ host = new URL(path).host;
36499
+ } catch {
36500
+ throw new Error(`FetchproxyServer.request: absolute path is not a valid URL: ${JSON.stringify(path)}`);
36501
+ }
36502
+ } else {
36503
+ host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
36504
+ }
36505
+ const url2 = isAbsolute2 ? path : `https://${host}${path}`;
36078
36506
  assertUrlInDomains("request url", url2, this.opts.domains);
36079
36507
  const init = {
36080
36508
  url: url2,
@@ -36085,7 +36513,7 @@ var FetchproxyServer = class {
36085
36513
  };
36086
36514
  const result = await this.fetch(init);
36087
36515
  if (!result.ok) {
36088
- throw new FetchproxyProtocolError(result.error);
36516
+ throw this._typedErrorFor(result, init.url, "fetch", result.retryAttempted ?? false);
36089
36517
  }
36090
36518
  const response = {
36091
36519
  status: result.status,
@@ -36179,9 +36607,8 @@ var FetchproxyServer = class {
36179
36607
  if (!this.opts.capabilities.includes("read_cookies")) {
36180
36608
  throw new Error('FetchproxyServer.readCookies(): MCP did not declare "read_cookies" in capabilities \u2014 add it to FetchproxyServerOpts.capabilities to enable this verb');
36181
36609
  }
36182
- if (!this.hostHandle && !this.peerHandle) {
36183
- throw new Error("FetchproxyServer.readCookies called before listen() \u2014 not listening");
36184
- }
36610
+ await this.ensureConnected();
36611
+ this.throwIfPendingPair();
36185
36612
  if (opts.subdomain !== void 0)
36186
36613
  assertSubdomainLabel(opts.subdomain);
36187
36614
  const baseDomain = this.resolveBaseDomain(opts.domain);
@@ -36238,9 +36665,8 @@ var FetchproxyServer = class {
36238
36665
  if (!this.opts.capabilities.includes(op)) {
36239
36666
  throw new Error(`FetchproxyServer.${op === "read_local_storage" ? "readLocalStorage" : "readSessionStorage"}(): MCP did not declare ${JSON.stringify(op)} in capabilities`);
36240
36667
  }
36241
- if (!this.hostHandle && !this.peerHandle) {
36242
- throw new Error(`FetchproxyServer.${op} called before listen() \u2014 not listening`);
36243
- }
36668
+ await this.ensureConnected();
36669
+ this.throwIfPendingPair();
36244
36670
  if (!Array.isArray(opts.keys) || opts.keys.length === 0) {
36245
36671
  throw new Error(`FetchproxyServer.${op}: opts.keys must be a non-empty array`);
36246
36672
  }
@@ -36295,13 +36721,75 @@ var FetchproxyServer = class {
36295
36721
  if (!this.opts.capabilities.includes("capture_request_header")) {
36296
36722
  throw new Error('FetchproxyServer.captureRequestHeader(): MCP did not declare "capture_request_header" in capabilities');
36297
36723
  }
36298
- if (!this.hostHandle && !this.peerHandle) {
36299
- throw new Error("FetchproxyServer.captureRequestHeader called before listen() \u2014 not listening");
36724
+ await this.ensureConnected();
36725
+ this.throwIfPendingPair();
36726
+ const decls = this.opts.captureHeaders;
36727
+ let resolved;
36728
+ if (opts?.urlPattern !== void 0 && opts?.headerName !== void 0) {
36729
+ const found = decls.find((d) => d.urlPattern === opts.urlPattern && d.headerName === opts.headerName);
36730
+ if (!found) {
36731
+ throw new Error(`FetchproxyServer.captureRequestHeader: (urlPattern=${JSON.stringify(opts.urlPattern)}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
36732
+ }
36733
+ resolved = found;
36734
+ } else if (opts?.urlPattern === void 0 && opts?.headerName === void 0) {
36735
+ if (decls.length === 0) {
36736
+ 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");
36737
+ }
36738
+ if (decls.length > 1) {
36739
+ const list = decls.map((d) => `${JSON.stringify(d.urlPattern)}/${JSON.stringify(d.headerName)}`).join(", ");
36740
+ throw new Error(`FetchproxyServer.captureRequestHeader: multiple captureHeaders declared (${decls.length}: ${list}); pass {urlPattern, headerName} to disambiguate`);
36741
+ }
36742
+ resolved = decls[0];
36743
+ } else {
36744
+ throw new Error("FetchproxyServer.captureRequestHeader: pass both urlPattern AND headerName, or neither (which defaults to the single declared entry)");
36300
36745
  }
36301
- const declared = this.opts.captureHeaders.find((d) => d.urlPattern === opts.urlPattern && d.headerName === opts.headerName);
36302
- if (!declared) {
36303
- throw new Error(`FetchproxyServer.captureRequestHeader: (urlPattern=${JSON.stringify(opts.urlPattern)}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
36746
+ const callOpts = { ...resolved, ...opts?.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {} };
36747
+ try {
36748
+ const result = await this._captureRequestHeaderOnce(callOpts);
36749
+ this.recordSuccess();
36750
+ return result;
36751
+ } catch (err) {
36752
+ const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
36753
+ if (!swDown) {
36754
+ this.recordFailure(`capture_request_header: ${err.message ?? String(err)}`);
36755
+ throw err;
36756
+ }
36757
+ const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
36758
+ if (reviveMs > 0) {
36759
+ await new Promise((r) => setTimeout(r, reviveMs));
36760
+ try {
36761
+ const result = await this._captureRequestHeaderOnce(callOpts);
36762
+ this.recordSuccess();
36763
+ return result;
36764
+ } catch (retryErr) {
36765
+ const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
36766
+ if (!stillDown) {
36767
+ this.recordFailure(`capture_request_header: ${retryErr.message ?? String(retryErr)}`);
36768
+ throw retryErr;
36769
+ }
36770
+ this.recordFailure(`capture_request_header bridge-down: ${retryErr.message}`);
36771
+ throw new FetchproxyBridgeDownError({
36772
+ originalError: retryErr.message,
36773
+ retryAttempted: true,
36774
+ op: "capture_request_header",
36775
+ url: resolved.urlPattern,
36776
+ role: this.role,
36777
+ port: this.opts.port
36778
+ });
36779
+ }
36780
+ }
36781
+ this.recordFailure(`capture_request_header bridge-down: ${err.message}`);
36782
+ throw new FetchproxyBridgeDownError({
36783
+ originalError: err.message,
36784
+ retryAttempted: false,
36785
+ op: "capture_request_header",
36786
+ url: resolved.urlPattern,
36787
+ role: this.role,
36788
+ port: this.opts.port
36789
+ });
36304
36790
  }
36791
+ }
36792
+ async _captureRequestHeaderOnce(opts) {
36305
36793
  const id = this.nextRequestId++;
36306
36794
  const inner = {
36307
36795
  type: "request",
@@ -36338,9 +36826,8 @@ var FetchproxyServer = class {
36338
36826
  if (!this.opts.capabilities.includes("read_indexed_db")) {
36339
36827
  throw new Error('FetchproxyServer.readIndexedDb(): MCP did not declare "read_indexed_db" in capabilities');
36340
36828
  }
36341
- if (!this.hostHandle && !this.peerHandle) {
36342
- throw new Error("FetchproxyServer.readIndexedDb called before listen() \u2014 not listening");
36343
- }
36829
+ await this.ensureConnected();
36830
+ this.throwIfPendingPair();
36344
36831
  if (!Array.isArray(opts.keys) || opts.keys.length === 0) {
36345
36832
  throw new Error("FetchproxyServer.readIndexedDb: opts.keys must be a non-empty array");
36346
36833
  }
@@ -36411,18 +36898,35 @@ var FetchproxyServer = class {
36411
36898
  onInner(inner) {
36412
36899
  if (inner.type !== "response")
36413
36900
  return;
36901
+ this.lastExtensionMessageAt = Date.now();
36414
36902
  const fetchCb = this.pending.get(inner.id);
36415
36903
  if (fetchCb) {
36416
36904
  this.pending.delete(inner.id);
36417
36905
  if (inner.ok) {
36418
36906
  if (inner.op === void 0 || inner.op === "fetch") {
36419
- fetchCb({ ok: true, status: inner.status, url: inner.url, body: inner.body });
36907
+ fetchCb({
36908
+ ok: true,
36909
+ status: inner.status,
36910
+ url: inner.url,
36911
+ body: inner.body,
36912
+ retryAttempted: false
36913
+ });
36420
36914
  } else {
36421
36915
  const error51 = `unexpected ${inner.op} response on fetch awaiter`;
36422
- fetchCb({ ok: false, error: error51, kind: classifyFetchError(error51) });
36916
+ fetchCb({
36917
+ ok: false,
36918
+ error: error51,
36919
+ kind: classifyFetchError(error51),
36920
+ retryAttempted: false
36921
+ });
36423
36922
  }
36424
36923
  } else {
36425
- fetchCb({ ok: false, error: inner.error, kind: classifyFetchError(inner.error) });
36924
+ fetchCb({
36925
+ ok: false,
36926
+ error: inner.error,
36927
+ kind: classifyFetchError(inner.error),
36928
+ retryAttempted: false
36929
+ });
36426
36930
  }
36427
36931
  return;
36428
36932
  }
@@ -36489,10 +36993,15 @@ var FetchproxyServer = class {
36489
36993
  }
36490
36994
  }
36491
36995
  }
36492
- rejectAllPending() {
36493
- const err = new FetchproxyProtocolError("extension disconnected");
36996
+ rejectAllPending(reason = "extension disconnected") {
36997
+ const err = new FetchproxyProtocolError(reason);
36494
36998
  for (const cb of this.pending.values()) {
36495
- cb({ ok: false, error: err.message, kind: classifyFetchError(err.message) });
36999
+ cb({
37000
+ ok: false,
37001
+ error: err.message,
37002
+ kind: classifyFetchError(err.message),
37003
+ retryAttempted: false
37004
+ });
36496
37005
  }
36497
37006
  this.pending.clear();
36498
37007
  for (const cb of this.pendingReadCookies.values()) {
@@ -36509,6 +37018,32 @@ var FetchproxyServer = class {
36509
37018
  reject(err);
36510
37019
  this.pendingIdb.clear();
36511
37020
  }
37021
+ /**
37022
+ * 0.5.2+: read the current pair-pending pair code from whichever handle
37023
+ * is active, returning null when none is pending. Public verbs call this
37024
+ * at the top so that a tool invoked while the bridge is waiting on user
37025
+ * approval fails fast with the actionable error rather than hanging on a
37026
+ * sealed frame the extension will never process.
37027
+ */
37028
+ currentPendingPairCode() {
37029
+ if (this.hostHandle)
37030
+ return this.hostHandle.pendingPairCode();
37031
+ if (this.peerHandle)
37032
+ return this.peerHandle.pendingPairCode();
37033
+ return null;
37034
+ }
37035
+ /**
37036
+ * 0.5.2+: throw `FetchproxyProtocolError` with the actionable pair-code
37037
+ * message if the bridge is waiting on user approval. Used by the verb
37038
+ * methods (readCookies, readLocalStorage, etc.) that surface errors via
37039
+ * thrown exceptions rather than `ok:false` discriminated unions.
37040
+ */
37041
+ throwIfPendingPair() {
37042
+ const code = this.currentPendingPairCode();
37043
+ if (code !== null) {
37044
+ throw new FetchproxyProtocolError(this.pairingErrorMessage(code));
37045
+ }
37046
+ }
36512
37047
  /**
36513
37048
  * Shut down the bridge. Host: terminates the WebSocket server and any
36514
37049
  * still-attached extension/peer clients. Peer: closes the upstream
@@ -36517,6 +37052,9 @@ var FetchproxyServer = class {
36517
37052
  */
36518
37053
  async close() {
36519
37054
  this.rejectAllPending();
37055
+ if (this.connectingPromise) {
37056
+ await this.connectingPromise.catch(() => void 0);
37057
+ }
36520
37058
  if (this.hostHandle)
36521
37059
  await this.hostHandle.close();
36522
37060
  if (this.peerHandle)
@@ -36524,9 +37062,23 @@ var FetchproxyServer = class {
36524
37062
  this.hostHandle = null;
36525
37063
  this.peerHandle = null;
36526
37064
  this.role = null;
37065
+ this.connectingPromise = null;
36527
37066
  }
36528
37067
  };
36529
37068
 
37069
+ // node_modules/@fetchproxy/server/dist/classify-bridge-error.js
37070
+ function classifyBridgeError(err) {
37071
+ if (err instanceof FetchproxyTimeoutError)
37072
+ return "timeout";
37073
+ if (err instanceof FetchproxyBridgeDownError)
37074
+ return "bridge_down";
37075
+ if (err instanceof FetchproxyHttpError)
37076
+ return "http";
37077
+ if (err instanceof FetchproxyProtocolError)
37078
+ return "protocol";
37079
+ return "other";
37080
+ }
37081
+
36530
37082
  // node_modules/@fetchproxy/bootstrap/dist/index.js
36531
37083
  var defaultFactory = (opts) => new FetchproxyServer(opts);
36532
37084
  async function bootstrap(opts) {
@@ -36581,7 +37133,11 @@ async function bootstrap(opts) {
36581
37133
  key: p.storageKey,
36582
37134
  jsonPointer: p.jsonPointer
36583
37135
  })),
36584
- onPairCode: opts.onPairCode
37136
+ onPairCode: opts.onPairCode,
37137
+ // 0.8.0+ pass-through. Only forwarded when the caller set them;
37138
+ // unset → server defaults apply (30000 / 2000 in 0.8.0).
37139
+ ...opts.fetchTimeoutMs !== void 0 ? { fetchTimeoutMs: opts.fetchTimeoutMs } : {},
37140
+ ...opts.bridgeReviveDelayMs !== void 0 ? { bridgeReviveDelayMs: opts.bridgeReviveDelayMs } : {}
36585
37141
  });
36586
37142
  const storageDomainOpts = {};
36587
37143
  if (opts.storageDomain !== void 0)
@@ -36766,7 +37322,7 @@ function getDefaultInlineAttachments() {
36766
37322
  // package.json
36767
37323
  var package_default = {
36768
37324
  name: "ofw-mcp",
36769
- version: "2.0.19",
37325
+ version: "2.2.0",
36770
37326
  mcpName: "io.github.chrischall/ofw-mcp",
36771
37327
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
36772
37328
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -36793,17 +37349,18 @@ var package_default = {
36793
37349
  "test:watch": "vitest"
36794
37350
  },
36795
37351
  dependencies: {
36796
- "@fetchproxy/bootstrap": "^0.5.1",
37352
+ "@fetchproxy/bootstrap": "^0.8.0",
37353
+ "@fetchproxy/server": "^0.8.0",
36797
37354
  "@modelcontextprotocol/sdk": "^1.29.0",
36798
- dotenv: "^17.4.0",
36799
- zod: "^4.4.2"
37355
+ dotenv: "^17.4.2",
37356
+ zod: "^4.4.3"
36800
37357
  },
36801
37358
  devDependencies: {
36802
- "@types/node": "^25.8.0",
36803
- "@vitest/coverage-v8": "^4.1.6",
37359
+ "@types/node": "^25.9.1",
37360
+ "@vitest/coverage-v8": "^4.1.7",
36804
37361
  esbuild: "^0.28.0",
36805
- typescript: "^6.0.2",
36806
- vitest: "^4.1.6"
37362
+ typescript: "^6.0.3",
37363
+ vitest: "^4.1.7"
36807
37364
  }
36808
37365
  };
36809
37366
 
@@ -36860,6 +37417,12 @@ async function resolveAuth() {
36860
37417
  source: "fetchproxy"
36861
37418
  };
36862
37419
  } catch (e) {
37420
+ if (classifyBridgeError(e) === "bridge_down") {
37421
+ const downErr = e;
37422
+ throw new Error(
37423
+ `OFW auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
37424
+ );
37425
+ }
36863
37426
  const msg = e instanceof Error ? e.message : String(e);
36864
37427
  throw new Error(
36865
37428
  `OFW auth: no OFW_USERNAME/OFW_PASSWORD set, and fetchproxy fallback failed: ${msg}`
@@ -36899,6 +37462,13 @@ function redactHeaders(h) {
36899
37462
  if (out.Authorization) out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}\u2026`;
36900
37463
  return out;
36901
37464
  }
37465
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
37466
+ function getRequestTimeoutMs() {
37467
+ const raw = process.env.OFW_REQUEST_TIMEOUT_MS;
37468
+ if (typeof raw !== "string" || raw.trim().length === 0) return DEFAULT_REQUEST_TIMEOUT_MS;
37469
+ const n = Number(raw.trim());
37470
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
37471
+ }
36902
37472
  var OFWClient = class {
36903
37473
  token = null;
36904
37474
  tokenExpiry = null;
@@ -36939,13 +37509,37 @@ var OFWClient = class {
36939
37509
  console.error(`[ofw-debug] headers: ${JSON.stringify(redactHeaders(headers))}`);
36940
37510
  console.error(`[ofw-debug] body: ${bodyPreview}`);
36941
37511
  }
36942
- const response = await fetch(url2, {
36943
- method,
36944
- headers,
36945
- ...body !== void 0 ? { body: isFormData ? body : JSON.stringify(body) } : {}
36946
- });
37512
+ const timeoutMs = getRequestTimeoutMs();
37513
+ const ac = new AbortController();
37514
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
37515
+ const startedAt = Date.now();
37516
+ let response;
37517
+ try {
37518
+ response = await fetch(url2, {
37519
+ method,
37520
+ headers,
37521
+ signal: ac.signal,
37522
+ ...body !== void 0 ? { body: isFormData ? body : JSON.stringify(body) } : {}
37523
+ });
37524
+ } catch (err) {
37525
+ const elapsed = Date.now() - startedAt;
37526
+ if (ac.signal.aborted) {
37527
+ if (debugLogEnabled()) {
37528
+ console.error(`[ofw-debug] \u23F1 TIMEOUT after ${elapsed}ms: ${method} ${url2}`);
37529
+ }
37530
+ throw new Error(
37531
+ `OFW API request timed out after ${timeoutMs}ms: ${method} ${path}`
37532
+ );
37533
+ }
37534
+ if (debugLogEnabled()) {
37535
+ console.error(`[ofw-debug] \u2717 ${err.message} after ${elapsed}ms: ${method} ${url2}`);
37536
+ }
37537
+ throw err;
37538
+ } finally {
37539
+ clearTimeout(timer);
37540
+ }
36947
37541
  if (debugLogEnabled()) {
36948
- console.error(`[ofw-debug] \u2190 ${response.status} ${response.statusText}`);
37542
+ console.error(`[ofw-debug] \u2190 ${response.status} ${response.statusText} (${Date.now() - startedAt}ms)`);
36949
37543
  }
36950
37544
  if (response.status === 401 && !isRetry) {
36951
37545
  this.token = null;
@@ -37680,18 +38274,55 @@ function registerMessageTools(server2, client2) {
37680
38274
  return jsonResponse({ ...row, attachments });
37681
38275
  });
37682
38276
  server2.registerTool("ofw_send_message", {
37683
- description: "Send a message via OurFamilyWizard. If sending from a draft, pass draftId to delete the draft after sending. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.",
38277
+ description: "Send a message via OurFamilyWizard. To send an existing draft, pass messageId \u2014 subject/body/recipientIds become optional overrides (missing fields default to the draft's cached values) and the draft is deleted after sending. To send a fresh message, supply subject/body/recipientIds directly. draftId is the legacy spelling of messageId and works the same way. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.",
37684
38278
  annotations: { destructiveHint: true },
37685
38279
  inputSchema: {
37686
- subject: external_exports.string().describe("Message subject"),
37687
- body: external_exports.string().describe("Message body text"),
37688
- recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (get from ofw_get_profile)"),
38280
+ subject: external_exports.string().describe("Message subject. Required unless messageId/draftId references a cached draft.").optional(),
38281
+ body: external_exports.string().describe("Message body text. Required unless messageId/draftId references a cached draft.").optional(),
38282
+ recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (get from ofw_get_profile). Required unless messageId/draftId references a cached draft.").optional(),
37689
38283
  replyToId: external_exports.number().describe("ID of the message being replied to").optional(),
37690
- draftId: external_exports.number().describe("ID of the draft to delete after sending (omit if not sending from a draft)").optional(),
38284
+ messageId: external_exports.number().describe("ID of an existing draft to send. When set, missing subject/body/recipientIds default to the draft's cached values, and the draft is deleted after sending.").optional(),
38285
+ draftId: external_exports.number().describe("Legacy synonym for messageId. If both are passed they must be equal.").optional(),
37691
38286
  myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment) to attach to the message").optional()
37692
38287
  }
37693
38288
  }, async (args) => {
37694
- const requestedReplyTo = args.replyToId ?? null;
38289
+ if (args.messageId !== void 0 && args.draftId !== void 0 && args.messageId !== args.draftId) {
38290
+ throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
38291
+ }
38292
+ const draftRef = args.messageId ?? args.draftId;
38293
+ let subject = args.subject;
38294
+ let body = args.body;
38295
+ let recipientIds = args.recipientIds;
38296
+ let draftReplyToId = null;
38297
+ let draftLookupAttempted = false;
38298
+ let draftFound = false;
38299
+ if (draftRef !== void 0) {
38300
+ draftLookupAttempted = true;
38301
+ const draft = getDraft(draftRef);
38302
+ if (draft !== null) {
38303
+ draftFound = true;
38304
+ subject = subject ?? draft.subject;
38305
+ body = body ?? draft.body;
38306
+ recipientIds = recipientIds ?? draft.recipients.map((r) => r.userId);
38307
+ draftReplyToId = draft.replyToId;
38308
+ }
38309
+ }
38310
+ if (subject === void 0 || body === void 0 || recipientIds === void 0) {
38311
+ if (draftLookupAttempted && !draftFound) {
38312
+ throw new Error(
38313
+ `draft ${draftRef} not found in local cache. Call ofw_sync_messages first, or supply subject/body/recipientIds explicitly.`
38314
+ );
38315
+ }
38316
+ const missing = [
38317
+ subject === void 0 ? "subject" : null,
38318
+ body === void 0 ? "body" : null,
38319
+ recipientIds === void 0 ? "recipientIds" : null
38320
+ ].filter((n) => n !== null).join(", ");
38321
+ throw new Error(
38322
+ `ofw_send_message requires ${missing}. Pass it directly, or pass messageId to default missing fields from a cached draft.`
38323
+ );
38324
+ }
38325
+ const requestedReplyTo = args.replyToId ?? draftReplyToId ?? null;
37695
38326
  let resolvedReplyTo = requestedReplyTo;
37696
38327
  let chainRootId = null;
37697
38328
  let rewriteNote = null;
@@ -37705,9 +38336,9 @@ function registerMessageTools(server2, client2) {
37705
38336
  }
37706
38337
  const myFileIDs = args.myFileIDs ?? [];
37707
38338
  const { id: newId, detail, raw } = await postMessageAndRefetch(client2, {
37708
- subject: args.subject,
37709
- body: args.body,
37710
- recipientIds: args.recipientIds,
38339
+ subject,
38340
+ body,
38341
+ recipientIds,
37711
38342
  attachments: { myFileIDs },
37712
38343
  draft: false,
37713
38344
  includeOriginal: resolvedReplyTo !== null,
@@ -37718,11 +38349,11 @@ function registerMessageTools(server2, client2) {
37718
38349
  persisted = {
37719
38350
  id: newId,
37720
38351
  folder: "sent",
37721
- subject: detail.subject ?? args.subject,
38352
+ subject: detail.subject ?? subject,
37722
38353
  fromUser: detail.from?.name ?? "",
37723
38354
  sentAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
37724
38355
  recipients: mapRecipients(detail.recipients),
37725
- body: detail.body ?? args.body,
38356
+ body: detail.body ?? body,
37726
38357
  fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
37727
38358
  replyToId: resolvedReplyTo,
37728
38359
  chainRootId,
@@ -37742,9 +38373,9 @@ function registerMessageTools(server2, client2) {
37742
38373
  });
37743
38374
  }
37744
38375
  }
37745
- if (args.draftId !== void 0) {
37746
- await deleteOFWMessages(client2, [args.draftId]);
37747
- deleteDraft(args.draftId);
38376
+ if (draftRef !== void 0) {
38377
+ await deleteOFWMessages(client2, [draftRef]);
38378
+ deleteDraft(draftRef);
37748
38379
  }
37749
38380
  const responseObj = persisted ?? raw;
37750
38381
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
@@ -38153,7 +38784,7 @@ process.emit = function(event, ...args) {
38153
38784
  }
38154
38785
  return originalEmit(event, ...args);
38155
38786
  };
38156
- var server = new McpServer({ name: "ofw", version: "2.0.19" });
38787
+ var server = new McpServer({ name: "ofw", version: "2.2.0" });
38157
38788
  registerUserTools(server, client);
38158
38789
  registerMessageTools(server, client);
38159
38790
  registerCalendarTools(server, client);