zola-mcp 1.4.2 → 1.4.4

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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "Zola wedding planning tools for Claude Code",
10
- "version": "1.4.2"
10
+ "version": "1.4.4"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Zola",
16
16
  "source": "./",
17
17
  "description": "Zola wedding planning tools for Claude — vendors, budget, guests, seating, events, registry, inquiries, and more via MCP",
18
- "version": "1.4.2",
18
+ "version": "1.4.4",
19
19
  "author": {
20
20
  "name": "Chris Chall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zola",
3
3
  "displayName": "Zola",
4
- "version": "1.4.2",
4
+ "version": "1.4.4",
5
5
  "description": "Zola wedding planning tools for Claude — vendors, budget, guests, seating, events, registry, inquiries, and more via MCP",
6
6
  "author": {
7
7
  "name": "Chris Chall",
package/dist/bundle.js CHANGED
@@ -12,7 +12,11 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
12
12
  throw Error('Dynamic require of "' + x + '" is not supported');
13
13
  });
14
14
  var __commonJS = (cb, mod) => function __require2() {
15
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
15
+ try {
16
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
17
+ } catch (e) {
18
+ throw mod = 0, e;
19
+ }
16
20
  };
17
21
  var __export = (target, all) => {
18
22
  for (var name in all)
@@ -34724,8 +34728,12 @@ var API_KEY_RE = new RegExp([
34724
34728
  // webhook signing secret (Stripe-style)
34725
34729
  ].map((p) => `\\b${p}`).join("|"), "g");
34726
34730
  var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
34731
+ var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
34732
+ var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
34733
+ var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
34734
+ var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
34727
34735
  function redactSecrets(text) {
34728
- return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
34736
+ return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
34729
34737
  }
34730
34738
  function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
34731
34739
  const str = text === null || text === void 0 ? "" : String(text);
@@ -34848,6 +34856,8 @@ var NonNegInt = external_exports.number().int().nonnegative();
34848
34856
  var NonEmptyString = external_exports.string().min(1);
34849
34857
  var IsoDate = external_exports.iso.date();
34850
34858
  var IsoTime = external_exports.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, "must be HH:MM (24h), e.g. 19:30");
34859
+ var NumericIdString = external_exports.string().regex(/^\d+$/, "must be a numeric id (digits only)").describe("A numeric id string (digits only), safe to interpolate into a URL path.");
34860
+ var SafePathSegment = external_exports.string().min(1).regex(/^[^/?#\s]+$/, 'must not contain "/", "?", "#", or whitespace').refine((v) => !v.includes(".."), 'must not contain ".."').describe('A single URL path segment, safe to interpolate: no "/", "..", "?", "#", or whitespace.');
34851
34861
  var schemaOrigin = external_exports.string().optional().describe("Portal origin (e.g. https://<vendor>.example.co) selecting which active session to use. Optional when only one session is active.");
34852
34862
  var schemaConfirm = external_exports.boolean().optional().describe("Must be true to proceed. Without this, the tool returns a preview.");
34853
34863
  var paginationSchema = {
@@ -34859,6 +34869,41 @@ var pageSchema = {
34859
34869
  page_size: external_exports.number().int().min(1).max(200).default(50).describe("Number of items per page (1-200).")
34860
34870
  };
34861
34871
 
34872
+ // node_modules/@chrischall/mcp-utils/dist/auth/cached-token.js
34873
+ function createCachedTokenSource(opts) {
34874
+ const now = opts.now ?? Date.now;
34875
+ const bufferMs = opts.bufferMs ?? 6e4;
34876
+ const defaultTtlMs = opts.defaultTtlMs ?? 36e5;
34877
+ let cached2 = null;
34878
+ let inFlight = null;
34879
+ const expiryOf = (minted) => {
34880
+ if (minted.expiresAt !== void 0) {
34881
+ return minted.expiresAt instanceof Date ? minted.expiresAt.getTime() : minted.expiresAt;
34882
+ }
34883
+ return now() + (minted.ttlMs ?? defaultTtlMs);
34884
+ };
34885
+ return {
34886
+ getToken() {
34887
+ if (cached2 && cached2.expiresAt - bufferMs > now())
34888
+ return Promise.resolve(cached2.token);
34889
+ if (inFlight)
34890
+ return inFlight;
34891
+ const p = opts.mint().then((minted) => {
34892
+ cached2 = { token: minted.token, expiresAt: expiryOf(minted) };
34893
+ return minted.token;
34894
+ }).finally(() => {
34895
+ if (inFlight === p)
34896
+ inFlight = null;
34897
+ });
34898
+ inFlight = p;
34899
+ return p;
34900
+ },
34901
+ invalidate() {
34902
+ cached2 = null;
34903
+ }
34904
+ };
34905
+ }
34906
+
34862
34907
  // node_modules/@chrischall/mcp-utils/dist/auth/index.js
34863
34908
  function bridgeDownHintOf(err) {
34864
34909
  if (err instanceof Error && err.name === "FetchproxyBridgeDownError") {
@@ -35860,10 +35905,20 @@ async function openEncryptedFrame(sessionKey, frame) {
35860
35905
 
35861
35906
  // node_modules/@fetchproxy/server/dist/election.js
35862
35907
  import { createServer, Server as HttpServer } from "node:http";
35908
+ var DEFAULT_BIND_TIMEOUT_MS = 5e3;
35863
35909
  async function electRole(opts) {
35864
35910
  const server = createServer();
35911
+ const bindTimeoutMs = opts.bindTimeoutMs ?? DEFAULT_BIND_TIMEOUT_MS;
35865
35912
  return new Promise((resolve, reject) => {
35913
+ let timer;
35914
+ const clearBindTimer = () => {
35915
+ if (timer) {
35916
+ clearTimeout(timer);
35917
+ timer = void 0;
35918
+ }
35919
+ };
35866
35920
  const onError = (e) => {
35921
+ clearBindTimer();
35867
35922
  server.removeListener("listening", onListening);
35868
35923
  if (e.code === "EADDRINUSE") {
35869
35924
  try {
@@ -35876,11 +35931,25 @@ async function electRole(opts) {
35876
35931
  }
35877
35932
  };
35878
35933
  const onListening = () => {
35934
+ clearBindTimer();
35879
35935
  server.removeListener("error", onError);
35880
35936
  resolve({ role: "host", server });
35881
35937
  };
35882
35938
  server.once("error", onError);
35883
35939
  server.once("listening", onListening);
35940
+ if (bindTimeoutMs > 0) {
35941
+ timer = setTimeout(() => {
35942
+ server.removeListener("error", onError);
35943
+ server.removeListener("listening", onListening);
35944
+ try {
35945
+ server.close();
35946
+ } catch {
35947
+ }
35948
+ reject(new Error(`fetchproxy: bind to ${opts.host}:${opts.port} timed out after ${bindTimeoutMs}ms (port may be in a bad state)`));
35949
+ }, bindTimeoutMs);
35950
+ if (typeof timer.unref === "function")
35951
+ timer.unref();
35952
+ }
35884
35953
  server.listen(opts.port, opts.host);
35885
35954
  });
35886
35955
  }
@@ -35973,6 +36042,44 @@ var SessionState = class {
35973
36042
  }
35974
36043
  };
35975
36044
 
36045
+ // node_modules/@fetchproxy/server/dist/session-ready.js
36046
+ var SESSION_READY_TIMEOUT_MS = 3e4;
36047
+ var FetchproxySessionNotReadyError = class extends Error {
36048
+ reason;
36049
+ pairCode;
36050
+ mcpId;
36051
+ hint;
36052
+ constructor(info) {
36053
+ const pairing = info.pairCode !== null && info.pairCode !== "";
36054
+ const hint = pairing ? `Open the Transporter extension popup and approve pair code ${info.pairCode} for "${info.mcpId}", then retry.` : `The extension is connected but hasn't confirmed a session for "${info.mcpId}" \u2014 sign in to the target site in that browser (and approve the requested scope if it changed), then retry.`;
36055
+ super(`fetchproxy: ${pairing ? "pairing not yet approved" : "no confirmed browser session"} for "${info.mcpId}". ${hint}`);
36056
+ this.name = "FetchproxySessionNotReadyError";
36057
+ this.reason = pairing ? "pair-required" : "not-ready";
36058
+ this.pairCode = pairing ? info.pairCode : null;
36059
+ this.mcpId = info.mcpId;
36060
+ this.hint = hint;
36061
+ Object.setPrototypeOf(this, new.target.prototype);
36062
+ }
36063
+ };
36064
+ async function awaitSessionReady(ready, opts) {
36065
+ const ms = opts.timeoutMs ?? SESSION_READY_TIMEOUT_MS;
36066
+ if (ms <= 0)
36067
+ return ready;
36068
+ let timer;
36069
+ const timeout = new Promise((_, reject) => {
36070
+ timer = setTimeout(() => {
36071
+ reject(new FetchproxySessionNotReadyError({ mcpId: opts.mcpId, pairCode: opts.pendingPairCode() }));
36072
+ }, ms);
36073
+ timer.unref?.();
36074
+ });
36075
+ try {
36076
+ return await Promise.race([ready, timeout]);
36077
+ } finally {
36078
+ if (timer)
36079
+ clearTimeout(timer);
36080
+ }
36081
+ }
36082
+
35976
36083
  // node_modules/@fetchproxy/server/dist/host.js
35977
36084
  var PUBLIC_ORIGIN_RE = /^https?:\/\/(?!(127\.0\.0\.1|localhost)(:|$))/i;
35978
36085
  var enc2 = new TextEncoder();
@@ -36060,6 +36167,29 @@ async function startHost(opts) {
36060
36167
  return;
36061
36168
  }
36062
36169
  if (frame.type === "hello" && frame.role === "server") {
36170
+ const peerEdPub = fromB64(frame.identityEd25519Pub);
36171
+ const peerSigMsg = concatBytes(enc2.encode(frame.mcpId), fromB64(frame.sessionNonce));
36172
+ const peerSig = fromB64(frame.sessionSig);
36173
+ let peerSigOk = false;
36174
+ try {
36175
+ peerSigOk = await ed25519Verify(peerEdPub, peerSigMsg, peerSig);
36176
+ } catch {
36177
+ peerSigOk = false;
36178
+ }
36179
+ if (!peerSigOk) {
36180
+ console.warn("[fetchproxy] peer hello signature invalid \u2014 refusing registration (possible squatter)");
36181
+ ws.close(1008, "peer hello signature invalid");
36182
+ return;
36183
+ }
36184
+ const existing = peers.get(frame.mcpId);
36185
+ if (existing && existing.ws !== ws) {
36186
+ const existingEdPub = existing.helloFrame.identityEd25519Pub;
36187
+ if (existingEdPub !== frame.identityEd25519Pub) {
36188
+ console.warn("[fetchproxy] peer mcpId already mapped to a different identity \u2014 refusing (mcpId squatting)");
36189
+ ws.close(1008, "mcpId already registered to another identity");
36190
+ return;
36191
+ }
36192
+ }
36063
36193
  identified = "peer";
36064
36194
  peerMcpId = frame.mcpId;
36065
36195
  peers.set(frame.mcpId, { ws, helloFrame: frame });
@@ -36152,8 +36282,10 @@ async function startHost(opts) {
36152
36282
  resetSessionPromise();
36153
36283
  disconnectListeners.forEach((cb) => cb());
36154
36284
  }
36155
- if (identified === "peer" && peerMcpId)
36156
- peers.delete(peerMcpId);
36285
+ if (identified === "peer" && peerMcpId) {
36286
+ if (peers.get(peerMcpId)?.ws === ws)
36287
+ peers.delete(peerMcpId);
36288
+ }
36157
36289
  });
36158
36290
  });
36159
36291
  return {
@@ -36169,7 +36301,10 @@ async function startHost(opts) {
36169
36301
  });
36170
36302
  }),
36171
36303
  sendOwnInner: async (inner) => {
36172
- const session = await ownSessionReady;
36304
+ const session = await awaitSessionReady(ownSessionReady, {
36305
+ mcpId: opts.ownMcpId,
36306
+ pendingPairCode: () => ownPendingPairCode
36307
+ });
36173
36308
  if (!extensionWs)
36174
36309
  throw new Error("host: no extension connected");
36175
36310
  const sealed = await sealInnerFrame(session.sessionKey, opts.ownMcpId, session.nextOutboundSeq(), inner);
@@ -36274,7 +36409,10 @@ async function startPeer(opts) {
36274
36409
  ws,
36275
36410
  session: sessionPromise,
36276
36411
  sendInner: async (inner) => {
36277
- await sessionPromise;
36412
+ await awaitSessionReady(sessionPromise, {
36413
+ mcpId: opts.mcpId,
36414
+ pendingPairCode: () => pendingPairCode
36415
+ });
36278
36416
  const s = session;
36279
36417
  const sealed = await sealInnerFrame(s.sessionKey, opts.mcpId, s.nextOutboundSeq(), inner);
36280
36418
  ws.send(JSON.stringify(sealed));
@@ -36940,6 +37078,35 @@ var FetchproxyServer = class {
36940
37078
  this.keepAliveTimer = null;
36941
37079
  }
36942
37080
  }
37081
+ /**
37082
+ * Send an inner request frame via whichever bridge handle is active. If the
37083
+ * send throws (e.g. `FetchproxySessionNotReadyError` — the session never
37084
+ * confirmed), the frame never reached the bridge, so no reply will arrive:
37085
+ * drop the just-registered pending resolver for this id (it lives in exactly
37086
+ * one of the op maps — request ids are unique) so it doesn't leak until the
37087
+ * server closes, then rethrow.
37088
+ */
37089
+ async sendInnerFrame(inner) {
37090
+ try {
37091
+ if (this.hostHandle) {
37092
+ await this.hostHandle.sendOwnInner(inner);
37093
+ } else if (this.peerHandle) {
37094
+ await this.peerHandle.sendInner(inner);
37095
+ }
37096
+ } catch (err) {
37097
+ if ("id" in inner && typeof inner.id === "number") {
37098
+ const { id } = inner;
37099
+ this.pending.delete(id);
37100
+ this.pendingReadCookies.delete(id);
37101
+ this.pendingStorage.delete(id);
37102
+ this.pendingCapture.delete(id);
37103
+ this.pendingRedirect.delete(id);
37104
+ this.pendingDownload.delete(id);
37105
+ this.pendingIdb.delete(id);
37106
+ }
37107
+ throw err;
37108
+ }
37109
+ }
36943
37110
  /**
36944
37111
  * Single bridge round-trip, wrapped by `fetchTimeoutMs` when set.
36945
37112
  * On timeout returns the `{ok:false, kind:'timeout'}` envelope —
@@ -36951,11 +37118,7 @@ var FetchproxyServer = class {
36951
37118
  const pending = new Promise((resolve) => {
36952
37119
  this.pending.set(id, resolve);
36953
37120
  });
36954
- if (this.hostHandle) {
36955
- await this.hostHandle.sendOwnInner(inner);
36956
- } else if (this.peerHandle) {
36957
- await this.peerHandle.sendInner(inner);
36958
- }
37121
+ await this.sendInnerFrame(inner);
36959
37122
  const timeoutMs = this.opts.fetchTimeoutMs;
36960
37123
  if (timeoutMs === void 0 || timeoutMs <= 0)
36961
37124
  return pending;
@@ -36984,6 +37147,51 @@ var FetchproxyServer = class {
36984
37147
  clearTimeout(timer);
36985
37148
  }
36986
37149
  }
37150
+ /**
37151
+ * FP-B2: bound a non-`fetch` verb's reply wait by `fetchTimeoutMs`.
37152
+ *
37153
+ * Before this, only `fetch()` raced its `pending` reply against a timer
37154
+ * (`_fetchOnceWithTimeout`); `readCookies`, the storage reads,
37155
+ * `capture_request_header`, `capture_redirect`, `download`, and
37156
+ * `read_indexed_db` awaited their `pending` promise with no race, so a
37157
+ * wedged extension hung the tool call indefinitely.
37158
+ *
37159
+ * `pending` is the already-registered reply promise. `pendingMap`/`id`
37160
+ * point at the op-specific map entry so we can drop it on expiry exactly
37161
+ * as `_fetchOnceWithTimeout` does — otherwise a late bridge reply would
37162
+ * resolve into a stale resolver / leak. On timeout we reject with a
37163
+ * `FetchproxyTimeoutError` (the same throwable the convenience methods
37164
+ * already surface for fetch timeouts). `0`/unset opts out (unbounded),
37165
+ * matching the fetch path.
37166
+ */
37167
+ async _withVerbTimeout(pending, pendingMap, id, url2) {
37168
+ const timeoutMs = this.opts.fetchTimeoutMs;
37169
+ if (timeoutMs === void 0 || timeoutMs <= 0)
37170
+ return pending;
37171
+ let timer;
37172
+ const start = Date.now();
37173
+ try {
37174
+ return await Promise.race([
37175
+ pending,
37176
+ new Promise((_resolve, reject) => {
37177
+ timer = setTimeout(() => {
37178
+ pendingMap.delete(id);
37179
+ reject(new FetchproxyTimeoutError({
37180
+ url: url2,
37181
+ timeoutMs,
37182
+ role: this.role,
37183
+ port: this.opts.port,
37184
+ elapsedMs: Date.now() - start,
37185
+ retryAttempted: false
37186
+ }));
37187
+ }, timeoutMs);
37188
+ })
37189
+ ]);
37190
+ } finally {
37191
+ if (timer)
37192
+ clearTimeout(timer);
37193
+ }
37194
+ }
36987
37195
  /**
36988
37196
  * Map an `ok:false` fetch result to its typed throwable. Centralizes
36989
37197
  * the kind-to-error-class switch so `request()` and (via the same
@@ -37274,12 +37482,8 @@ var FetchproxyServer = class {
37274
37482
  const pending = new Promise((resolve) => {
37275
37483
  this.pendingReadCookies.set(id, resolve);
37276
37484
  });
37277
- if (this.hostHandle) {
37278
- await this.hostHandle.sendOwnInner(inner);
37279
- } else if (this.peerHandle) {
37280
- await this.peerHandle.sendInner(inner);
37281
- }
37282
- const result = await pending;
37485
+ await this.sendInnerFrame(inner);
37486
+ const result = await this._withVerbTimeout(pending, this.pendingReadCookies, id, `https://${host}`);
37283
37487
  if (!result.ok) {
37284
37488
  throw new FetchproxyProtocolError(result.error);
37285
37489
  }
@@ -37345,12 +37549,8 @@ var FetchproxyServer = class {
37345
37549
  const pending = new Promise((resolve, reject) => {
37346
37550
  this.pendingStorage.set(id, { resolve, reject });
37347
37551
  });
37348
- if (this.hostHandle) {
37349
- await this.hostHandle.sendOwnInner(inner);
37350
- } else if (this.peerHandle) {
37351
- await this.peerHandle.sendInner(inner);
37352
- }
37353
- return pending;
37552
+ await this.sendInnerFrame(inner);
37553
+ return this._withVerbTimeout(pending, this.pendingStorage, id, `https://${host}`);
37354
37554
  }
37355
37555
  /**
37356
37556
  * 0.3.0+: snapshot the next outgoing request's named header. Single-
@@ -37454,12 +37654,8 @@ var FetchproxyServer = class {
37454
37654
  const pending = new Promise((resolve, reject) => {
37455
37655
  this.pendingCapture.set(id, { resolve, reject });
37456
37656
  });
37457
- if (this.hostHandle) {
37458
- await this.hostHandle.sendOwnInner(inner);
37459
- } else if (this.peerHandle) {
37460
- await this.peerHandle.sendInner(inner);
37461
- }
37462
- return pending;
37657
+ await this.sendInnerFrame(inner);
37658
+ return this._withVerbTimeout(pending, this.pendingCapture, id, `https://${opts.host}${opts.path ?? "/*"}`);
37463
37659
  }
37464
37660
  /**
37465
37661
  * Snapshot the redirect target URL of the next request the browser
@@ -37543,12 +37739,8 @@ var FetchproxyServer = class {
37543
37739
  const pending = new Promise((resolve, reject) => {
37544
37740
  this.pendingRedirect.set(id, { resolve, reject });
37545
37741
  });
37546
- if (this.hostHandle) {
37547
- await this.hostHandle.sendOwnInner(inner);
37548
- } else if (this.peerHandle) {
37549
- await this.peerHandle.sendInner(inner);
37550
- }
37551
- return pending;
37742
+ await this.sendInnerFrame(inner);
37743
+ return this._withVerbTimeout(pending, this.pendingRedirect, id, `https://${opts.host}${opts.path ?? "/*"}`);
37552
37744
  }
37553
37745
  /**
37554
37746
  * Download `url` through the BROWSER's own network stack via
@@ -37629,12 +37821,8 @@ var FetchproxyServer = class {
37629
37821
  const pending = new Promise((resolve, reject) => {
37630
37822
  this.pendingDownload.set(id, { resolve, reject });
37631
37823
  });
37632
- if (this.hostHandle) {
37633
- await this.hostHandle.sendOwnInner(inner);
37634
- } else if (this.peerHandle) {
37635
- await this.peerHandle.sendInner(inner);
37636
- }
37637
- return pending;
37824
+ await this.sendInnerFrame(inner);
37825
+ return this._withVerbTimeout(pending, this.pendingDownload, id, opts.url);
37638
37826
  }
37639
37827
  /**
37640
37828
  * 0.4.0+: read declared IndexedDB keys from the user's signed-in
@@ -37681,12 +37869,8 @@ var FetchproxyServer = class {
37681
37869
  const pending = new Promise((resolve, reject) => {
37682
37870
  this.pendingIdb.set(id, { resolve, reject });
37683
37871
  });
37684
- if (this.hostHandle) {
37685
- await this.hostHandle.sendOwnInner(inner);
37686
- } else if (this.peerHandle) {
37687
- await this.peerHandle.sendInner(inner);
37688
- }
37689
- return pending;
37872
+ await this.sendInnerFrame(inner);
37873
+ return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
37690
37874
  }
37691
37875
  assertScopeSubset(requested, declared, label) {
37692
37876
  const undeclared = undeclaredKeys(requested, declared);
@@ -38084,7 +38268,7 @@ var BootstrapDisabledError = class extends Error {
38084
38268
  // package.json
38085
38269
  var package_default = {
38086
38270
  name: "zola-mcp",
38087
- version: "1.4.2",
38271
+ version: "1.4.4",
38088
38272
  mcpName: "io.github.chrischall/zola-mcp",
38089
38273
  description: "Zola wedding MCP server for Claude",
38090
38274
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38130,7 +38314,7 @@ var package_default = {
38130
38314
  "test:watch": "vitest"
38131
38315
  },
38132
38316
  dependencies: {
38133
- "@chrischall/mcp-utils": "^0.7.0",
38317
+ "@chrischall/mcp-utils": "^0.12.0",
38134
38318
  "@fetchproxy/bootstrap": "^1.3.0",
38135
38319
  "@fetchproxy/server": "^1.3.0",
38136
38320
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -38138,7 +38322,7 @@ var package_default = {
38138
38322
  zod: "^4.4.3"
38139
38323
  },
38140
38324
  devDependencies: {
38141
- "@types/node": "^25.9.2",
38325
+ "@types/node": "^26.0.0",
38142
38326
  "@vitest/coverage-v8": "^4.1.8",
38143
38327
  esbuild: "^0.28.0",
38144
38328
  typescript: "^6.0.3",
@@ -38183,18 +38367,26 @@ async function resolveRefreshToken() {
38183
38367
  var __dirname = dirname(fileURLToPath(import.meta.url));
38184
38368
  await loadDotenvSafely({ path: join2(__dirname, "..", ".env"), override: false });
38185
38369
  var MOBILE_BASE_URL = "https://mobile-api.zola.com";
38370
+ var SESSION_REFRESH_BUFFER_MS = 5 * 60 * 1e3;
38186
38371
  var ZolaClient = class {
38187
- sessionToken = null;
38188
- sessionExpiry = null;
38189
38372
  cachedContext = null;
38190
38373
  // WAF requires x-zola-session-id on all mobile-api.zola.com requests
38191
38374
  deviceSessionId = crypto.randomUUID().toUpperCase();
38375
+ // The `ZOLA_SESSION_TOKEN` seed is a cold-start-only shortcut, exactly as the
38376
+ // old `ensureSession` gated it on `sessionToken === null`; a 401 re-mint goes
38377
+ // straight to `refresh()`, never back to the env token.
38378
+ triedEnvSeed = false;
38379
+ // Single-flight cached mint: caches the 30-min session JWT until 5 min before
38380
+ // its `exp`, coalesces concurrent mints, and re-mints on demand after a 401.
38381
+ session = createCachedTokenSource({
38382
+ mint: () => this.mintSession(),
38383
+ bufferMs: SESSION_REFRESH_BUFFER_MS
38384
+ });
38192
38385
  /**
38193
38386
  * Make a request to the Zola mobile API (mobile-api.zola.com).
38194
38387
  * Uses Bearer JWT auth with x-zola-session-id header.
38195
38388
  */
38196
38389
  async requestMobile(method, path, body) {
38197
- await this.ensureSession();
38198
38390
  return this.doRequest(method, path, body);
38199
38391
  }
38200
38392
  /**
@@ -38204,7 +38396,6 @@ var ZolaClient = class {
38204
38396
  * raw bytes and the server's content-type so callers can pass it through.
38205
38397
  */
38206
38398
  async requestMobileBinary(method, path, body) {
38207
- await this.ensureSession();
38208
38399
  const response = await this.sendWithRetry(method, path, body, false, false, "*/*");
38209
38400
  const contentType = response.headers.get("content-type") ?? "application/octet-stream";
38210
38401
  const bytes = new Uint8Array(await response.arrayBuffer());
@@ -38248,10 +38439,11 @@ var ZolaClient = class {
38248
38439
  return text ? JSON.parse(text) : null;
38249
38440
  }
38250
38441
  async sendWithRetry(method, path, body, isAuthRetry = false, isRateRetry = false, accept = "application/json") {
38251
- const sessionId = decodeJwtSessionId(this.sessionToken);
38442
+ const token = await this.session.getToken();
38443
+ const sessionId = decodeJwtSessionId(token);
38252
38444
  const headers = {
38253
38445
  accept,
38254
- authorization: `Bearer ${this.sessionToken}`,
38446
+ authorization: `Bearer ${token}`,
38255
38447
  "x-zola-platform-type": "iphone_app",
38256
38448
  "x-zola-session-id": this.deviceSessionId,
38257
38449
  "user-agent": "Zola/42.5.0 (iPad; iOS 26.4; Scale/2.0)",
@@ -38264,9 +38456,7 @@ var ZolaClient = class {
38264
38456
  ...body !== void 0 ? { body: JSON.stringify(body) } : {}
38265
38457
  });
38266
38458
  if (response.status === 401 && !isAuthRetry) {
38267
- this.sessionToken = null;
38268
- this.sessionExpiry = null;
38269
- await this.refresh();
38459
+ this.session.invalidate();
38270
38460
  return this.sendWithRetry(method, path, body, true, isRateRetry, accept);
38271
38461
  }
38272
38462
  if (response.status === 429) {
@@ -38284,30 +38474,33 @@ var ZolaClient = class {
38284
38474
  }
38285
38475
  return response;
38286
38476
  }
38287
- async ensureSession() {
38288
- if (this.sessionToken && this.sessionExpiry) {
38289
- if (this.sessionExpiry.getTime() - Date.now() > 5 * 60 * 1e3) return;
38290
- }
38291
- if (this.sessionToken === null) {
38477
+ /**
38478
+ * Mint a session token for the cache. On the very first mint, use a valid
38479
+ * `ZOLA_SESSION_TOKEN` if present (a cold-start shortcut that skips the
38480
+ * initial refresh); otherwise, and on every later mint, refresh via the
38481
+ * mobile API. Returns `{ token, expiresAt }` so the cache serves it until
38482
+ * `SESSION_REFRESH_BUFFER_MS` before the JWT's `exp`.
38483
+ */
38484
+ async mintSession() {
38485
+ if (!this.triedEnvSeed) {
38486
+ this.triedEnvSeed = true;
38292
38487
  const envSession = readEnvVar("ZOLA_SESSION_TOKEN");
38293
38488
  if (envSession) {
38294
38489
  try {
38295
38490
  const exp = decodeJwtExp(envSession);
38296
- if (exp * 1e3 - Date.now() > 5 * 60 * 1e3) {
38297
- this.sessionToken = envSession;
38298
- this.sessionExpiry = new Date(exp * 1e3);
38299
- return;
38491
+ if (exp * 1e3 - Date.now() > SESSION_REFRESH_BUFFER_MS) {
38492
+ return { token: envSession, expiresAt: exp * 1e3 };
38300
38493
  }
38301
38494
  } catch {
38302
38495
  }
38303
38496
  }
38304
38497
  }
38305
- await this.refresh();
38498
+ return this.refresh();
38306
38499
  }
38307
38500
  /**
38308
38501
  * Refresh the session using the mobile API endpoint.
38309
38502
  * POST /v3/sessions/refresh with the refresh token JWT.
38310
- * Returns a new session_token (30-min) and optionally a new refresh_token.
38503
+ * Returns a new session_token (30-min) as a {@link MintedToken}.
38311
38504
  *
38312
38505
  * The refresh token comes from `resolveRefreshToken()`, which tries
38313
38506
  * (1) ZOLA_REFRESH_TOKEN env var, then (2) the fetchproxy 0.3.0 extension's
@@ -38336,8 +38529,7 @@ To fix: set ZOLA_REFRESH_TOKEN, or install the fetchproxy extension and sign int
38336
38529
  const result = await response.json();
38337
38530
  const { session_token } = result.data;
38338
38531
  const exp = decodeJwtExp(session_token);
38339
- this.sessionToken = session_token;
38340
- this.sessionExpiry = new Date(exp * 1e3);
38532
+ return { token: session_token, expiresAt: exp * 1e3 };
38341
38533
  }
38342
38534
  };
38343
38535
  var client = new ZolaClient();
@@ -40303,7 +40495,7 @@ function registerEventInvitationTools(server) {
40303
40495
  }
40304
40496
 
40305
40497
  // src/index.ts
40306
- var VERSION = "1.4.2";
40498
+ var VERSION = "1.4.4";
40307
40499
  await runMcp({
40308
40500
  name: "zola-mcp",
40309
40501
  version: VERSION,
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { dirname, join } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
- import { readEnvVar, loadDotenvSafely, decodeJwtExp, decodeJwtSessionId, formatApiError, truncateErrorMessage, } from '@chrischall/mcp-utils';
3
+ import { readEnvVar, loadDotenvSafely, decodeJwtExp, decodeJwtSessionId, formatApiError, truncateErrorMessage, createCachedTokenSource, } from '@chrischall/mcp-utils';
4
4
  import { resolveRefreshToken } from './auth.js';
5
5
  // Load `.env` next to the compiled entry point. `loadDotenvSafely` is a
6
6
  // no-throw loader: in bundled mode (no resolvable `dotenv`) it returns false
@@ -8,18 +8,29 @@ import { resolveRefreshToken } from './auth.js';
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
10
10
  const MOBILE_BASE_URL = 'https://mobile-api.zola.com';
11
+ // Refresh the session token this long before its JWT `exp` — the same 5-minute
12
+ // comfort margin the hand-rolled cache used for both the "still valid" check and
13
+ // the `ZOLA_SESSION_TOKEN` seed's freshness gate.
14
+ const SESSION_REFRESH_BUFFER_MS = 5 * 60 * 1000;
11
15
  export class ZolaClient {
12
- sessionToken = null;
13
- sessionExpiry = null;
14
16
  cachedContext = null;
15
17
  // WAF requires x-zola-session-id on all mobile-api.zola.com requests
16
18
  deviceSessionId = crypto.randomUUID().toUpperCase();
19
+ // The `ZOLA_SESSION_TOKEN` seed is a cold-start-only shortcut, exactly as the
20
+ // old `ensureSession` gated it on `sessionToken === null`; a 401 re-mint goes
21
+ // straight to `refresh()`, never back to the env token.
22
+ triedEnvSeed = false;
23
+ // Single-flight cached mint: caches the 30-min session JWT until 5 min before
24
+ // its `exp`, coalesces concurrent mints, and re-mints on demand after a 401.
25
+ session = createCachedTokenSource({
26
+ mint: () => this.mintSession(),
27
+ bufferMs: SESSION_REFRESH_BUFFER_MS,
28
+ });
17
29
  /**
18
30
  * Make a request to the Zola mobile API (mobile-api.zola.com).
19
31
  * Uses Bearer JWT auth with x-zola-session-id header.
20
32
  */
21
33
  async requestMobile(method, path, body) {
22
- await this.ensureSession();
23
34
  return this.doRequest(method, path, body);
24
35
  }
25
36
  /**
@@ -29,7 +40,6 @@ export class ZolaClient {
29
40
  * raw bytes and the server's content-type so callers can pass it through.
30
41
  */
31
42
  async requestMobileBinary(method, path, body) {
32
- await this.ensureSession();
33
43
  const response = await this.sendWithRetry(method, path, body, false, false, '*/*');
34
44
  const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
35
45
  const bytes = new Uint8Array(await response.arrayBuffer());
@@ -75,10 +85,14 @@ export class ZolaClient {
75
85
  return (text ? JSON.parse(text) : null);
76
86
  }
77
87
  async sendWithRetry(method, path, body, isAuthRetry = false, isRateRetry = false, accept = 'application/json') {
78
- const sessionId = decodeJwtSessionId(this.sessionToken);
88
+ // Current session token from the cache (mints/refreshes as needed). The
89
+ // per-request `x-zola-user-session-id` is derived from it every call, so a
90
+ // re-minted token below carries its own session id on the replay.
91
+ const token = await this.session.getToken();
92
+ const sessionId = decodeJwtSessionId(token);
79
93
  const headers = {
80
94
  accept,
81
- authorization: `Bearer ${this.sessionToken}`,
95
+ authorization: `Bearer ${token}`,
82
96
  'x-zola-platform-type': 'iphone_app',
83
97
  'x-zola-session-id': this.deviceSessionId,
84
98
  'user-agent': 'Zola/42.5.0 (iPad; iOS 26.4; Scale/2.0)',
@@ -92,9 +106,8 @@ export class ZolaClient {
92
106
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
93
107
  });
94
108
  if (response.status === 401 && !isAuthRetry) {
95
- this.sessionToken = null;
96
- this.sessionExpiry = null;
97
- await this.refresh();
109
+ // Drop the cached token so the replay's getToken re-mints via refresh().
110
+ this.session.invalidate();
98
111
  return this.sendWithRetry(method, path, body, true, isRateRetry, accept);
99
112
  }
100
113
  if (response.status === 429) {
@@ -110,22 +123,24 @@ export class ZolaClient {
110
123
  }
111
124
  return response;
112
125
  }
113
- async ensureSession() {
114
- // Session still valid with comfortable margin
115
- if (this.sessionToken && this.sessionExpiry) {
116
- if (this.sessionExpiry.getTime() - Date.now() > 5 * 60 * 1000)
117
- return;
118
- }
119
- // Check for session token in env (first load only)
120
- if (this.sessionToken === null) {
126
+ /**
127
+ * Mint a session token for the cache. On the very first mint, use a valid
128
+ * `ZOLA_SESSION_TOKEN` if present (a cold-start shortcut that skips the
129
+ * initial refresh); otherwise, and on every later mint, refresh via the
130
+ * mobile API. Returns `{ token, expiresAt }` so the cache serves it until
131
+ * `SESSION_REFRESH_BUFFER_MS` before the JWT's `exp`.
132
+ */
133
+ async mintSession() {
134
+ // Cold-start seed: use ZOLA_SESSION_TOKEN if present and comfortably
135
+ // unexpired. Only attempted once — a 401 re-mint always goes to refresh().
136
+ if (!this.triedEnvSeed) {
137
+ this.triedEnvSeed = true;
121
138
  const envSession = readEnvVar('ZOLA_SESSION_TOKEN');
122
139
  if (envSession) {
123
140
  try {
124
141
  const exp = decodeJwtExp(envSession);
125
- if (exp * 1000 - Date.now() > 5 * 60 * 1000) {
126
- this.sessionToken = envSession;
127
- this.sessionExpiry = new Date(exp * 1000);
128
- return;
142
+ if (exp * 1000 - Date.now() > SESSION_REFRESH_BUFFER_MS) {
143
+ return { token: envSession, expiresAt: exp * 1000 };
129
144
  }
130
145
  }
131
146
  catch {
@@ -133,12 +148,12 @@ export class ZolaClient {
133
148
  }
134
149
  }
135
150
  }
136
- await this.refresh();
151
+ return this.refresh();
137
152
  }
138
153
  /**
139
154
  * Refresh the session using the mobile API endpoint.
140
155
  * POST /v3/sessions/refresh with the refresh token JWT.
141
- * Returns a new session_token (30-min) and optionally a new refresh_token.
156
+ * Returns a new session_token (30-min) as a {@link MintedToken}.
142
157
  *
143
158
  * The refresh token comes from `resolveRefreshToken()`, which tries
144
159
  * (1) ZOLA_REFRESH_TOKEN env var, then (2) the fetchproxy 0.3.0 extension's
@@ -168,8 +183,7 @@ export class ZolaClient {
168
183
  const result = (await response.json());
169
184
  const { session_token } = result.data;
170
185
  const exp = decodeJwtExp(session_token);
171
- this.sessionToken = session_token;
172
- this.sessionExpiry = new Date(exp * 1000);
186
+ return { token: session_token, expiresAt: exp * 1000 };
173
187
  }
174
188
  }
175
189
  export const client = new ZolaClient();
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { registerWebsiteThemeTools } from './tools/website-theme.js';
12
12
  import { registerRegistryItemTools } from './tools/registry-items.js';
13
13
  import { registerInvitationTools } from './tools/invitations.js';
14
14
  import { registerEventInvitationTools } from './tools/event-invitations.js';
15
- const VERSION = '1.4.2'; // x-release-please-version
15
+ const VERSION = '1.4.4'; // x-release-please-version
16
16
  await runMcp({
17
17
  name: 'zola-mcp',
18
18
  version: VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zola-mcp",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "mcpName": "io.github.chrischall/zola-mcp",
5
5
  "description": "Zola wedding MCP server for Claude",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -46,7 +46,7 @@
46
46
  "test:watch": "vitest"
47
47
  },
48
48
  "dependencies": {
49
- "@chrischall/mcp-utils": "^0.7.0",
49
+ "@chrischall/mcp-utils": "^0.12.0",
50
50
  "@fetchproxy/bootstrap": "^1.3.0",
51
51
  "@fetchproxy/server": "^1.3.0",
52
52
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -54,7 +54,7 @@
54
54
  "zod": "^4.4.3"
55
55
  },
56
56
  "devDependencies": {
57
- "@types/node": "^25.9.2",
57
+ "@types/node": "^26.0.0",
58
58
  "@vitest/coverage-v8": "^4.1.8",
59
59
  "esbuild": "^0.28.0",
60
60
  "typescript": "^6.0.3",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/zola-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.4.2",
9
+ "version": "1.4.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "zola-mcp",
14
- "version": "1.4.2",
14
+ "version": "1.4.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },