creditkarma-mcp 2.0.10 → 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/README.md CHANGED
@@ -23,6 +23,28 @@ Ask Claude things like:
23
23
  - A Credit Karma account
24
24
  - For the no-env-var path: the [fetchproxy 0.3.0 Chrome / Safari extension](https://github.com/chrischall/fetchproxy)
25
25
 
26
+ ## Acknowledgement of Terms
27
+
28
+ By using this MCP server, you acknowledge and agree to the following:
29
+
30
+ **1. This server accesses your own Credit Karma account.** Every request is dispatched through your own signed-in browser tab via the fetchproxy extension. **You** are the one logged in. It does not — and cannot — access anyone else's account.
31
+
32
+ **2. [Credit Karma's Terms](https://www.creditkarma.com/about/terms) govern your use of this server**, just as they govern your direct use of creditkarma.com. The clauses most relevant here:
33
+
34
+ > You must not sell, transfer, or assign your account to anyone else… you may not allow anyone else to log into our Services as you.
35
+
36
+ CK does contemplate third-party data retrieval at the user's direction (Section 3.7). There is no explicit anti-scraping clause in the membership agreement; Section 4.1 restricts copying or distributing CK content without express prior written consent.
37
+
38
+ You are agreeing to those terms — read by the maintainer 2026-05-23 — every time you invoke a tool in this server. Critically: this server runs **as you**, not as a third party logging in on your behalf. You direct the tool.
39
+
40
+ **3. Personal, non-commercial use only.** This project is not affiliated with, endorsed by, sponsored by, or in partnership with Intuit, Credit Karma, or any financial institution. It is a personal automation tool that reads your transaction history, spending categories, and account snapshots — the same data Credit Karma already shows you in their app. Do not use it on someone else's account, do not redistribute their content, and do not use it to make trading or lending decisions on behalf of others.
41
+
42
+ **4. This server may break.** Credit Karma rotates its internal endpoints; what works today may 404 tomorrow. This is the nature of unofficial integrations.
43
+
44
+ **5. You accept full responsibility** for any consequences of using this server in connection with your Credit Karma account — rate limiting, account warnings, suspension, or any enforcement action Intuit takes. If Credit Karma objects to your use, stop using this server. **Do not commit your `.env` to git** — your CK session/auth artifacts are credentials, and the Membership Agreement holds you responsible for their confidentiality.
45
+
46
+ This section is the maintainer's good-faith summary of the terms — it is not legal advice and does not modify or supersede Credit Karma's actual Membership Agreement.
47
+
26
48
  ## Installation
27
49
 
28
50
  ### 1. Clone and build
package/dist/auth.js CHANGED
@@ -58,6 +58,7 @@
58
58
  // `src/tools/auth.ts` extracts the CKAT JWTs the same way it does
59
59
  // today.
60
60
  import { bootstrap } from '@fetchproxy/bootstrap';
61
+ import { classifyBridgeError } from '@fetchproxy/server';
61
62
  import pkg from '../package.json' with { type: 'json' };
62
63
  import { extractCookieValue } from './client.js';
63
64
  /**
@@ -143,6 +144,18 @@ export async function resolveAuth() {
143
144
  return { cookies, source: 'fetchproxy' };
144
145
  }
145
146
  catch (e) {
147
+ // 0.8.0+ typed-error discrimination. The fetchproxy server already
148
+ // retries once on SW eviction (bridgeReviveDelayMs=2000 default), so
149
+ // a thrown FetchproxyBridgeDownError means the retry also failed —
150
+ // the extension's service worker is genuinely down and the user
151
+ // needs to wake it. The `.hint` is the actionable copy
152
+ // ("click the extension toolbar icon...") that we'd otherwise have
153
+ // to hand-write here. Surface it verbatim so users in path 3 get
154
+ // the same self-service guidance as path 4.
155
+ if (classifyBridgeError(e) === 'bridge_down') {
156
+ const downErr = e;
157
+ throw new Error(`CK auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`);
158
+ }
146
159
  const msg = e instanceof Error ? e.message : String(e);
147
160
  throw new Error(`CK auth: no CK_COOKIES set, and fetchproxy fallback failed: ${msg}`);
148
161
  }
package/dist/bundle.js CHANGED
@@ -7658,6 +7658,10 @@ var require_receiver = __commonJS({
7658
7658
  * extensions
7659
7659
  * @param {Boolean} [options.isServer=false] Specifies whether to operate in
7660
7660
  * client or server mode
7661
+ * @param {Number} [options.maxBufferedChunks=0] The maximum number of
7662
+ * buffered data chunks
7663
+ * @param {Number} [options.maxFragments=0] The maximum number of message
7664
+ * fragments
7661
7665
  * @param {Number} [options.maxPayload=0] The maximum allowed message length
7662
7666
  * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
7663
7667
  * not to skip UTF-8 validation for text and close messages
@@ -7668,6 +7672,8 @@ var require_receiver = __commonJS({
7668
7672
  this._binaryType = options.binaryType || BINARY_TYPES[0];
7669
7673
  this._extensions = options.extensions || {};
7670
7674
  this._isServer = !!options.isServer;
7675
+ this._maxBufferedChunks = options.maxBufferedChunks | 0;
7676
+ this._maxFragments = options.maxFragments | 0;
7671
7677
  this._maxPayload = options.maxPayload | 0;
7672
7678
  this._skipUTF8Validation = !!options.skipUTF8Validation;
7673
7679
  this[kWebSocket] = void 0;
@@ -7697,6 +7703,18 @@ var require_receiver = __commonJS({
7697
7703
  */
7698
7704
  _write(chunk, encoding, cb) {
7699
7705
  if (this._opcode === 8 && this._state == GET_INFO) return cb();
7706
+ if (this._maxBufferedChunks > 0 && this._buffers.length >= this._maxBufferedChunks) {
7707
+ cb(
7708
+ this.createError(
7709
+ RangeError,
7710
+ "Too many buffered chunks",
7711
+ false,
7712
+ 1008,
7713
+ "WS_ERR_TOO_MANY_BUFFERED_PARTS"
7714
+ )
7715
+ );
7716
+ return;
7717
+ }
7700
7718
  this._bufferedBytes += chunk.length;
7701
7719
  this._buffers.push(chunk);
7702
7720
  this.startLoop(cb);
@@ -8026,6 +8044,17 @@ var require_receiver = __commonJS({
8026
8044
  return;
8027
8045
  }
8028
8046
  if (data.length) {
8047
+ if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
8048
+ const error51 = this.createError(
8049
+ RangeError,
8050
+ "Too many message fragments",
8051
+ false,
8052
+ 1008,
8053
+ "WS_ERR_TOO_MANY_BUFFERED_PARTS"
8054
+ );
8055
+ cb(error51);
8056
+ return;
8057
+ }
8029
8058
  this._messageLength = this._totalPayloadLength;
8030
8059
  this._fragments.push(data);
8031
8060
  }
@@ -8055,6 +8084,17 @@ var require_receiver = __commonJS({
8055
8084
  cb(error51);
8056
8085
  return;
8057
8086
  }
8087
+ if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
8088
+ const error51 = this.createError(
8089
+ RangeError,
8090
+ "Too many message fragments",
8091
+ false,
8092
+ 1008,
8093
+ "WS_ERR_TOO_MANY_BUFFERED_PARTS"
8094
+ );
8095
+ cb(error51);
8096
+ return;
8097
+ }
8058
8098
  this._fragments.push(buf);
8059
8099
  }
8060
8100
  this.dataMessage(cb);
@@ -9261,6 +9301,10 @@ var require_websocket = __commonJS({
9261
9301
  * multiple times in the same tick
9262
9302
  * @param {Function} [options.generateMask] The function used to generate the
9263
9303
  * masking key
9304
+ * @param {Number} [options.maxBufferedChunks=0] The maximum number of
9305
+ * buffered data chunks
9306
+ * @param {Number} [options.maxFragments=0] The maximum number of message
9307
+ * fragments
9264
9308
  * @param {Number} [options.maxPayload=0] The maximum allowed message size
9265
9309
  * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
9266
9310
  * not to skip UTF-8 validation for text and close messages
@@ -9272,6 +9316,8 @@ var require_websocket = __commonJS({
9272
9316
  binaryType: this.binaryType,
9273
9317
  extensions: this._extensions,
9274
9318
  isServer: this._isServer,
9319
+ maxBufferedChunks: options.maxBufferedChunks,
9320
+ maxFragments: options.maxFragments,
9275
9321
  maxPayload: options.maxPayload,
9276
9322
  skipUTF8Validation: options.skipUTF8Validation
9277
9323
  });
@@ -9571,6 +9617,8 @@ var require_websocket = __commonJS({
9571
9617
  autoPong: true,
9572
9618
  closeTimeout: CLOSE_TIMEOUT,
9573
9619
  protocolVersion: protocolVersions[1],
9620
+ maxBufferedChunks: 1024 * 1024,
9621
+ maxFragments: 128 * 1024,
9574
9622
  maxPayload: 100 * 1024 * 1024,
9575
9623
  skipUTF8Validation: false,
9576
9624
  perMessageDeflate: true,
@@ -9813,6 +9861,8 @@ var require_websocket = __commonJS({
9813
9861
  websocket.setSocket(socket, head, {
9814
9862
  allowSynchronousEvents: opts.allowSynchronousEvents,
9815
9863
  generateMask: opts.generateMask,
9864
+ maxBufferedChunks: opts.maxBufferedChunks,
9865
+ maxFragments: opts.maxFragments,
9816
9866
  maxPayload: opts.maxPayload,
9817
9867
  skipUTF8Validation: opts.skipUTF8Validation
9818
9868
  });
@@ -10155,6 +10205,10 @@ var require_websocket_server = __commonJS({
10155
10205
  * called
10156
10206
  * @param {Function} [options.handleProtocols] A hook to handle protocols
10157
10207
  * @param {String} [options.host] The hostname where to bind the server
10208
+ * @param {Number} [options.maxBufferedChunks=1048576] The maximum number of
10209
+ * buffered data chunks
10210
+ * @param {Number} [options.maxFragments=131072] The maximum number of message
10211
+ * fragments
10158
10212
  * @param {Number} [options.maxPayload=104857600] The maximum allowed message
10159
10213
  * size
10160
10214
  * @param {Boolean} [options.noServer=false] Enable no server mode
@@ -10176,6 +10230,8 @@ var require_websocket_server = __commonJS({
10176
10230
  options = {
10177
10231
  allowSynchronousEvents: true,
10178
10232
  autoPong: true,
10233
+ maxBufferedChunks: 1024 * 1024,
10234
+ maxFragments: 128 * 1024,
10179
10235
  maxPayload: 100 * 1024 * 1024,
10180
10236
  skipUTF8Validation: false,
10181
10237
  perMessageDeflate: false,
@@ -10455,6 +10511,8 @@ var require_websocket_server = __commonJS({
10455
10511
  socket.removeListener("error", socketOnError);
10456
10512
  ws.setSocket(socket, head, {
10457
10513
  allowSynchronousEvents: this.options.allowSynchronousEvents,
10514
+ maxBufferedChunks: this.options.maxBufferedChunks,
10515
+ maxFragments: this.options.maxFragments,
10458
10516
  maxPayload: this.options.maxPayload,
10459
10517
  skipUTF8Validation: this.options.skipUTF8Validation
10460
10518
  });
@@ -34974,6 +35032,7 @@ function registerAuthTools(server, ctx) {
34974
35032
 
34975
35033
  // node_modules/@fetchproxy/protocol/dist/frames.js
34976
35034
  var PROTOCOL_VERSION = 2;
35035
+ var HKDF_SESSION_INFO = "fetchproxy/1.0.0/session";
34977
35036
  var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34978
35037
  "fetch",
34979
35038
  "read_cookies",
@@ -35048,7 +35107,7 @@ var ProtocolError = class extends Error {
35048
35107
  var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
35049
35108
  var BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
35050
35109
  var SCOPE_KEY_RE = /^[A-Za-z0-9_.\-]{1,256}$/;
35051
- var SCOPE_KEY_GLOB_RE = /^[A-Za-z0-9_.\-]{1,255}\*?$/;
35110
+ var SCOPE_KEY_GLOB_RE = /^[A-Za-z0-9_.\-]{1,256}\*?$/;
35052
35111
  var HEADER_NAME_RE = /^[A-Za-z0-9_\-]{1,128}$/;
35053
35112
  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;
35054
35113
  function assertObject(x, label) {
@@ -35177,7 +35236,7 @@ function assertStoragePointersArray(value, label, declaredKeys) {
35177
35236
  if (entry.jsonPointer === void 0) {
35178
35237
  throw new ProtocolError(`${label}[${i}].jsonPointer: missing`);
35179
35238
  }
35180
- if (typeof entry.key !== "string" || !SCOPE_KEY_GLOB_RE.test(entry.key)) {
35239
+ if (typeof entry.key !== "string" || !SCOPE_KEY_RE.test(entry.key)) {
35181
35240
  throw new ProtocolError(`${label}[${i}].key: invalid key ${JSON.stringify(entry.key)}`);
35182
35241
  }
35183
35242
  if (typeof entry.jsonPointer !== "string" || !isValidJsonPointer(entry.jsonPointer)) {
@@ -35276,6 +35335,8 @@ function validateFrame(raw) {
35276
35335
  return validateReady(raw);
35277
35336
  if (t === "frame")
35278
35337
  return validateEncrypted(raw);
35338
+ if (t === "pair-pending")
35339
+ return validatePairPending(raw);
35279
35340
  throw new ProtocolError(`unknown frame type: ${String(t)}`);
35280
35341
  }
35281
35342
  function validateHello(raw) {
@@ -35377,6 +35438,17 @@ function validateEncrypted(raw) {
35377
35438
  assertBase64(raw.ciphertext, "frame.ciphertext");
35378
35439
  return raw;
35379
35440
  }
35441
+ var PAIR_CODE_RE = /^\d{3}-\d{3}$/;
35442
+ function validatePairPending(raw) {
35443
+ assertString(raw.mcpId, "pair-pending.mcpId");
35444
+ if (!isValidMcpId(raw.mcpId))
35445
+ throw new ProtocolError("pair-pending.mcpId: invalid format");
35446
+ assertString(raw.pairCode, "pair-pending.pairCode");
35447
+ if (!PAIR_CODE_RE.test(raw.pairCode)) {
35448
+ throw new ProtocolError(`pair-pending.pairCode: must match XXX-XXX, got ${String(raw.pairCode)}`);
35449
+ }
35450
+ return { type: "pair-pending", mcpId: raw.mcpId, pairCode: raw.pairCode };
35451
+ }
35380
35452
  function validateInnerFrame(raw) {
35381
35453
  assertObject(raw, "inner");
35382
35454
  const t = raw.type;
@@ -35959,15 +36031,22 @@ async function startHost(opts) {
35959
36031
  let extensionWs = null;
35960
36032
  const peers = /* @__PURE__ */ new Map();
35961
36033
  const ownInnerListeners = [];
36034
+ const disconnectListeners = [];
36035
+ const pendingPairListeners = [];
35962
36036
  let ownSession = null;
36037
+ let ownPendingPairCode = null;
35963
36038
  let resolveOwnSession;
35964
36039
  let rejectOwnSession;
35965
- const ownSessionReady = new Promise((resolve, reject) => {
35966
- resolveOwnSession = resolve;
35967
- rejectOwnSession = reject;
35968
- });
35969
- ownSessionReady.catch(() => {
35970
- });
36040
+ let ownSessionReady;
36041
+ function resetSessionPromise() {
36042
+ ownSessionReady = new Promise((resolve, reject) => {
36043
+ resolveOwnSession = resolve;
36044
+ rejectOwnSession = reject;
36045
+ });
36046
+ ownSessionReady.catch(() => {
36047
+ });
36048
+ }
36049
+ resetSessionPromise();
35971
36050
  let extensionHello = null;
35972
36051
  wss.on("connection", (ws) => {
35973
36052
  let identified = null;
@@ -36036,11 +36115,12 @@ async function startHost(opts) {
36036
36115
  }
36037
36116
  const extPub = fromB64(frame.extensionSessionPub);
36038
36117
  const shared = await ecdhX25519(opts.ownIdentity.x25519Priv, extPub);
36039
- const key = await hkdfSha256(shared, ownSessionNonce, enc2.encode("fetchproxy/0.1.0/session"), 32);
36040
- if (!ownSession) {
36041
- ownSession = new SessionState(key);
36042
- resolveOwnSession(ownSession);
36043
- }
36118
+ const key = await hkdfSha256(shared, ownSessionNonce, enc2.encode(HKDF_SESSION_INFO), 32);
36119
+ if (extensionWs !== ws)
36120
+ return;
36121
+ ownSession = new SessionState(key);
36122
+ ownPendingPairCode = null;
36123
+ resolveOwnSession(ownSession);
36044
36124
  } else {
36045
36125
  const slot = peers.get(frame.mcpId);
36046
36126
  if (slot)
@@ -36067,6 +36147,16 @@ async function startHost(opts) {
36067
36147
  extensionWs.send(JSON.stringify(frame));
36068
36148
  }
36069
36149
  }
36150
+ if (frame.type === "pair-pending" && identified === "extension") {
36151
+ if (frame.mcpId === opts.ownMcpId) {
36152
+ ownPendingPairCode = frame.pairCode;
36153
+ pendingPairListeners.forEach((cb) => cb(frame.pairCode));
36154
+ } else {
36155
+ const slot = peers.get(frame.mcpId);
36156
+ if (slot)
36157
+ slot.ws.send(JSON.stringify(frame));
36158
+ }
36159
+ }
36070
36160
  } catch (e) {
36071
36161
  console.error("[fetchproxy] host: message handler error:", e);
36072
36162
  try {
@@ -36082,6 +36172,9 @@ async function startHost(opts) {
36082
36172
  if (!ownSession) {
36083
36173
  rejectOwnSession(new Error("extension disconnected before ready"));
36084
36174
  }
36175
+ ownSession = null;
36176
+ resetSessionPromise();
36177
+ disconnectListeners.forEach((cb) => cb());
36085
36178
  }
36086
36179
  if (identified === "peer" && peerMcpId)
36087
36180
  peers.delete(peerMcpId);
@@ -36106,7 +36199,14 @@ async function startHost(opts) {
36106
36199
  },
36107
36200
  onOwnInner: (cb) => {
36108
36201
  ownInnerListeners.push(cb);
36109
- }
36202
+ },
36203
+ onExtensionDisconnect: (cb) => {
36204
+ disconnectListeners.push(cb);
36205
+ },
36206
+ onPendingPair: (cb) => {
36207
+ pendingPairListeners.push(cb);
36208
+ },
36209
+ pendingPairCode: () => ownPendingPairCode
36110
36210
  };
36111
36211
  }
36112
36212
 
@@ -36136,36 +36236,57 @@ async function startPeer(opts) {
36136
36236
  const sessionNonce = fromB64(hello.sessionNonce);
36137
36237
  ws.send(JSON.stringify(hello));
36138
36238
  const innerListeners = [];
36239
+ const renegotiateListeners = [];
36240
+ const pendingPairListeners = [];
36139
36241
  let session = null;
36242
+ let pendingPairCode = null;
36243
+ let resolveFirstReady;
36244
+ let rejectFirstReady;
36140
36245
  const sessionPromise = new Promise((resolve, reject) => {
36141
- const onMessage = async (data) => {
36142
- try {
36143
- const raw = JSON.parse(data.toString());
36144
- const frame = validateFrame(raw);
36145
- if (frame.type === "ready" && frame.mcpId === opts.mcpId) {
36146
- const extPub = fromB64(frame.extensionSessionPub);
36147
- const shared = await ecdhX25519(opts.identity.x25519Priv, extPub);
36148
- const sessionKey = await hkdfSha256(shared, sessionNonce, enc3.encode("fetchproxy/0.1.0/session"), 32);
36149
- session = new SessionState(sessionKey);
36150
- resolve(session);
36151
- return;
36246
+ resolveFirstReady = resolve;
36247
+ rejectFirstReady = reject;
36248
+ });
36249
+ const onMessage = async (data) => {
36250
+ try {
36251
+ const raw = JSON.parse(data.toString());
36252
+ const frame = validateFrame(raw);
36253
+ if (frame.type === "ready" && frame.mcpId === opts.mcpId) {
36254
+ const extPub = fromB64(frame.extensionSessionPub);
36255
+ const shared = await ecdhX25519(opts.identity.x25519Priv, extPub);
36256
+ const sessionKey = await hkdfSha256(shared, sessionNonce, enc3.encode(HKDF_SESSION_INFO), 32);
36257
+ const isRenegotiation = session !== null;
36258
+ session = new SessionState(sessionKey);
36259
+ pendingPairCode = null;
36260
+ if (isRenegotiation) {
36261
+ renegotiateListeners.forEach((cb) => cb());
36262
+ } else {
36263
+ resolveFirstReady(session);
36152
36264
  }
36153
- if (frame.type === "frame" && frame.mcpId === opts.mcpId) {
36154
- if (!session)
36155
- return;
36156
- if (!session.acceptInboundSeq(frame.seq))
36157
- return;
36265
+ return;
36266
+ }
36267
+ if (frame.type === "pair-pending" && frame.mcpId === opts.mcpId) {
36268
+ pendingPairCode = frame.pairCode;
36269
+ pendingPairListeners.forEach((cb) => cb(frame.pairCode));
36270
+ return;
36271
+ }
36272
+ if (frame.type === "frame" && frame.mcpId === opts.mcpId) {
36273
+ if (!session)
36274
+ return;
36275
+ if (!session.acceptInboundSeq(frame.seq))
36276
+ return;
36277
+ try {
36158
36278
  const inner = await openEncryptedFrame(session.sessionKey, frame);
36159
36279
  innerListeners.forEach((cb) => cb(inner));
36280
+ } catch {
36160
36281
  }
36161
- } catch (e) {
36162
- reject(e instanceof Error ? e : new Error(String(e)));
36163
36282
  }
36164
- };
36165
- ws.on("message", onMessage);
36166
- ws.once("close", () => {
36167
- reject(new Error("peer WS closed before ready"));
36168
- });
36283
+ } catch (e) {
36284
+ rejectFirstReady(e instanceof Error ? e : new Error(String(e)));
36285
+ }
36286
+ };
36287
+ ws.on("message", onMessage);
36288
+ ws.once("close", () => {
36289
+ rejectFirstReady(new Error("peer WS closed before ready"));
36169
36290
  });
36170
36291
  sessionPromise.catch(() => {
36171
36292
  });
@@ -36173,13 +36294,21 @@ async function startPeer(opts) {
36173
36294
  ws,
36174
36295
  session: sessionPromise,
36175
36296
  sendInner: async (inner) => {
36176
- const s = await sessionPromise;
36297
+ await sessionPromise;
36298
+ const s = session;
36177
36299
  const sealed = await sealInnerFrame(s.sessionKey, opts.mcpId, s.nextOutboundSeq(), inner);
36178
36300
  ws.send(JSON.stringify(sealed));
36179
36301
  },
36180
36302
  onInner: (cb) => {
36181
36303
  innerListeners.push(cb);
36182
36304
  },
36305
+ onRenegotiate: (cb) => {
36306
+ renegotiateListeners.push(cb);
36307
+ },
36308
+ onPendingPair: (cb) => {
36309
+ pendingPairListeners.push(cb);
36310
+ },
36311
+ pendingPairCode: () => pendingPairCode,
36183
36312
  close: () => ws.close()
36184
36313
  };
36185
36314
  return handle;
@@ -36236,6 +36365,32 @@ async function loadOrCreateIdentity(serverName, dir = defaultIdentityDir()) {
36236
36365
  return id;
36237
36366
  }
36238
36367
 
36368
+ // node_modules/@fetchproxy/server/dist/error-kind.js
36369
+ function classifyFetchError(error51) {
36370
+ if (/Could not establish connection/i.test(error51) || /Receiving end does not exist/i.test(error51)) {
36371
+ return "content_script_unreachable";
36372
+ }
36373
+ if (/^tab fetch failed:/.test(error51)) {
36374
+ return "tab_fetch_failed";
36375
+ }
36376
+ if (/^fetch threw:/.test(error51)) {
36377
+ return "tab_fetch_failed";
36378
+ }
36379
+ if (/^no tab matching /.test(error51)) {
36380
+ return "no_tab";
36381
+ }
36382
+ if (/not in domains \[/.test(error51)) {
36383
+ return "domain_denied";
36384
+ }
36385
+ if (/^capability .+ not granted/.test(error51)) {
36386
+ return "capability_denied";
36387
+ }
36388
+ if (/^(request|response) body too large:/.test(error51)) {
36389
+ return "body_too_large";
36390
+ }
36391
+ return "other";
36392
+ }
36393
+
36239
36394
  // node_modules/@fetchproxy/server/dist/ws-server.js
36240
36395
  var FetchproxyProtocolError = class extends Error {
36241
36396
  constructor(message) {
@@ -36251,6 +36406,52 @@ var FetchproxyHttpError = class extends Error {
36251
36406
  this.name = "FetchproxyHttpError";
36252
36407
  }
36253
36408
  };
36409
+ var FetchproxyBridgeDownError = class extends FetchproxyProtocolError {
36410
+ originalError;
36411
+ retryAttempted;
36412
+ op;
36413
+ url;
36414
+ /** 0.8.0+: bridge role at throw time; `null` if listen() hadn't bound yet. */
36415
+ role;
36416
+ /** 0.8.0+: bridge port at throw time (the same port `listen()` bound to). */
36417
+ port;
36418
+ hint;
36419
+ constructor(args) {
36420
+ const retryAttempted = args.retryAttempted ?? false;
36421
+ const op = args.op ?? "fetch";
36422
+ 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). `;
36423
+ 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.`;
36424
+ super(`fetchproxy bridge down during ${op}${args.url ? ` (${args.url})` : ""}. ${hint}`);
36425
+ this.name = "FetchproxyBridgeDownError";
36426
+ this.originalError = args.originalError;
36427
+ this.retryAttempted = retryAttempted;
36428
+ this.op = op;
36429
+ if (args.url !== void 0)
36430
+ this.url = args.url;
36431
+ this.role = args.role ?? null;
36432
+ this.port = args.port ?? 0;
36433
+ this.hint = hint;
36434
+ }
36435
+ };
36436
+ var FetchproxyTimeoutError = class extends FetchproxyProtocolError {
36437
+ url;
36438
+ timeoutMs;
36439
+ /** 0.8.0+: bridge role at throw time; `null` if listen() hadn't bound yet. */
36440
+ role;
36441
+ /** 0.8.0+: bridge port at throw time. */
36442
+ port;
36443
+ /** 0.8.0+: actual elapsed milliseconds when the timer won the race. */
36444
+ elapsedMs;
36445
+ constructor(args) {
36446
+ super(`fetchproxy: ${args.url} did not respond within ${args.timeoutMs}ms`);
36447
+ this.name = "FetchproxyTimeoutError";
36448
+ this.url = args.url;
36449
+ this.timeoutMs = args.timeoutMs;
36450
+ this.role = args.role ?? null;
36451
+ this.port = args.port ?? 0;
36452
+ this.elapsedMs = args.elapsedMs ?? args.timeoutMs;
36453
+ }
36454
+ };
36254
36455
  var SUBDOMAIN_LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
36255
36456
  function assertSubdomainLabel(label) {
36256
36457
  if (!SUBDOMAIN_LABEL_RE.test(label)) {
@@ -36278,12 +36479,28 @@ function assertUrlInDomains(field, url2, domains) {
36278
36479
  }
36279
36480
  var DEFAULT_JSON_OK_STATUSES = [200, 201, 202, 204];
36280
36481
  var FetchproxyServer = class {
36281
- /** Set after `listen()` succeeds. Null while not listening. */
36482
+ /**
36483
+ * Bridge role. `null` until the first verb call (or an explicit
36484
+ * `connect()`) — `listen()` no longer triggers the role election
36485
+ * as of 0.5.3+. Reset to `null` on `close()`.
36486
+ */
36282
36487
  role = null;
36283
36488
  opts;
36284
36489
  hostHandle = null;
36285
36490
  peerHandle = null;
36286
36491
  nextRequestId = 1;
36492
+ // 0.8.0+: process-wide freshness counters surfaced via bridgeHealth().
36493
+ // Replaces the local copies every downstream MCP was rolling on top
36494
+ // of its own transport adapter — see realty-mcp cohort drift notes.
36495
+ // Updated by recordSuccess / recordFailure from fetch + capture paths.
36496
+ // `lastExtensionMessageAt` (#23 ask 4) is updated whenever any inner
36497
+ // frame from the extension arrives — gives extension-side liveness
36498
+ // distinct from per-call success/failure.
36499
+ lastSuccessAt = null;
36500
+ lastFailureAt = null;
36501
+ lastFailureReason = null;
36502
+ consecutiveFailures = 0;
36503
+ lastExtensionMessageAt = null;
36287
36504
  pending = /* @__PURE__ */ new Map();
36288
36505
  // Separate pending map for read_cookies so the response shape (cookies
36289
36506
  // string vs status/body) doesn't have to share a union type with fetch.
@@ -36300,6 +36517,12 @@ var FetchproxyServer = class {
36300
36517
  pendingIdb = /* @__PURE__ */ new Map();
36301
36518
  mcpId = null;
36302
36519
  identity = null;
36520
+ // 0.5.3+: in-flight role-election / handle-start promise. Set the
36521
+ // first time a verb call runs `ensureConnected`, awaited by concurrent
36522
+ // callers, cleared once the connection is up. Single source of truth
36523
+ // for "we're connecting right now" so two parallel first-calls don't
36524
+ // race the port bind.
36525
+ connectingPromise = null;
36303
36526
  constructor(opts) {
36304
36527
  if (!Array.isArray(opts.domains) || opts.domains.length === 0) {
36305
36528
  throw new Error("FetchproxyServer: opts.domains must be a non-empty array of hostnames");
@@ -36346,28 +36569,98 @@ var FetchproxyServer = class {
36346
36569
  key: d.key,
36347
36570
  jsonPointer: d.jsonPointer
36348
36571
  })),
36572
+ // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
36573
+ // adapter was about to set these to the same numbers anyway; the
36574
+ // back-door is `0` (explicit opt-out) if a caller genuinely wants
36575
+ // the legacy hang-forever / fail-once-on-SW-eviction behavior.
36576
+ fetchTimeoutMs: opts.fetchTimeoutMs ?? 3e4,
36577
+ bridgeReviveDelayMs: opts.bridgeReviveDelayMs ?? 2e3,
36349
36578
  identityDir: opts.identityDir,
36350
36579
  onPairCode: opts.onPairCode
36351
36580
  };
36352
36581
  }
36353
36582
  /**
36354
- * Start the WebSocket bridge. Loads the long-term identity keypair
36355
- * from disk (creating it on first call), elects the host-vs-peer
36356
- * role by attempting to bind the configured port, and stands up the
36357
- * matching handshake machinery. Idempotent only insofar as it leaves
36358
- * `role` non-null on success; calling `listen()` twice without an
36359
- * intervening `close()` is a programming error.
36583
+ * Prepare the bridge for use. Loads the long-term identity keypair
36584
+ * from disk (creating it on first call) and computes this instance's
36585
+ * `mcpId`. Does NOT bind the bridge port or dial any WebSocket — the
36586
+ * connection is established lazily on the first verb call (see
36587
+ * `ensureConnected` / `getOrConnect`).
36588
+ *
36589
+ * Pre-0.5.3 behavior: `listen()` also did role election and started
36590
+ * the host/peer immediately, which meant every configured-but-unused
36591
+ * MCP claimed bridge resources at MCP-client boot. Several MCPs
36592
+ * starting in parallel under Claude Desktop also produced noisy
36593
+ * `ERR_CONNECTION_REFUSED` errors in the extension if it raced ahead
36594
+ * of the first MCP's port bind. Deferring keeps boot quiet and
36595
+ * leaves the port unowned until something actually needs it.
36596
+ *
36597
+ * Calling `listen()` twice without an intervening `close()` is a
36598
+ * no-op (the second call's identity load is idempotent).
36360
36599
  */
36361
36600
  async listen() {
36362
- this.identity = await loadOrCreateIdentity(this.opts.serverName, this.opts.identityDir);
36363
- this.mcpId = generateMcpId(this.opts.serverName, this.opts.version);
36601
+ if (!this.identity) {
36602
+ this.identity = await loadOrCreateIdentity(this.opts.serverName, this.opts.identityDir);
36603
+ }
36604
+ if (!this.mcpId) {
36605
+ this.mcpId = generateMcpId(this.opts.serverName, this.opts.version);
36606
+ }
36607
+ }
36608
+ /**
36609
+ * Force an eager bridge connection (role-election + host/peer handle
36610
+ * start + listener wiring) without waiting for the first verb call.
36611
+ * Useful for callers that want to surface the role / connection
36612
+ * outcome at boot, or for tests whose harness dials a mock extension
36613
+ * immediately after server construction. Production MCPs that just
36614
+ * answer tool calls should NOT call this — the lazy connect via
36615
+ * `ensureConnected` will do the right thing on first use, keeping
36616
+ * boot cheap and avoiding port-bind contention for MCPs that never
36617
+ * actually get invoked.
36618
+ *
36619
+ * Idempotent: a second call after the first has resolved is a no-op
36620
+ * (the existing handle is reused). Throws if `listen()` was never
36621
+ * called.
36622
+ */
36623
+ async connect() {
36624
+ await this.ensureConnected();
36625
+ }
36626
+ /**
36627
+ * Establish the bridge connection (role-election + host/peer handle
36628
+ * start + listener wiring) the first time a verb is invoked.
36629
+ * Idempotent after the connection is up; concurrent first-callers
36630
+ * share the same in-flight promise so only one election happens.
36631
+ *
36632
+ * Throws if `listen()` was never called — the contract is that the
36633
+ * MCP author still must wire `transport.start()` at boot to load
36634
+ * identity / set mcpId, even though the WS doesn't open until a
36635
+ * verb runs.
36636
+ */
36637
+ async ensureConnected() {
36638
+ if (this.hostHandle || this.peerHandle)
36639
+ return;
36640
+ if (this.connectingPromise) {
36641
+ await this.connectingPromise;
36642
+ return;
36643
+ }
36644
+ if (!this.identity || !this.mcpId) {
36645
+ throw new Error("FetchproxyServer: ensureConnected called before listen() \u2014 call listen() at MCP boot to load identity");
36646
+ }
36647
+ this.connectingPromise = this.doConnect();
36648
+ try {
36649
+ await this.connectingPromise;
36650
+ } finally {
36651
+ this.connectingPromise = null;
36652
+ }
36653
+ }
36654
+ async doConnect() {
36655
+ const identity = this.identity;
36656
+ const mcpId = this.mcpId;
36364
36657
  const el = await electRole({ host: this.opts.host, port: this.opts.port });
36365
36658
  if (el.role === "host") {
36366
36659
  this.role = "host";
36367
36660
  this.hostHandle = await startHost({
36368
36661
  httpServer: el.server,
36369
- ownIdentity: this.identity,
36370
- ownMcpId: this.mcpId,
36662
+ ownIdentity: identity,
36663
+ ownMcpId: mcpId,
36371
36664
  ownServerName: this.opts.serverName,
36372
36665
  ownVersion: this.opts.version,
36373
36666
  ownDomains: this.opts.domains,
@@ -36382,13 +36675,17 @@ var FetchproxyServer = class {
36382
36675
  onPairCode: this.opts.onPairCode
36383
36676
  });
36384
36677
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
36678
+ this.hostHandle.onExtensionDisconnect(() => this.rejectAllPending());
36679
+ this.hostHandle.onPendingPair((code) => {
36680
+ this.rejectAllPending(this.pairingErrorMessage(code));
36681
+ });
36385
36682
  } else {
36386
36683
  this.role = "peer";
36387
36684
  this.peerHandle = await startPeer({
36388
36685
  host: this.opts.host,
36389
36686
  port: this.opts.port,
36390
- identity: this.identity,
36391
- mcpId: this.mcpId,
36687
+ identity,
36688
+ mcpId,
36392
36689
  serverName: this.opts.serverName,
36393
36690
  version: this.opts.version,
36394
36691
  domains: this.opts.domains,
@@ -36402,8 +36699,19 @@ var FetchproxyServer = class {
36402
36699
  sessionStoragePointers: this.opts.sessionStoragePointers
36403
36700
  });
36404
36701
  this.peerHandle.onInner((inner) => this.onInner(inner));
36702
+ this.peerHandle.onRenegotiate(() => this.rejectAllPending());
36703
+ this.peerHandle.onPendingPair((code) => {
36704
+ this.rejectAllPending(this.pairingErrorMessage(code));
36705
+ });
36706
+ if (this.opts.onPairCode) {
36707
+ const cb = this.opts.onPairCode;
36708
+ this.peerHandle.onPendingPair((code) => cb(code));
36709
+ }
36405
36710
  }
36406
36711
  }
36712
+ pairingErrorMessage(code) {
36713
+ 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.`;
36714
+ }
36407
36715
  /**
36408
36716
  * Raw single-shot fetch through the bridge. Most callers should prefer
36409
36717
  * the verb shortcuts (`get` / `post` / `getJson` / `postJson` / `getHtml`)
@@ -36419,9 +36727,75 @@ var FetchproxyServer = class {
36419
36727
  * offline, etc.).
36420
36728
  */
36421
36729
  async fetch(init) {
36422
- if (!this.hostHandle && !this.peerHandle) {
36423
- throw new Error("FetchproxyServer.fetch called before listen() \u2014 not listening");
36730
+ await this.ensureConnected();
36731
+ const pendingCode = this.currentPendingPairCode();
36732
+ if (pendingCode !== null) {
36733
+ const error51 = this.pairingErrorMessage(pendingCode);
36734
+ return {
36735
+ ok: false,
36736
+ error: error51,
36737
+ kind: classifyFetchError(error51),
36738
+ retryAttempted: false
36739
+ };
36424
36740
  }
36741
+ const first = await this._fetchOnceWithTimeout(init);
36742
+ const reviveMs = this.opts.bridgeReviveDelayMs;
36743
+ let final = first;
36744
+ if (!first.ok && first.kind === "content_script_unreachable" && reviveMs !== void 0 && reviveMs > 0) {
36745
+ await new Promise((r) => setTimeout(r, reviveMs));
36746
+ const second = await this._fetchOnceWithTimeout(init);
36747
+ if (second.ok)
36748
+ this.recordSuccess();
36749
+ else
36750
+ this.recordFailure(`${second.kind ?? "other"}: ${second.error}`);
36751
+ return { ...second, retryAttempted: true };
36752
+ }
36753
+ if (first.ok)
36754
+ this.recordSuccess();
36755
+ else
36756
+ this.recordFailure(`${first.kind ?? "other"}: ${first.error}`);
36757
+ return { ...first, retryAttempted: false };
36758
+ }
36759
+ /**
36760
+ * 0.8.0+: snapshot of the bridge's process-wide freshness counters,
36761
+ * suitable for surfacing through a downstream MCP's healthcheck tool.
36762
+ * Counters reset on a success (consecutiveFailures), accumulate
36763
+ * across the process lifetime otherwise. Replaces the per-MCP
36764
+ * duplication the realty cohort had been rolling in their adapters.
36765
+ * `lastExtensionMessageAt` is updated whenever ANY inner frame
36766
+ * arrives from the extension — gives extension-side liveness
36767
+ * distinct from server-side success/failure of the user-visible
36768
+ * call (addresses #23 ask 4).
36769
+ */
36770
+ bridgeHealth() {
36771
+ return {
36772
+ role: this.role,
36773
+ port: this.opts.port,
36774
+ serverVersion: this.opts.version,
36775
+ fetchTimeoutMs: this.opts.fetchTimeoutMs ?? 0,
36776
+ bridgeReviveDelayMs: this.opts.bridgeReviveDelayMs ?? 0,
36777
+ lastSuccessAt: this.lastSuccessAt,
36778
+ lastFailureAt: this.lastFailureAt,
36779
+ lastFailureReason: this.lastFailureReason,
36780
+ consecutiveFailures: this.consecutiveFailures,
36781
+ lastExtensionMessageAt: this.lastExtensionMessageAt
36782
+ };
36783
+ }
36784
+ recordSuccess() {
36785
+ this.lastSuccessAt = Date.now();
36786
+ this.consecutiveFailures = 0;
36787
+ }
36788
+ recordFailure(reason) {
36789
+ this.lastFailureAt = Date.now();
36790
+ this.lastFailureReason = reason;
36791
+ this.consecutiveFailures += 1;
36792
+ }
36793
+ /**
36794
+ * Single bridge round-trip, wrapped by `fetchTimeoutMs` when set.
36795
+ * On timeout returns the `{ok:false, kind:'timeout'}` envelope —
36796
+ * the throwing surface is the convenience methods.
36797
+ */
36798
+ async _fetchOnceWithTimeout(init) {
36425
36799
  const id = this.nextRequestId++;
36426
36800
  const inner = { type: "request", id, op: "fetch", init };
36427
36801
  const pending = new Promise((resolve) => {
@@ -36432,7 +36806,61 @@ var FetchproxyServer = class {
36432
36806
  } else if (this.peerHandle) {
36433
36807
  await this.peerHandle.sendInner(inner);
36434
36808
  }
36435
- return pending;
36809
+ const timeoutMs = this.opts.fetchTimeoutMs;
36810
+ if (timeoutMs === void 0 || timeoutMs <= 0)
36811
+ return pending;
36812
+ let timer;
36813
+ const start = Date.now();
36814
+ try {
36815
+ return await Promise.race([
36816
+ pending,
36817
+ new Promise((resolve) => {
36818
+ timer = setTimeout(() => {
36819
+ this.pending.delete(id);
36820
+ const elapsedMs = Date.now() - start;
36821
+ const error51 = `fetchproxy: ${init.url} did not respond within ${timeoutMs}ms`;
36822
+ resolve({
36823
+ ok: false,
36824
+ error: error51,
36825
+ kind: "timeout",
36826
+ retryAttempted: false,
36827
+ elapsedMs
36828
+ });
36829
+ }, timeoutMs);
36830
+ })
36831
+ ]);
36832
+ } finally {
36833
+ if (timer)
36834
+ clearTimeout(timer);
36835
+ }
36836
+ }
36837
+ /**
36838
+ * Map an `ok:false` fetch result to its typed throwable. Centralizes
36839
+ * the kind-to-error-class switch so `request()` and (via the same
36840
+ * logic re-implemented inline) `captureRequestHeader()` agree on what
36841
+ * to throw.
36842
+ */
36843
+ _typedErrorFor(result, url2, op, retryAttempted) {
36844
+ if (result.kind === "timeout") {
36845
+ return new FetchproxyTimeoutError({
36846
+ url: url2,
36847
+ timeoutMs: this.opts.fetchTimeoutMs ?? 0,
36848
+ role: this.role,
36849
+ port: this.opts.port,
36850
+ elapsedMs: result.elapsedMs
36851
+ });
36852
+ }
36853
+ if (result.kind === "content_script_unreachable") {
36854
+ return new FetchproxyBridgeDownError({
36855
+ originalError: result.error,
36856
+ retryAttempted,
36857
+ op,
36858
+ url: url2,
36859
+ role: this.role,
36860
+ port: this.opts.port
36861
+ });
36862
+ }
36863
+ return new FetchproxyProtocolError(result.error);
36436
36864
  }
36437
36865
  /**
36438
36866
  * Convenience wrapper around `fetch()`. Builds the URL from a path
@@ -36455,8 +36883,18 @@ var FetchproxyServer = class {
36455
36883
  if (opts.subdomain !== void 0)
36456
36884
  assertSubdomainLabel(opts.subdomain);
36457
36885
  const baseDomain = this.resolveBaseDomain(opts.domain);
36458
- const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
36459
- const url2 = path.startsWith("http://") || path.startsWith("https://") ? path : `https://${host}${path}`;
36886
+ const isAbsolute = path.startsWith("http://") || path.startsWith("https://");
36887
+ let host;
36888
+ if (isAbsolute) {
36889
+ try {
36890
+ host = new URL(path).host;
36891
+ } catch {
36892
+ throw new Error(`FetchproxyServer.request: absolute path is not a valid URL: ${JSON.stringify(path)}`);
36893
+ }
36894
+ } else {
36895
+ host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
36896
+ }
36897
+ const url2 = isAbsolute ? path : `https://${host}${path}`;
36460
36898
  assertUrlInDomains("request url", url2, this.opts.domains);
36461
36899
  const init = {
36462
36900
  url: url2,
@@ -36467,7 +36905,7 @@ var FetchproxyServer = class {
36467
36905
  };
36468
36906
  const result = await this.fetch(init);
36469
36907
  if (!result.ok) {
36470
- throw new FetchproxyProtocolError(result.error);
36908
+ throw this._typedErrorFor(result, init.url, "fetch", result.retryAttempted ?? false);
36471
36909
  }
36472
36910
  const response = {
36473
36911
  status: result.status,
@@ -36561,9 +36999,8 @@ var FetchproxyServer = class {
36561
36999
  if (!this.opts.capabilities.includes("read_cookies")) {
36562
37000
  throw new Error('FetchproxyServer.readCookies(): MCP did not declare "read_cookies" in capabilities \u2014 add it to FetchproxyServerOpts.capabilities to enable this verb');
36563
37001
  }
36564
- if (!this.hostHandle && !this.peerHandle) {
36565
- throw new Error("FetchproxyServer.readCookies called before listen() \u2014 not listening");
36566
- }
37002
+ await this.ensureConnected();
37003
+ this.throwIfPendingPair();
36567
37004
  if (opts.subdomain !== void 0)
36568
37005
  assertSubdomainLabel(opts.subdomain);
36569
37006
  const baseDomain = this.resolveBaseDomain(opts.domain);
@@ -36620,9 +37057,8 @@ var FetchproxyServer = class {
36620
37057
  if (!this.opts.capabilities.includes(op)) {
36621
37058
  throw new Error(`FetchproxyServer.${op === "read_local_storage" ? "readLocalStorage" : "readSessionStorage"}(): MCP did not declare ${JSON.stringify(op)} in capabilities`);
36622
37059
  }
36623
- if (!this.hostHandle && !this.peerHandle) {
36624
- throw new Error(`FetchproxyServer.${op} called before listen() \u2014 not listening`);
36625
- }
37060
+ await this.ensureConnected();
37061
+ this.throwIfPendingPair();
36626
37062
  if (!Array.isArray(opts.keys) || opts.keys.length === 0) {
36627
37063
  throw new Error(`FetchproxyServer.${op}: opts.keys must be a non-empty array`);
36628
37064
  }
@@ -36677,13 +37113,75 @@ var FetchproxyServer = class {
36677
37113
  if (!this.opts.capabilities.includes("capture_request_header")) {
36678
37114
  throw new Error('FetchproxyServer.captureRequestHeader(): MCP did not declare "capture_request_header" in capabilities');
36679
37115
  }
36680
- if (!this.hostHandle && !this.peerHandle) {
36681
- throw new Error("FetchproxyServer.captureRequestHeader called before listen() \u2014 not listening");
37116
+ await this.ensureConnected();
37117
+ this.throwIfPendingPair();
37118
+ const decls = this.opts.captureHeaders;
37119
+ let resolved;
37120
+ if (opts?.urlPattern !== void 0 && opts?.headerName !== void 0) {
37121
+ const found = decls.find((d) => d.urlPattern === opts.urlPattern && d.headerName === opts.headerName);
37122
+ if (!found) {
37123
+ throw new Error(`FetchproxyServer.captureRequestHeader: (urlPattern=${JSON.stringify(opts.urlPattern)}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
37124
+ }
37125
+ resolved = found;
37126
+ } else if (opts?.urlPattern === void 0 && opts?.headerName === void 0) {
37127
+ if (decls.length === 0) {
37128
+ 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");
37129
+ }
37130
+ if (decls.length > 1) {
37131
+ const list = decls.map((d) => `${JSON.stringify(d.urlPattern)}/${JSON.stringify(d.headerName)}`).join(", ");
37132
+ throw new Error(`FetchproxyServer.captureRequestHeader: multiple captureHeaders declared (${decls.length}: ${list}); pass {urlPattern, headerName} to disambiguate`);
37133
+ }
37134
+ resolved = decls[0];
37135
+ } else {
37136
+ throw new Error("FetchproxyServer.captureRequestHeader: pass both urlPattern AND headerName, or neither (which defaults to the single declared entry)");
36682
37137
  }
36683
- const declared = this.opts.captureHeaders.find((d) => d.urlPattern === opts.urlPattern && d.headerName === opts.headerName);
36684
- if (!declared) {
36685
- throw new Error(`FetchproxyServer.captureRequestHeader: (urlPattern=${JSON.stringify(opts.urlPattern)}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
37138
+ const callOpts = { ...resolved, ...opts?.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {} };
37139
+ try {
37140
+ const result = await this._captureRequestHeaderOnce(callOpts);
37141
+ this.recordSuccess();
37142
+ return result;
37143
+ } catch (err) {
37144
+ const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
37145
+ if (!swDown) {
37146
+ this.recordFailure(`capture_request_header: ${err.message ?? String(err)}`);
37147
+ throw err;
37148
+ }
37149
+ const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
37150
+ if (reviveMs > 0) {
37151
+ await new Promise((r) => setTimeout(r, reviveMs));
37152
+ try {
37153
+ const result = await this._captureRequestHeaderOnce(callOpts);
37154
+ this.recordSuccess();
37155
+ return result;
37156
+ } catch (retryErr) {
37157
+ const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
37158
+ if (!stillDown) {
37159
+ this.recordFailure(`capture_request_header: ${retryErr.message ?? String(retryErr)}`);
37160
+ throw retryErr;
37161
+ }
37162
+ this.recordFailure(`capture_request_header bridge-down: ${retryErr.message}`);
37163
+ throw new FetchproxyBridgeDownError({
37164
+ originalError: retryErr.message,
37165
+ retryAttempted: true,
37166
+ op: "capture_request_header",
37167
+ url: resolved.urlPattern,
37168
+ role: this.role,
37169
+ port: this.opts.port
37170
+ });
37171
+ }
37172
+ }
37173
+ this.recordFailure(`capture_request_header bridge-down: ${err.message}`);
37174
+ throw new FetchproxyBridgeDownError({
37175
+ originalError: err.message,
37176
+ retryAttempted: false,
37177
+ op: "capture_request_header",
37178
+ url: resolved.urlPattern,
37179
+ role: this.role,
37180
+ port: this.opts.port
37181
+ });
36686
37182
  }
37183
+ }
37184
+ async _captureRequestHeaderOnce(opts) {
36687
37185
  const id = this.nextRequestId++;
36688
37186
  const inner = {
36689
37187
  type: "request",
@@ -36720,9 +37218,8 @@ var FetchproxyServer = class {
36720
37218
  if (!this.opts.capabilities.includes("read_indexed_db")) {
36721
37219
  throw new Error('FetchproxyServer.readIndexedDb(): MCP did not declare "read_indexed_db" in capabilities');
36722
37220
  }
36723
- if (!this.hostHandle && !this.peerHandle) {
36724
- throw new Error("FetchproxyServer.readIndexedDb called before listen() \u2014 not listening");
36725
- }
37221
+ await this.ensureConnected();
37222
+ this.throwIfPendingPair();
36726
37223
  if (!Array.isArray(opts.keys) || opts.keys.length === 0) {
36727
37224
  throw new Error("FetchproxyServer.readIndexedDb: opts.keys must be a non-empty array");
36728
37225
  }
@@ -36793,17 +37290,35 @@ var FetchproxyServer = class {
36793
37290
  onInner(inner) {
36794
37291
  if (inner.type !== "response")
36795
37292
  return;
37293
+ this.lastExtensionMessageAt = Date.now();
36796
37294
  const fetchCb = this.pending.get(inner.id);
36797
37295
  if (fetchCb) {
36798
37296
  this.pending.delete(inner.id);
36799
37297
  if (inner.ok) {
36800
37298
  if (inner.op === void 0 || inner.op === "fetch") {
36801
- fetchCb({ ok: true, status: inner.status, url: inner.url, body: inner.body });
37299
+ fetchCb({
37300
+ ok: true,
37301
+ status: inner.status,
37302
+ url: inner.url,
37303
+ body: inner.body,
37304
+ retryAttempted: false
37305
+ });
36802
37306
  } else {
36803
- fetchCb({ ok: false, error: `unexpected ${inner.op} response on fetch awaiter` });
37307
+ const error51 = `unexpected ${inner.op} response on fetch awaiter`;
37308
+ fetchCb({
37309
+ ok: false,
37310
+ error: error51,
37311
+ kind: classifyFetchError(error51),
37312
+ retryAttempted: false
37313
+ });
36804
37314
  }
36805
37315
  } else {
36806
- fetchCb({ ok: false, error: inner.error });
37316
+ fetchCb({
37317
+ ok: false,
37318
+ error: inner.error,
37319
+ kind: classifyFetchError(inner.error),
37320
+ retryAttempted: false
37321
+ });
36807
37322
  }
36808
37323
  return;
36809
37324
  }
@@ -36870,6 +37385,57 @@ var FetchproxyServer = class {
36870
37385
  }
36871
37386
  }
36872
37387
  }
37388
+ rejectAllPending(reason = "extension disconnected") {
37389
+ const err = new FetchproxyProtocolError(reason);
37390
+ for (const cb of this.pending.values()) {
37391
+ cb({
37392
+ ok: false,
37393
+ error: err.message,
37394
+ kind: classifyFetchError(err.message),
37395
+ retryAttempted: false
37396
+ });
37397
+ }
37398
+ this.pending.clear();
37399
+ for (const cb of this.pendingReadCookies.values()) {
37400
+ cb({ ok: false, error: err.message });
37401
+ }
37402
+ this.pendingReadCookies.clear();
37403
+ for (const { reject } of this.pendingStorage.values())
37404
+ reject(err);
37405
+ this.pendingStorage.clear();
37406
+ for (const { reject } of this.pendingCapture.values())
37407
+ reject(err);
37408
+ this.pendingCapture.clear();
37409
+ for (const { reject } of this.pendingIdb.values())
37410
+ reject(err);
37411
+ this.pendingIdb.clear();
37412
+ }
37413
+ /**
37414
+ * 0.5.2+: read the current pair-pending pair code from whichever handle
37415
+ * is active, returning null when none is pending. Public verbs call this
37416
+ * at the top so that a tool invoked while the bridge is waiting on user
37417
+ * approval fails fast with the actionable error rather than hanging on a
37418
+ * sealed frame the extension will never process.
37419
+ */
37420
+ currentPendingPairCode() {
37421
+ if (this.hostHandle)
37422
+ return this.hostHandle.pendingPairCode();
37423
+ if (this.peerHandle)
37424
+ return this.peerHandle.pendingPairCode();
37425
+ return null;
37426
+ }
37427
+ /**
37428
+ * 0.5.2+: throw `FetchproxyProtocolError` with the actionable pair-code
37429
+ * message if the bridge is waiting on user approval. Used by the verb
37430
+ * methods (readCookies, readLocalStorage, etc.) that surface errors via
37431
+ * thrown exceptions rather than `ok:false` discriminated unions.
37432
+ */
37433
+ throwIfPendingPair() {
37434
+ const code = this.currentPendingPairCode();
37435
+ if (code !== null) {
37436
+ throw new FetchproxyProtocolError(this.pairingErrorMessage(code));
37437
+ }
37438
+ }
36873
37439
  /**
36874
37440
  * Shut down the bridge. Host: terminates the WebSocket server and any
36875
37441
  * still-attached extension/peer clients. Peer: closes the upstream
@@ -36877,6 +37443,10 @@ var FetchproxyServer = class {
36877
37443
  * twice in a row.
36878
37444
  */
36879
37445
  async close() {
37446
+ this.rejectAllPending();
37447
+ if (this.connectingPromise) {
37448
+ await this.connectingPromise.catch(() => void 0);
37449
+ }
36880
37450
  if (this.hostHandle)
36881
37451
  await this.hostHandle.close();
36882
37452
  if (this.peerHandle)
@@ -36884,9 +37454,23 @@ var FetchproxyServer = class {
36884
37454
  this.hostHandle = null;
36885
37455
  this.peerHandle = null;
36886
37456
  this.role = null;
37457
+ this.connectingPromise = null;
36887
37458
  }
36888
37459
  };
36889
37460
 
37461
+ // node_modules/@fetchproxy/server/dist/classify-bridge-error.js
37462
+ function classifyBridgeError(err) {
37463
+ if (err instanceof FetchproxyTimeoutError)
37464
+ return "timeout";
37465
+ if (err instanceof FetchproxyBridgeDownError)
37466
+ return "bridge_down";
37467
+ if (err instanceof FetchproxyHttpError)
37468
+ return "http";
37469
+ if (err instanceof FetchproxyProtocolError)
37470
+ return "protocol";
37471
+ return "other";
37472
+ }
37473
+
36890
37474
  // node_modules/@fetchproxy/bootstrap/dist/index.js
36891
37475
  var defaultFactory = (opts) => new FetchproxyServer(opts);
36892
37476
  async function bootstrap(opts) {
@@ -36941,7 +37525,11 @@ async function bootstrap(opts) {
36941
37525
  key: p.storageKey,
36942
37526
  jsonPointer: p.jsonPointer
36943
37527
  })),
36944
- onPairCode: opts.onPairCode
37528
+ onPairCode: opts.onPairCode,
37529
+ // 0.8.0+ pass-through. Only forwarded when the caller set them;
37530
+ // unset → server defaults apply (30000 / 2000 in 0.8.0).
37531
+ ...opts.fetchTimeoutMs !== void 0 ? { fetchTimeoutMs: opts.fetchTimeoutMs } : {},
37532
+ ...opts.bridgeReviveDelayMs !== void 0 ? { bridgeReviveDelayMs: opts.bridgeReviveDelayMs } : {}
36945
37533
  });
36946
37534
  const storageDomainOpts = {};
36947
37535
  if (opts.storageDomain !== void 0)
@@ -36972,8 +37560,7 @@ async function bootstrap(opts) {
36972
37560
  for (const p of localStoragePointers) {
36973
37561
  pointers[p.outputKey] = { storageKey: p.storageKey, jsonPointer: p.jsonPointer };
36974
37562
  }
36975
- const stub = server;
36976
- localStorage = await stub.readLocalStorage({
37563
+ localStorage = await server.readLocalStorage({
36977
37564
  keys: allKeys,
36978
37565
  ...storageDomainOpts,
36979
37566
  ...localStoragePointers.length > 0 ? { pointers } : {}
@@ -36986,8 +37573,7 @@ async function bootstrap(opts) {
36986
37573
  for (const p of sessionStoragePointers) {
36987
37574
  pointers[p.outputKey] = { storageKey: p.storageKey, jsonPointer: p.jsonPointer };
36988
37575
  }
36989
- const stub = server;
36990
- sessionStorage = await stub.readSessionStorage({
37576
+ sessionStorage = await server.readSessionStorage({
36991
37577
  keys: allKeys,
36992
37578
  ...storageDomainOpts,
36993
37579
  ...sessionStoragePointers.length > 0 ? { pointers } : {}
@@ -37010,9 +37596,6 @@ async function bootstrap(opts) {
37010
37596
  }
37011
37597
  const indexedDbBucket = {};
37012
37598
  for (const d of indexedDb) {
37013
- if (!server.readIndexedDb) {
37014
- throw new Error("bootstrap: server factory does not implement readIndexedDb (declared indexedDb but server stub omits it)");
37015
- }
37016
37599
  const values = await server.readIndexedDb({
37017
37600
  database: d.database,
37018
37601
  store: d.store,
@@ -37049,7 +37632,7 @@ var BootstrapDisabledError = class extends Error {
37049
37632
  // package.json
37050
37633
  var package_default = {
37051
37634
  name: "creditkarma-mcp",
37052
- version: "2.0.10",
37635
+ version: "2.2.0",
37053
37636
  mcpName: "io.github.chrischall/creditkarma-mcp",
37054
37637
  description: "MCP server for Credit Karma \u2014 natural-language access to your transactions, spending, and accounts",
37055
37638
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -37093,17 +37676,18 @@ var package_default = {
37093
37676
  "test:coverage": "vitest run --coverage"
37094
37677
  },
37095
37678
  dependencies: {
37096
- "@fetchproxy/bootstrap": "^0.4.2",
37679
+ "@fetchproxy/bootstrap": "^0.8.0",
37680
+ "@fetchproxy/server": "^0.8.0",
37097
37681
  "@modelcontextprotocol/sdk": "^1.29.0",
37098
- dotenv: "^17.4.0",
37099
- zod: "^4.3.6"
37682
+ dotenv: "^17.4.2",
37683
+ zod: "^4.4.3"
37100
37684
  },
37101
37685
  devDependencies: {
37102
- "@types/node": "^25.5.2",
37103
- "@vitest/coverage-v8": "^4.1.2",
37686
+ "@types/node": "^25.9.1",
37687
+ "@vitest/coverage-v8": "^4.1.7",
37104
37688
  esbuild: "^0.28.0",
37105
- typescript: "^6.0.2",
37106
- vitest: "^4.1.2"
37689
+ typescript: "^6.0.3",
37690
+ vitest: "^4.1.7"
37107
37691
  }
37108
37692
  };
37109
37693
 
@@ -37163,6 +37747,12 @@ async function resolveAuth() {
37163
37747
  const cookies = `CKTRKID=${cktrkid}; CKAT=${ckat}`;
37164
37748
  return { cookies, source: "fetchproxy" };
37165
37749
  } catch (e) {
37750
+ if (classifyBridgeError(e) === "bridge_down") {
37751
+ const downErr = e;
37752
+ throw new Error(
37753
+ `CK auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
37754
+ );
37755
+ }
37166
37756
  const msg = e instanceof Error ? e.message : String(e);
37167
37757
  throw new Error(
37168
37758
  `CK auth: no CK_COOKIES set, and fetchproxy fallback failed: ${msg}`
@@ -37623,7 +38213,8 @@ async function main() {
37623
38213
  mcpJsonPath
37624
38214
  };
37625
38215
  const server = new McpServer(
37626
- { name: "creditkarma-mcp", version: "2.0.10" }
38216
+ { name: "creditkarma-mcp", version: "2.2.0" }
38217
+ // x-release-please-version
37627
38218
  );
37628
38219
  registerAuthTools(server, ctx);
37629
38220
  registerSyncTools(server, ctx);
package/dist/index.js CHANGED
@@ -63,7 +63,8 @@ async function main() {
63
63
  db,
64
64
  mcpJsonPath
65
65
  };
66
- const server = new McpServer({ name: 'creditkarma-mcp', version: '2.0.10' });
66
+ const server = new McpServer({ name: 'creditkarma-mcp', version: '2.2.0' } // x-release-please-version
67
+ );
67
68
  registerAuthTools(server, ctx);
68
69
  registerSyncTools(server, ctx);
69
70
  registerQueryTools(server, ctx);
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "creditkarma-mcp",
3
- "version": "2.0.10",
3
+ "version": "2.2.0",
4
4
  "mcpName": "io.github.chrischall/creditkarma-mcp",
5
- "description": "MCP server for Credit Karma \u2014 natural-language access to your transactions, spending, and accounts",
5
+ "description": "MCP server for Credit Karma natural-language access to your transactions, spending, and accounts",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
7
7
  "repository": {
8
8
  "type": "git",
@@ -44,16 +44,17 @@
44
44
  "test:coverage": "vitest run --coverage"
45
45
  },
46
46
  "dependencies": {
47
- "@fetchproxy/bootstrap": "^0.4.2",
47
+ "@fetchproxy/bootstrap": "^0.8.0",
48
+ "@fetchproxy/server": "^0.8.0",
48
49
  "@modelcontextprotocol/sdk": "^1.29.0",
49
- "dotenv": "^17.4.0",
50
- "zod": "^4.3.6"
50
+ "dotenv": "^17.4.2",
51
+ "zod": "^4.4.3"
51
52
  },
52
53
  "devDependencies": {
53
- "@types/node": "^25.5.2",
54
- "@vitest/coverage-v8": "^4.1.2",
54
+ "@types/node": "^25.9.1",
55
+ "@vitest/coverage-v8": "^4.1.7",
55
56
  "esbuild": "^0.28.0",
56
- "typescript": "^6.0.2",
57
- "vitest": "^4.1.2"
57
+ "typescript": "^6.0.3",
58
+ "vitest": "^4.1.7"
58
59
  }
59
60
  }
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/creditkarma-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.0.10",
9
+ "version": "2.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "creditkarma-mcp",
14
- "version": "2.0.10",
14
+ "version": "2.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },