zola-mcp 1.2.3 → 1.3.1

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.2.3"
10
+ "version": "1.3.1"
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.2.3",
18
+ "version": "1.3.1",
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.2.3",
4
+ "version": "1.3.1",
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/auth.js CHANGED
@@ -45,6 +45,7 @@
45
45
  // returns the JWT plus the source — callers (the client) treat the
46
46
  // return value as opaque credentials.
47
47
  import { bootstrap } from '@fetchproxy/bootstrap';
48
+ import { classifyBridgeError } from '@fetchproxy/server';
48
49
  import pkg from '../package.json' with { type: 'json' };
49
50
  /**
50
51
  * Read an env var, trim, and treat blank / `${UNEXPANDED}` placeholders as
@@ -113,6 +114,18 @@ export async function resolveRefreshToken() {
113
114
  return { token, source: 'fetchproxy' };
114
115
  }
115
116
  catch (e) {
117
+ // 0.8.0+ typed-error discrimination. The fetchproxy server already
118
+ // retries once on SW eviction (bridgeReviveDelayMs=2000 default), so
119
+ // a thrown FetchproxyBridgeDownError means the retry also failed —
120
+ // the extension's service worker is genuinely down and the user
121
+ // needs to wake it. The `.hint` is the actionable copy
122
+ // ("click the extension toolbar icon...") that we'd otherwise have
123
+ // to hand-write here. Surface it verbatim so users in path 2 get
124
+ // the same self-service guidance as path 3.
125
+ if (classifyBridgeError(e) === 'bridge_down') {
126
+ const downErr = e;
127
+ throw new Error(`Zola auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`);
128
+ }
116
129
  const msg = e instanceof Error ? e.message : String(e);
117
130
  throw new Error(`Zola auth: no ZOLA_REFRESH_TOKEN set, and fetchproxy fallback failed: ${msg}`);
118
131
  }
package/dist/bundle.js CHANGED
@@ -35178,6 +35178,52 @@ var FetchproxyHttpError = class extends Error {
35178
35178
  this.name = "FetchproxyHttpError";
35179
35179
  }
35180
35180
  };
35181
+ var FetchproxyBridgeDownError = class extends FetchproxyProtocolError {
35182
+ originalError;
35183
+ retryAttempted;
35184
+ op;
35185
+ url;
35186
+ /** 0.8.0+: bridge role at throw time; `null` if listen() hadn't bound yet. */
35187
+ role;
35188
+ /** 0.8.0+: bridge port at throw time (the same port `listen()` bound to). */
35189
+ port;
35190
+ hint;
35191
+ constructor(args) {
35192
+ const retryAttempted = args.retryAttempted ?? false;
35193
+ const op = args.op ?? "fetch";
35194
+ 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). `;
35195
+ 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.`;
35196
+ super(`fetchproxy bridge down during ${op}${args.url ? ` (${args.url})` : ""}. ${hint}`);
35197
+ this.name = "FetchproxyBridgeDownError";
35198
+ this.originalError = args.originalError;
35199
+ this.retryAttempted = retryAttempted;
35200
+ this.op = op;
35201
+ if (args.url !== void 0)
35202
+ this.url = args.url;
35203
+ this.role = args.role ?? null;
35204
+ this.port = args.port ?? 0;
35205
+ this.hint = hint;
35206
+ }
35207
+ };
35208
+ var FetchproxyTimeoutError = class extends FetchproxyProtocolError {
35209
+ url;
35210
+ timeoutMs;
35211
+ /** 0.8.0+: bridge role at throw time; `null` if listen() hadn't bound yet. */
35212
+ role;
35213
+ /** 0.8.0+: bridge port at throw time. */
35214
+ port;
35215
+ /** 0.8.0+: actual elapsed milliseconds when the timer won the race. */
35216
+ elapsedMs;
35217
+ constructor(args) {
35218
+ super(`fetchproxy: ${args.url} did not respond within ${args.timeoutMs}ms`);
35219
+ this.name = "FetchproxyTimeoutError";
35220
+ this.url = args.url;
35221
+ this.timeoutMs = args.timeoutMs;
35222
+ this.role = args.role ?? null;
35223
+ this.port = args.port ?? 0;
35224
+ this.elapsedMs = args.elapsedMs ?? args.timeoutMs;
35225
+ }
35226
+ };
35181
35227
  var SUBDOMAIN_LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
35182
35228
  function assertSubdomainLabel(label) {
35183
35229
  if (!SUBDOMAIN_LABEL_RE.test(label)) {
@@ -35215,6 +35261,18 @@ var FetchproxyServer = class {
35215
35261
  hostHandle = null;
35216
35262
  peerHandle = null;
35217
35263
  nextRequestId = 1;
35264
+ // 0.8.0+: process-wide freshness counters surfaced via bridgeHealth().
35265
+ // Replaces the local copies every downstream MCP was rolling on top
35266
+ // of its own transport adapter — see realty-mcp cohort drift notes.
35267
+ // Updated by recordSuccess / recordFailure from fetch + capture paths.
35268
+ // `lastExtensionMessageAt` (#23 ask 4) is updated whenever any inner
35269
+ // frame from the extension arrives — gives extension-side liveness
35270
+ // distinct from per-call success/failure.
35271
+ lastSuccessAt = null;
35272
+ lastFailureAt = null;
35273
+ lastFailureReason = null;
35274
+ consecutiveFailures = 0;
35275
+ lastExtensionMessageAt = null;
35218
35276
  pending = /* @__PURE__ */ new Map();
35219
35277
  // Separate pending map for read_cookies so the response shape (cookies
35220
35278
  // string vs status/body) doesn't have to share a union type with fetch.
@@ -35283,6 +35341,12 @@ var FetchproxyServer = class {
35283
35341
  key: d.key,
35284
35342
  jsonPointer: d.jsonPointer
35285
35343
  })),
35344
+ // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
35345
+ // adapter was about to set these to the same numbers anyway; the
35346
+ // back-door is `0` (explicit opt-out) if a caller genuinely wants
35347
+ // the legacy hang-forever / fail-once-on-SW-eviction behavior.
35348
+ fetchTimeoutMs: opts.fetchTimeoutMs ?? 3e4,
35349
+ bridgeReviveDelayMs: opts.bridgeReviveDelayMs ?? 2e3,
35286
35350
  identityDir: opts.identityDir,
35287
35351
  onPairCode: opts.onPairCode
35288
35352
  };
@@ -35418,7 +35482,7 @@ var FetchproxyServer = class {
35418
35482
  }
35419
35483
  }
35420
35484
  pairingErrorMessage(code) {
35421
- return `fetchproxy: pairing required for ${this.opts.serverName} \u2014 open the fetchproxy extension popup in Chrome and approve the pair request. Verify the pair code matches: ${code}`;
35485
+ 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.`;
35422
35486
  }
35423
35487
  /**
35424
35488
  * Raw single-shot fetch through the bridge. Most callers should prefer
@@ -35439,8 +35503,71 @@ var FetchproxyServer = class {
35439
35503
  const pendingCode = this.currentPendingPairCode();
35440
35504
  if (pendingCode !== null) {
35441
35505
  const error48 = this.pairingErrorMessage(pendingCode);
35442
- return { ok: false, error: error48, kind: classifyFetchError(error48) };
35506
+ return {
35507
+ ok: false,
35508
+ error: error48,
35509
+ kind: classifyFetchError(error48),
35510
+ retryAttempted: false
35511
+ };
35443
35512
  }
35513
+ const first = await this._fetchOnceWithTimeout(init);
35514
+ const reviveMs = this.opts.bridgeReviveDelayMs;
35515
+ let final = first;
35516
+ if (!first.ok && first.kind === "content_script_unreachable" && reviveMs !== void 0 && reviveMs > 0) {
35517
+ await new Promise((r) => setTimeout(r, reviveMs));
35518
+ const second = await this._fetchOnceWithTimeout(init);
35519
+ if (second.ok)
35520
+ this.recordSuccess();
35521
+ else
35522
+ this.recordFailure(`${second.kind ?? "other"}: ${second.error}`);
35523
+ return { ...second, retryAttempted: true };
35524
+ }
35525
+ if (first.ok)
35526
+ this.recordSuccess();
35527
+ else
35528
+ this.recordFailure(`${first.kind ?? "other"}: ${first.error}`);
35529
+ return { ...first, retryAttempted: false };
35530
+ }
35531
+ /**
35532
+ * 0.8.0+: snapshot of the bridge's process-wide freshness counters,
35533
+ * suitable for surfacing through a downstream MCP's healthcheck tool.
35534
+ * Counters reset on a success (consecutiveFailures), accumulate
35535
+ * across the process lifetime otherwise. Replaces the per-MCP
35536
+ * duplication the realty cohort had been rolling in their adapters.
35537
+ * `lastExtensionMessageAt` is updated whenever ANY inner frame
35538
+ * arrives from the extension — gives extension-side liveness
35539
+ * distinct from server-side success/failure of the user-visible
35540
+ * call (addresses #23 ask 4).
35541
+ */
35542
+ bridgeHealth() {
35543
+ return {
35544
+ role: this.role,
35545
+ port: this.opts.port,
35546
+ serverVersion: this.opts.version,
35547
+ fetchTimeoutMs: this.opts.fetchTimeoutMs ?? 0,
35548
+ bridgeReviveDelayMs: this.opts.bridgeReviveDelayMs ?? 0,
35549
+ lastSuccessAt: this.lastSuccessAt,
35550
+ lastFailureAt: this.lastFailureAt,
35551
+ lastFailureReason: this.lastFailureReason,
35552
+ consecutiveFailures: this.consecutiveFailures,
35553
+ lastExtensionMessageAt: this.lastExtensionMessageAt
35554
+ };
35555
+ }
35556
+ recordSuccess() {
35557
+ this.lastSuccessAt = Date.now();
35558
+ this.consecutiveFailures = 0;
35559
+ }
35560
+ recordFailure(reason) {
35561
+ this.lastFailureAt = Date.now();
35562
+ this.lastFailureReason = reason;
35563
+ this.consecutiveFailures += 1;
35564
+ }
35565
+ /**
35566
+ * Single bridge round-trip, wrapped by `fetchTimeoutMs` when set.
35567
+ * On timeout returns the `{ok:false, kind:'timeout'}` envelope —
35568
+ * the throwing surface is the convenience methods.
35569
+ */
35570
+ async _fetchOnceWithTimeout(init) {
35444
35571
  const id = this.nextRequestId++;
35445
35572
  const inner = { type: "request", id, op: "fetch", init };
35446
35573
  const pending = new Promise((resolve) => {
@@ -35451,7 +35578,61 @@ var FetchproxyServer = class {
35451
35578
  } else if (this.peerHandle) {
35452
35579
  await this.peerHandle.sendInner(inner);
35453
35580
  }
35454
- return pending;
35581
+ const timeoutMs = this.opts.fetchTimeoutMs;
35582
+ if (timeoutMs === void 0 || timeoutMs <= 0)
35583
+ return pending;
35584
+ let timer;
35585
+ const start = Date.now();
35586
+ try {
35587
+ return await Promise.race([
35588
+ pending,
35589
+ new Promise((resolve) => {
35590
+ timer = setTimeout(() => {
35591
+ this.pending.delete(id);
35592
+ const elapsedMs = Date.now() - start;
35593
+ const error48 = `fetchproxy: ${init.url} did not respond within ${timeoutMs}ms`;
35594
+ resolve({
35595
+ ok: false,
35596
+ error: error48,
35597
+ kind: "timeout",
35598
+ retryAttempted: false,
35599
+ elapsedMs
35600
+ });
35601
+ }, timeoutMs);
35602
+ })
35603
+ ]);
35604
+ } finally {
35605
+ if (timer)
35606
+ clearTimeout(timer);
35607
+ }
35608
+ }
35609
+ /**
35610
+ * Map an `ok:false` fetch result to its typed throwable. Centralizes
35611
+ * the kind-to-error-class switch so `request()` and (via the same
35612
+ * logic re-implemented inline) `captureRequestHeader()` agree on what
35613
+ * to throw.
35614
+ */
35615
+ _typedErrorFor(result, url2, op, retryAttempted) {
35616
+ if (result.kind === "timeout") {
35617
+ return new FetchproxyTimeoutError({
35618
+ url: url2,
35619
+ timeoutMs: this.opts.fetchTimeoutMs ?? 0,
35620
+ role: this.role,
35621
+ port: this.opts.port,
35622
+ elapsedMs: result.elapsedMs
35623
+ });
35624
+ }
35625
+ if (result.kind === "content_script_unreachable") {
35626
+ return new FetchproxyBridgeDownError({
35627
+ originalError: result.error,
35628
+ retryAttempted,
35629
+ op,
35630
+ url: url2,
35631
+ role: this.role,
35632
+ port: this.opts.port
35633
+ });
35634
+ }
35635
+ return new FetchproxyProtocolError(result.error);
35455
35636
  }
35456
35637
  /**
35457
35638
  * Convenience wrapper around `fetch()`. Builds the URL from a path
@@ -35474,8 +35655,18 @@ var FetchproxyServer = class {
35474
35655
  if (opts.subdomain !== void 0)
35475
35656
  assertSubdomainLabel(opts.subdomain);
35476
35657
  const baseDomain = this.resolveBaseDomain(opts.domain);
35477
- const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
35478
- const url2 = path.startsWith("http://") || path.startsWith("https://") ? path : `https://${host}${path}`;
35658
+ const isAbsolute = path.startsWith("http://") || path.startsWith("https://");
35659
+ let host;
35660
+ if (isAbsolute) {
35661
+ try {
35662
+ host = new URL(path).host;
35663
+ } catch {
35664
+ throw new Error(`FetchproxyServer.request: absolute path is not a valid URL: ${JSON.stringify(path)}`);
35665
+ }
35666
+ } else {
35667
+ host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
35668
+ }
35669
+ const url2 = isAbsolute ? path : `https://${host}${path}`;
35479
35670
  assertUrlInDomains("request url", url2, this.opts.domains);
35480
35671
  const init = {
35481
35672
  url: url2,
@@ -35486,7 +35677,7 @@ var FetchproxyServer = class {
35486
35677
  };
35487
35678
  const result = await this.fetch(init);
35488
35679
  if (!result.ok) {
35489
- throw new FetchproxyProtocolError(result.error);
35680
+ throw this._typedErrorFor(result, init.url, "fetch", result.retryAttempted ?? false);
35490
35681
  }
35491
35682
  const response = {
35492
35683
  status: result.status,
@@ -35696,10 +35887,73 @@ var FetchproxyServer = class {
35696
35887
  }
35697
35888
  await this.ensureConnected();
35698
35889
  this.throwIfPendingPair();
35699
- const declared = this.opts.captureHeaders.find((d) => d.urlPattern === opts.urlPattern && d.headerName === opts.headerName);
35700
- if (!declared) {
35701
- throw new Error(`FetchproxyServer.captureRequestHeader: (urlPattern=${JSON.stringify(opts.urlPattern)}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
35890
+ const decls = this.opts.captureHeaders;
35891
+ let resolved;
35892
+ if (opts?.urlPattern !== void 0 && opts?.headerName !== void 0) {
35893
+ const found = decls.find((d) => d.urlPattern === opts.urlPattern && d.headerName === opts.headerName);
35894
+ if (!found) {
35895
+ throw new Error(`FetchproxyServer.captureRequestHeader: (urlPattern=${JSON.stringify(opts.urlPattern)}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
35896
+ }
35897
+ resolved = found;
35898
+ } else if (opts?.urlPattern === void 0 && opts?.headerName === void 0) {
35899
+ if (decls.length === 0) {
35900
+ 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");
35901
+ }
35902
+ if (decls.length > 1) {
35903
+ const list = decls.map((d) => `${JSON.stringify(d.urlPattern)}/${JSON.stringify(d.headerName)}`).join(", ");
35904
+ throw new Error(`FetchproxyServer.captureRequestHeader: multiple captureHeaders declared (${decls.length}: ${list}); pass {urlPattern, headerName} to disambiguate`);
35905
+ }
35906
+ resolved = decls[0];
35907
+ } else {
35908
+ throw new Error("FetchproxyServer.captureRequestHeader: pass both urlPattern AND headerName, or neither (which defaults to the single declared entry)");
35909
+ }
35910
+ const callOpts = { ...resolved, ...opts?.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {} };
35911
+ try {
35912
+ const result = await this._captureRequestHeaderOnce(callOpts);
35913
+ this.recordSuccess();
35914
+ return result;
35915
+ } catch (err) {
35916
+ const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
35917
+ if (!swDown) {
35918
+ this.recordFailure(`capture_request_header: ${err.message ?? String(err)}`);
35919
+ throw err;
35920
+ }
35921
+ const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
35922
+ if (reviveMs > 0) {
35923
+ await new Promise((r) => setTimeout(r, reviveMs));
35924
+ try {
35925
+ const result = await this._captureRequestHeaderOnce(callOpts);
35926
+ this.recordSuccess();
35927
+ return result;
35928
+ } catch (retryErr) {
35929
+ const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
35930
+ if (!stillDown) {
35931
+ this.recordFailure(`capture_request_header: ${retryErr.message ?? String(retryErr)}`);
35932
+ throw retryErr;
35933
+ }
35934
+ this.recordFailure(`capture_request_header bridge-down: ${retryErr.message}`);
35935
+ throw new FetchproxyBridgeDownError({
35936
+ originalError: retryErr.message,
35937
+ retryAttempted: true,
35938
+ op: "capture_request_header",
35939
+ url: resolved.urlPattern,
35940
+ role: this.role,
35941
+ port: this.opts.port
35942
+ });
35943
+ }
35944
+ }
35945
+ this.recordFailure(`capture_request_header bridge-down: ${err.message}`);
35946
+ throw new FetchproxyBridgeDownError({
35947
+ originalError: err.message,
35948
+ retryAttempted: false,
35949
+ op: "capture_request_header",
35950
+ url: resolved.urlPattern,
35951
+ role: this.role,
35952
+ port: this.opts.port
35953
+ });
35702
35954
  }
35955
+ }
35956
+ async _captureRequestHeaderOnce(opts) {
35703
35957
  const id = this.nextRequestId++;
35704
35958
  const inner = {
35705
35959
  type: "request",
@@ -35808,18 +36062,35 @@ var FetchproxyServer = class {
35808
36062
  onInner(inner) {
35809
36063
  if (inner.type !== "response")
35810
36064
  return;
36065
+ this.lastExtensionMessageAt = Date.now();
35811
36066
  const fetchCb = this.pending.get(inner.id);
35812
36067
  if (fetchCb) {
35813
36068
  this.pending.delete(inner.id);
35814
36069
  if (inner.ok) {
35815
36070
  if (inner.op === void 0 || inner.op === "fetch") {
35816
- fetchCb({ ok: true, status: inner.status, url: inner.url, body: inner.body });
36071
+ fetchCb({
36072
+ ok: true,
36073
+ status: inner.status,
36074
+ url: inner.url,
36075
+ body: inner.body,
36076
+ retryAttempted: false
36077
+ });
35817
36078
  } else {
35818
36079
  const error48 = `unexpected ${inner.op} response on fetch awaiter`;
35819
- fetchCb({ ok: false, error: error48, kind: classifyFetchError(error48) });
36080
+ fetchCb({
36081
+ ok: false,
36082
+ error: error48,
36083
+ kind: classifyFetchError(error48),
36084
+ retryAttempted: false
36085
+ });
35820
36086
  }
35821
36087
  } else {
35822
- fetchCb({ ok: false, error: inner.error, kind: classifyFetchError(inner.error) });
36088
+ fetchCb({
36089
+ ok: false,
36090
+ error: inner.error,
36091
+ kind: classifyFetchError(inner.error),
36092
+ retryAttempted: false
36093
+ });
35823
36094
  }
35824
36095
  return;
35825
36096
  }
@@ -35889,7 +36160,12 @@ var FetchproxyServer = class {
35889
36160
  rejectAllPending(reason = "extension disconnected") {
35890
36161
  const err = new FetchproxyProtocolError(reason);
35891
36162
  for (const cb of this.pending.values()) {
35892
- cb({ ok: false, error: err.message, kind: classifyFetchError(err.message) });
36163
+ cb({
36164
+ ok: false,
36165
+ error: err.message,
36166
+ kind: classifyFetchError(err.message),
36167
+ retryAttempted: false
36168
+ });
35893
36169
  }
35894
36170
  this.pending.clear();
35895
36171
  for (const cb of this.pendingReadCookies.values()) {
@@ -35954,6 +36230,19 @@ var FetchproxyServer = class {
35954
36230
  }
35955
36231
  };
35956
36232
 
36233
+ // node_modules/@fetchproxy/server/dist/classify-bridge-error.js
36234
+ function classifyBridgeError(err) {
36235
+ if (err instanceof FetchproxyTimeoutError)
36236
+ return "timeout";
36237
+ if (err instanceof FetchproxyBridgeDownError)
36238
+ return "bridge_down";
36239
+ if (err instanceof FetchproxyHttpError)
36240
+ return "http";
36241
+ if (err instanceof FetchproxyProtocolError)
36242
+ return "protocol";
36243
+ return "other";
36244
+ }
36245
+
35957
36246
  // node_modules/@fetchproxy/bootstrap/dist/index.js
35958
36247
  var defaultFactory = (opts) => new FetchproxyServer(opts);
35959
36248
  async function bootstrap(opts) {
@@ -36008,7 +36297,11 @@ async function bootstrap(opts) {
36008
36297
  key: p.storageKey,
36009
36298
  jsonPointer: p.jsonPointer
36010
36299
  })),
36011
- onPairCode: opts.onPairCode
36300
+ onPairCode: opts.onPairCode,
36301
+ // 0.8.0+ pass-through. Only forwarded when the caller set them;
36302
+ // unset → server defaults apply (30000 / 2000 in 0.8.0).
36303
+ ...opts.fetchTimeoutMs !== void 0 ? { fetchTimeoutMs: opts.fetchTimeoutMs } : {},
36304
+ ...opts.bridgeReviveDelayMs !== void 0 ? { bridgeReviveDelayMs: opts.bridgeReviveDelayMs } : {}
36012
36305
  });
36013
36306
  const storageDomainOpts = {};
36014
36307
  if (opts.storageDomain !== void 0)
@@ -36111,7 +36404,7 @@ var BootstrapDisabledError = class extends Error {
36111
36404
  // package.json
36112
36405
  var package_default = {
36113
36406
  name: "zola-mcp",
36114
- version: "1.2.3",
36407
+ version: "1.3.1",
36115
36408
  mcpName: "io.github.chrischall/zola-mcp",
36116
36409
  description: "Zola wedding MCP server for Claude",
36117
36410
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -36157,7 +36450,8 @@ var package_default = {
36157
36450
  "test:watch": "vitest"
36158
36451
  },
36159
36452
  dependencies: {
36160
- "@fetchproxy/bootstrap": "^0.6.0",
36453
+ "@fetchproxy/bootstrap": "^0.8.0",
36454
+ "@fetchproxy/server": "^0.8.0",
36161
36455
  "@modelcontextprotocol/sdk": "^1.29.0",
36162
36456
  dotenv: "^17.4.2"
36163
36457
  },
@@ -36217,6 +36511,12 @@ async function resolveRefreshToken() {
36217
36511
  }
36218
36512
  return { token, source: "fetchproxy" };
36219
36513
  } catch (e) {
36514
+ if (classifyBridgeError(e) === "bridge_down") {
36515
+ const downErr = e;
36516
+ throw new Error(
36517
+ `Zola auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
36518
+ );
36519
+ }
36220
36520
  const msg = e instanceof Error ? e.message : String(e);
36221
36521
  throw new Error(
36222
36522
  `Zola auth: no ZOLA_REFRESH_TOKEN set, and fetchproxy fallback failed: ${msg}`
@@ -36280,6 +36580,19 @@ var ZolaClient = class {
36280
36580
  await this.ensureSession();
36281
36581
  return this.doRequest(method, path, body);
36282
36582
  }
36583
+ /**
36584
+ * Like `requestMobile` but for endpoints that return a non-JSON body
36585
+ * (e.g. the QR-code preview which returns image bytes).
36586
+ * Sends `Accept: *\/*` (the JSON default would 406), and returns both the
36587
+ * raw bytes and the server's content-type so callers can pass it through.
36588
+ */
36589
+ async requestMobileBinary(method, path, body) {
36590
+ await this.ensureSession();
36591
+ const response = await this.sendWithRetry(method, path, body, false, false, "*/*");
36592
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
36593
+ const bytes = new Uint8Array(await response.arrayBuffer());
36594
+ return { bytes, contentType };
36595
+ }
36283
36596
  /**
36284
36597
  * Get user context (wedding account ID, registry ID, etc.).
36285
36598
  * Uses env vars as overrides; falls back to GET /v3/users/me/context.
@@ -36312,10 +36625,15 @@ var ZolaClient = class {
36312
36625
  };
36313
36626
  return this.cachedContext;
36314
36627
  }
36315
- async doRequest(method, path, body, isAuthRetry = false, isRateRetry = false) {
36628
+ async doRequest(method, path, body) {
36629
+ const response = await this.sendWithRetry(method, path, body);
36630
+ const text = await response.text();
36631
+ return text ? JSON.parse(text) : null;
36632
+ }
36633
+ async sendWithRetry(method, path, body, isAuthRetry = false, isRateRetry = false, accept = "application/json") {
36316
36634
  const sessionId = decodeJwtSessionId(this.sessionToken);
36317
36635
  const headers = {
36318
- accept: "application/json",
36636
+ accept,
36319
36637
  authorization: `Bearer ${this.sessionToken}`,
36320
36638
  "x-zola-platform-type": "iphone_app",
36321
36639
  "x-zola-session-id": this.deviceSessionId,
@@ -36332,21 +36650,20 @@ var ZolaClient = class {
36332
36650
  this.sessionToken = null;
36333
36651
  this.sessionExpiry = null;
36334
36652
  await this.refresh();
36335
- return this.doRequest(method, path, body, true, isRateRetry);
36653
+ return this.sendWithRetry(method, path, body, true, isRateRetry, accept);
36336
36654
  }
36337
36655
  if (response.status === 429) {
36338
36656
  if (!isRateRetry) {
36339
36657
  await new Promise((r) => setTimeout(r, 2e3));
36340
- return this.doRequest(method, path, body, isAuthRetry, true);
36658
+ return this.sendWithRetry(method, path, body, isAuthRetry, true, accept);
36341
36659
  }
36342
36660
  throw new Error("Rate limited by Zola API");
36343
36661
  }
36344
36662
  if (!response.ok) {
36345
- const text2 = await response.text();
36346
- throw new Error(`Zola API error: ${response.status} ${response.statusText} for ${method} ${path}: ${text2}`);
36663
+ const text = await response.text();
36664
+ throw new Error(`Zola API error: ${response.status} ${response.statusText} for ${method} ${path}: ${text}`);
36347
36665
  }
36348
- const text = await response.text();
36349
- return text ? JSON.parse(text) : null;
36666
+ return response;
36350
36667
  }
36351
36668
  async ensureSession() {
36352
36669
  if (this.sessionToken && this.sessionExpiry) {
@@ -36410,6 +36727,9 @@ var client = new ZolaClient();
36410
36727
  function jsonResult(data) {
36411
36728
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
36412
36729
  }
36730
+ function imageResult(bytes, mimeType) {
36731
+ return { content: [{ type: "image", data: Buffer.from(bytes).toString("base64"), mimeType }] };
36732
+ }
36413
36733
  function pickDefined(base, args, keys) {
36414
36734
  const body = { ...base };
36415
36735
  for (const key of keys) {
@@ -37905,10 +38225,303 @@ function registerRegistryItemTools(server2) {
37905
38225
  }, removeRegistryItem);
37906
38226
  }
37907
38227
 
38228
+ // src/tools/invitations.ts
38229
+ async function listCardProjects(args) {
38230
+ const response = await client.requestMobile(
38231
+ "POST",
38232
+ "/v3/card-projects/search_request",
38233
+ {
38234
+ completed: args.include_completed ?? false,
38235
+ limit: args.limit ?? 30,
38236
+ card_suite_uuids: [],
38237
+ card_suite_ids: [],
38238
+ offset: 0,
38239
+ fetch_customizations: true,
38240
+ include_deleted: false,
38241
+ medium: ["PAPER", "MAGNET", "DIGITAL"],
38242
+ single_sample: false
38243
+ }
38244
+ );
38245
+ return jsonResult(response.data);
38246
+ }
38247
+ async function getCardProject(args) {
38248
+ const response = await client.requestMobile(
38249
+ "GET",
38250
+ `/v3/card-projects/${args.project_uuid}`
38251
+ );
38252
+ return jsonResult(response.data);
38253
+ }
38254
+ async function validateCardProject(args) {
38255
+ const response = await client.requestMobile(
38256
+ "GET",
38257
+ `/v3/card-projects/${args.project_uuid}/validate`
38258
+ );
38259
+ return jsonResult(response.data);
38260
+ }
38261
+ async function getCardProjectGuests(args) {
38262
+ const response = await client.requestMobile(
38263
+ "GET",
38264
+ `/v3/card-projects/${args.project_uuid}/project-guest-groups`
38265
+ );
38266
+ return jsonResult(response.data);
38267
+ }
38268
+ async function getCardSuite(args) {
38269
+ const response = await client.requestMobile(
38270
+ "POST",
38271
+ `/v4/card-catalog/suites/details/${args.suite_uuid}`
38272
+ );
38273
+ return jsonResult(response.data);
38274
+ }
38275
+ async function searchCardCatalog(args) {
38276
+ const response = await client.requestMobile(
38277
+ "POST",
38278
+ "/v3/card-catalog/search/faceted",
38279
+ {
38280
+ lead_card_types: [],
38281
+ include_updated_proof_module: true,
38282
+ limit: args.limit ?? 50,
38283
+ offset: 0,
38284
+ digital_suite: false,
38285
+ include_lead_card_type_metadata: true,
38286
+ lead_card_type: args.card_type ?? "INVITATION",
38287
+ include_module: true
38288
+ }
38289
+ );
38290
+ return jsonResult(response.data);
38291
+ }
38292
+ async function listFavoriteCardSuites() {
38293
+ const response = await client.requestMobile(
38294
+ "GET",
38295
+ "/v3/favorites/card-suites/"
38296
+ );
38297
+ return jsonResult(response.data);
38298
+ }
38299
+ async function getRsvpPage() {
38300
+ const { weddingAccountId } = await client.getContext();
38301
+ const response = await client.requestMobile(
38302
+ "GET",
38303
+ `/v3/websites/rsvps/wedding-accounts/${weddingAccountId}`
38304
+ );
38305
+ return jsonResult(response.data);
38306
+ }
38307
+ async function createCardProject(args) {
38308
+ const { weddingAccountId } = await client.getContext();
38309
+ const response = await client.requestMobile(
38310
+ "POST",
38311
+ "/v3/card-projects",
38312
+ {
38313
+ quantity: args.quantity ?? 150,
38314
+ lead_variation_uuid: args.lead_variation_uuid,
38315
+ extra_customizable: false,
38316
+ account_id: weddingAccountId,
38317
+ suite_uuid: args.suite_uuid
38318
+ }
38319
+ );
38320
+ return jsonResult(response.data);
38321
+ }
38322
+ async function swapCardProjectVariation(args) {
38323
+ const customizations = {};
38324
+ for (const [customizationUuid, variationUuid] of Object.entries(args.customizations)) {
38325
+ customizations[customizationUuid] = { variation_uuid: variationUuid };
38326
+ }
38327
+ const response = await client.requestMobile(
38328
+ "PUT",
38329
+ `/v3/card-projects/${args.project_uuid}`,
38330
+ { customizations }
38331
+ );
38332
+ return jsonResult(response.data);
38333
+ }
38334
+ async function setCardProjectGuests(args) {
38335
+ const response = await client.requestMobile(
38336
+ "PUT",
38337
+ `/v3/card-projects/${args.project_uuid}/project-guest-groups`,
38338
+ { guest_group_requests: args.guest_groups }
38339
+ );
38340
+ return jsonResult(response.data);
38341
+ }
38342
+ async function previewCardTemplate(args) {
38343
+ const substitutions = {};
38344
+ if (args.first_name !== void 0) substitutions.first_name = args.first_name;
38345
+ if (args.last_name !== void 0) substitutions.last_name = args.last_name;
38346
+ if (args.partner_first_name !== void 0) substitutions.partner_first_name = args.partner_first_name;
38347
+ if (args.partner_last_name !== void 0) substitutions.partner_last_name = args.partner_last_name;
38348
+ if (args.wedding_date !== void 0) {
38349
+ substitutions.wedding_date = args.wedding_date;
38350
+ } else {
38351
+ const { weddingDate } = await client.getContext();
38352
+ if (weddingDate) substitutions.wedding_date = weddingDate;
38353
+ }
38354
+ const response = await client.requestMobile(
38355
+ "POST",
38356
+ "/v3/card-templates/preview",
38357
+ {
38358
+ variation_uuids: args.variation_uuids,
38359
+ customizable: true,
38360
+ substitutions
38361
+ }
38362
+ );
38363
+ return jsonResult(response.data);
38364
+ }
38365
+ async function previewQrcode(args) {
38366
+ const { bytes, contentType } = await client.requestMobileBinary(
38367
+ "PUT",
38368
+ "/v3/card-projects/qrcode/preview",
38369
+ {
38370
+ dimension: args.dimension ?? "MEDIUM",
38371
+ url_type: args.url_type ?? "CUSTOM",
38372
+ enabled: args.enabled ?? true,
38373
+ url: args.url
38374
+ }
38375
+ );
38376
+ return imageResult(bytes, sniffImageType(bytes) ?? contentType);
38377
+ }
38378
+ function sniffImageType(bytes) {
38379
+ if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) {
38380
+ return "image/png";
38381
+ }
38382
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
38383
+ return "image/jpeg";
38384
+ }
38385
+ return null;
38386
+ }
38387
+ async function setCardProjectQrcode(args) {
38388
+ const response = await client.requestMobile(
38389
+ "PUT",
38390
+ `/v3/card-projects/${args.project_uuid}/customization/page/${args.page_uuid}/qrcode`,
38391
+ {
38392
+ dimension: args.dimension ?? "MEDIUM",
38393
+ url_type: args.url_type ?? "CUSTOM",
38394
+ color: args.color ?? "000000",
38395
+ enabled: args.enabled ?? true,
38396
+ url: args.url
38397
+ }
38398
+ );
38399
+ return jsonResult(response.data);
38400
+ }
38401
+ function registerInvitationTools(server2) {
38402
+ server2.registerTool("list_card_projects", {
38403
+ description: 'List your invitation / save-the-date / shower-invite "card" projects (paper or digital). Returns project UUID, name, customizations, suite, and quantity.',
38404
+ inputSchema: {
38405
+ include_completed: external_exports3.boolean().optional().describe("Include orders that have already been placed. Default: false (drafts only)."),
38406
+ limit: external_exports3.number().optional().describe("Max projects to return. Default: 30.")
38407
+ },
38408
+ annotations: { readOnlyHint: true }
38409
+ }, listCardProjects);
38410
+ server2.registerTool("get_card_project", {
38411
+ description: "Get full details for one invitation project including all customizations (invitation, envelope, RSVP card, details card), paper/color options, and per-customization pages.",
38412
+ inputSchema: {
38413
+ project_uuid: external_exports3.string().describe("Project UUID from list_card_projects")
38414
+ },
38415
+ annotations: { readOnlyHint: true }
38416
+ }, getCardProject);
38417
+ server2.registerTool("validate_card_project", {
38418
+ description: "Validate an invitation project \u2014 reports any text-fit, image, or guest-addressing errors per customization. Use before placing an order.",
38419
+ inputSchema: {
38420
+ project_uuid: external_exports3.string().describe("Project UUID from list_card_projects")
38421
+ },
38422
+ annotations: { readOnlyHint: true }
38423
+ }, validateCardProject);
38424
+ server2.registerTool("get_card_project_guests", {
38425
+ description: "List the guest groups assigned to an invitation project, including per-group font-size overrides for printed addressing.",
38426
+ inputSchema: {
38427
+ project_uuid: external_exports3.string().describe("Project UUID from list_card_projects")
38428
+ },
38429
+ annotations: { readOnlyHint: true }
38430
+ }, getCardProjectGuests);
38431
+ server2.registerTool("get_card_suite", {
38432
+ description: 'Get details for an invitation design "suite" (the family of matching invitation + RSVP + details cards), including paper types, sizes, and price range.',
38433
+ inputSchema: {
38434
+ suite_uuid: external_exports3.string().describe("Suite UUID (e.g. from search_card_catalog or list_favorite_card_suites)")
38435
+ },
38436
+ annotations: { readOnlyHint: true }
38437
+ }, getCardSuite);
38438
+ server2.registerTool("search_card_catalog", {
38439
+ description: "Search the invitation design catalog. Faceted search returning suites matching the requested card type.",
38440
+ inputSchema: {
38441
+ card_type: external_exports3.string().optional().describe("Lead card type: INVITATION (default), SAVE_THE_DATE, WEDDING_SHOWER_INVITATION, REHEARSAL_DINNER_INVITATION, THANK_YOU_CARD, etc."),
38442
+ limit: external_exports3.number().optional().describe("Max suites to return. Default: 50.")
38443
+ },
38444
+ annotations: { readOnlyHint: true }
38445
+ }, searchCardCatalog);
38446
+ server2.registerTool("list_favorite_card_suites", {
38447
+ description: "List invitation design suites you have favorited (hearted).",
38448
+ annotations: { readOnlyHint: true }
38449
+ }, listFavoriteCardSuites);
38450
+ server2.registerTool("get_rsvp_page", {
38451
+ description: "Get the RSVP page settings on the wedding website (title, intro copy, visibility, customization).",
38452
+ annotations: { readOnlyHint: true }
38453
+ }, getRsvpPage);
38454
+ server2.registerTool("create_card_project", {
38455
+ description: "Create a new invitation project from a design suite and a lead variation (specific size/paper).",
38456
+ inputSchema: {
38457
+ suite_uuid: external_exports3.string().describe("Suite UUID from search_card_catalog or get_card_suite"),
38458
+ lead_variation_uuid: external_exports3.string().describe("Lead variation UUID (specific size/paper/color from the suite)"),
38459
+ quantity: external_exports3.number().optional().describe("Quantity to order. Default: 150.")
38460
+ },
38461
+ annotations: { destructiveHint: false }
38462
+ }, createCardProject);
38463
+ server2.registerTool("swap_card_project_variation", {
38464
+ description: "Swap one or more customizations on a project to different variations (e.g. switch paper type, color, or size). Pass a map of customization UUID \u2192 new variation UUID.",
38465
+ inputSchema: {
38466
+ project_uuid: external_exports3.string().describe("Project UUID from list_card_projects"),
38467
+ customizations: external_exports3.record(external_exports3.string(), external_exports3.string()).describe("{ customization_uuid: new_variation_uuid }")
38468
+ },
38469
+ annotations: { destructiveHint: false }
38470
+ }, swapCardProjectVariation);
38471
+ server2.registerTool("set_card_project_guests", {
38472
+ description: "Enable / disable guest groups for an invitation project and optionally override font sizes for printed names and addresses. Pass the full list of guest groups you want recorded.",
38473
+ inputSchema: {
38474
+ project_uuid: external_exports3.string().describe("Project UUID from list_card_projects"),
38475
+ guest_groups: external_exports3.array(external_exports3.object({
38476
+ guest_group_id: external_exports3.number().describe("Guest group ID from list_guests / get_card_project_guests"),
38477
+ enabled: external_exports3.boolean().describe("Whether to include this group in printed addressing"),
38478
+ guest_name_font_size_override: external_exports3.number().optional().describe("Override font size for the guest name (pt)"),
38479
+ address_font_size_override: external_exports3.number().optional().describe("Override font size for the address (pt)")
38480
+ })).describe("Full list of guest groups to record. Omitted groups are not affected by this call.")
38481
+ },
38482
+ annotations: { destructiveHint: false }
38483
+ }, setCardProjectGuests);
38484
+ server2.registerTool("preview_card_template", {
38485
+ description: "Render an invitation template preview with text substituted in. Use to see how a design would look with your couple's names and wedding date. Returns the template structure including page layouts.",
38486
+ inputSchema: {
38487
+ variation_uuids: external_exports3.array(external_exports3.string()).describe("One or more variation UUIDs to preview (e.g. a specific size+paper of an invitation)"),
38488
+ first_name: external_exports3.string().optional().describe("Substitute for {{first_name}} placeholders"),
38489
+ last_name: external_exports3.string().optional().describe("Substitute for {{last_name}}"),
38490
+ partner_first_name: external_exports3.string().optional().describe("Substitute for {{partner_first_name}}"),
38491
+ partner_last_name: external_exports3.string().optional().describe("Substitute for {{partner_last_name}}"),
38492
+ wedding_date: external_exports3.string().optional().describe("Substitute for {{wedding_date}}, YYYY-MM-DD. Defaults to the wedding date on file.")
38493
+ },
38494
+ annotations: { readOnlyHint: true }
38495
+ }, previewCardTemplate);
38496
+ server2.registerTool("preview_qrcode", {
38497
+ description: "Generate a QR-code PNG for an invitation. Returns the image so it can be inspected. Use set_card_project_qrcode to actually place it on a card.",
38498
+ inputSchema: {
38499
+ url: external_exports3.string().describe("URL the QR code will resolve to"),
38500
+ dimension: external_exports3.string().optional().describe("SMALL | MEDIUM (default) | LARGE"),
38501
+ url_type: external_exports3.string().optional().describe("CUSTOM (default) | WEDDING_WEBSITE | WEDDING_WEBSITE_RSVP"),
38502
+ enabled: external_exports3.boolean().optional().describe("Whether the QR code is enabled. Default: true.")
38503
+ },
38504
+ annotations: { readOnlyHint: true }
38505
+ }, previewQrcode);
38506
+ server2.registerTool("set_card_project_qrcode", {
38507
+ description: "Place (or update) a QR code on a specific page of an invitation project. The page UUID comes from get_card_project (look under the customization's pages array).",
38508
+ inputSchema: {
38509
+ project_uuid: external_exports3.string().describe("Project UUID from list_card_projects"),
38510
+ page_uuid: external_exports3.string().describe("Page UUID of the customization to put the QR on"),
38511
+ url: external_exports3.string().describe("URL the QR code will resolve to"),
38512
+ dimension: external_exports3.string().optional().describe("SMALL | MEDIUM (default) | LARGE"),
38513
+ url_type: external_exports3.string().optional().describe("CUSTOM (default) | WEDDING_WEBSITE | WEDDING_WEBSITE_RSVP"),
38514
+ color: external_exports3.string().optional().describe("Hex color (no #). Default: 000000"),
38515
+ enabled: external_exports3.boolean().optional().describe("Whether to enable the QR code. Default: true.")
38516
+ },
38517
+ annotations: { destructiveHint: false }
38518
+ }, setCardProjectQrcode);
38519
+ }
38520
+
37908
38521
  // src/index.ts
37909
38522
  var server = new McpServer({
37910
38523
  name: "zola-mcp",
37911
- version: "1.2.3"
38524
+ version: "1.3.1"
37912
38525
  // x-release-please-version
37913
38526
  });
37914
38527
  registerVendorTools(server);
@@ -37922,5 +38535,6 @@ registerWebsiteTools(server);
37922
38535
  registerWebsiteContentTools(server);
37923
38536
  registerWebsiteThemeTools(server);
37924
38537
  registerRegistryItemTools(server);
38538
+ registerInvitationTools(server);
37925
38539
  var transport = new StdioServerTransport();
37926
38540
  await server.connect(transport);
package/dist/client.js CHANGED
@@ -65,6 +65,19 @@ export class ZolaClient {
65
65
  await this.ensureSession();
66
66
  return this.doRequest(method, path, body);
67
67
  }
68
+ /**
69
+ * Like `requestMobile` but for endpoints that return a non-JSON body
70
+ * (e.g. the QR-code preview which returns image bytes).
71
+ * Sends `Accept: *\/*` (the JSON default would 406), and returns both the
72
+ * raw bytes and the server's content-type so callers can pass it through.
73
+ */
74
+ async requestMobileBinary(method, path, body) {
75
+ await this.ensureSession();
76
+ const response = await this.sendWithRetry(method, path, body, false, false, '*/*');
77
+ const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
78
+ const bytes = new Uint8Array(await response.arrayBuffer());
79
+ return { bytes, contentType };
80
+ }
68
81
  /**
69
82
  * Get user context (wedding account ID, registry ID, etc.).
70
83
  * Uses env vars as overrides; falls back to GET /v3/users/me/context.
@@ -99,10 +112,15 @@ export class ZolaClient {
99
112
  };
100
113
  return this.cachedContext;
101
114
  }
102
- async doRequest(method, path, body, isAuthRetry = false, isRateRetry = false) {
115
+ async doRequest(method, path, body) {
116
+ const response = await this.sendWithRetry(method, path, body);
117
+ const text = await response.text();
118
+ return (text ? JSON.parse(text) : null);
119
+ }
120
+ async sendWithRetry(method, path, body, isAuthRetry = false, isRateRetry = false, accept = 'application/json') {
103
121
  const sessionId = decodeJwtSessionId(this.sessionToken);
104
122
  const headers = {
105
- accept: 'application/json',
123
+ accept,
106
124
  authorization: `Bearer ${this.sessionToken}`,
107
125
  'x-zola-platform-type': 'iphone_app',
108
126
  'x-zola-session-id': this.deviceSessionId,
@@ -120,12 +138,12 @@ export class ZolaClient {
120
138
  this.sessionToken = null;
121
139
  this.sessionExpiry = null;
122
140
  await this.refresh();
123
- return this.doRequest(method, path, body, true, isRateRetry);
141
+ return this.sendWithRetry(method, path, body, true, isRateRetry, accept);
124
142
  }
125
143
  if (response.status === 429) {
126
144
  if (!isRateRetry) {
127
145
  await new Promise((r) => setTimeout(r, 2000));
128
- return this.doRequest(method, path, body, isAuthRetry, true);
146
+ return this.sendWithRetry(method, path, body, isAuthRetry, true, accept);
129
147
  }
130
148
  throw new Error('Rate limited by Zola API');
131
149
  }
@@ -133,8 +151,7 @@ export class ZolaClient {
133
151
  const text = await response.text();
134
152
  throw new Error(`Zola API error: ${response.status} ${response.statusText} for ${method} ${path}: ${text}`);
135
153
  }
136
- const text = await response.text();
137
- return (text ? JSON.parse(text) : null);
154
+ return response;
138
155
  }
139
156
  async ensureSession() {
140
157
  // Session still valid with comfortable margin
package/dist/index.js CHANGED
@@ -11,9 +11,10 @@ import { registerWebsiteTools } from './tools/website.js';
11
11
  import { registerWebsiteContentTools } from './tools/website-content.js';
12
12
  import { registerWebsiteThemeTools } from './tools/website-theme.js';
13
13
  import { registerRegistryItemTools } from './tools/registry-items.js';
14
+ import { registerInvitationTools } from './tools/invitations.js';
14
15
  const server = new McpServer({
15
16
  name: 'zola-mcp',
16
- version: '1.2.3', // x-release-please-version
17
+ version: '1.3.1', // x-release-please-version
17
18
  });
18
19
  registerVendorTools(server);
19
20
  registerBudgetTools(server);
@@ -26,5 +27,6 @@ registerWebsiteTools(server);
26
27
  registerWebsiteContentTools(server);
27
28
  registerWebsiteThemeTools(server);
28
29
  registerRegistryItemTools(server);
30
+ registerInvitationTools(server);
29
31
  const transport = new StdioServerTransport();
30
32
  await server.connect(transport);
@@ -0,0 +1,256 @@
1
+ import { z } from 'zod';
2
+ import { client } from '../client.js';
3
+ import { jsonResult, imageResult } from '../types.js';
4
+ // ─── Tier 1: read-only ───────────────────────────────────────────────────────
5
+ export async function listCardProjects(args) {
6
+ const response = await client.requestMobile('POST', '/v3/card-projects/search_request', {
7
+ completed: args.include_completed ?? false,
8
+ limit: args.limit ?? 30,
9
+ card_suite_uuids: [],
10
+ card_suite_ids: [],
11
+ offset: 0,
12
+ fetch_customizations: true,
13
+ include_deleted: false,
14
+ medium: ['PAPER', 'MAGNET', 'DIGITAL'],
15
+ single_sample: false,
16
+ });
17
+ return jsonResult(response.data);
18
+ }
19
+ export async function getCardProject(args) {
20
+ const response = await client.requestMobile('GET', `/v3/card-projects/${args.project_uuid}`);
21
+ return jsonResult(response.data);
22
+ }
23
+ export async function validateCardProject(args) {
24
+ const response = await client.requestMobile('GET', `/v3/card-projects/${args.project_uuid}/validate`);
25
+ return jsonResult(response.data);
26
+ }
27
+ export async function getCardProjectGuests(args) {
28
+ const response = await client.requestMobile('GET', `/v3/card-projects/${args.project_uuid}/project-guest-groups`);
29
+ return jsonResult(response.data);
30
+ }
31
+ export async function getCardSuite(args) {
32
+ const response = await client.requestMobile('POST', `/v4/card-catalog/suites/details/${args.suite_uuid}`);
33
+ return jsonResult(response.data);
34
+ }
35
+ export async function searchCardCatalog(args) {
36
+ const response = await client.requestMobile('POST', '/v3/card-catalog/search/faceted', {
37
+ lead_card_types: [],
38
+ include_updated_proof_module: true,
39
+ limit: args.limit ?? 50,
40
+ offset: 0,
41
+ digital_suite: false,
42
+ include_lead_card_type_metadata: true,
43
+ lead_card_type: args.card_type ?? 'INVITATION',
44
+ include_module: true,
45
+ });
46
+ return jsonResult(response.data);
47
+ }
48
+ export async function listFavoriteCardSuites() {
49
+ const response = await client.requestMobile('GET', '/v3/favorites/card-suites/');
50
+ return jsonResult(response.data);
51
+ }
52
+ export async function getRsvpPage() {
53
+ const { weddingAccountId } = await client.getContext();
54
+ const response = await client.requestMobile('GET', `/v3/websites/rsvps/wedding-accounts/${weddingAccountId}`);
55
+ return jsonResult(response.data);
56
+ }
57
+ // ─── Tier 2: writes ──────────────────────────────────────────────────────────
58
+ export async function createCardProject(args) {
59
+ const { weddingAccountId } = await client.getContext();
60
+ const response = await client.requestMobile('POST', '/v3/card-projects', {
61
+ quantity: args.quantity ?? 150,
62
+ lead_variation_uuid: args.lead_variation_uuid,
63
+ extra_customizable: false,
64
+ account_id: weddingAccountId,
65
+ suite_uuid: args.suite_uuid,
66
+ });
67
+ return jsonResult(response.data);
68
+ }
69
+ export async function swapCardProjectVariation(args) {
70
+ const customizations = {};
71
+ for (const [customizationUuid, variationUuid] of Object.entries(args.customizations)) {
72
+ customizations[customizationUuid] = { variation_uuid: variationUuid };
73
+ }
74
+ const response = await client.requestMobile('PUT', `/v3/card-projects/${args.project_uuid}`, { customizations });
75
+ return jsonResult(response.data);
76
+ }
77
+ export async function setCardProjectGuests(args) {
78
+ const response = await client.requestMobile('PUT', `/v3/card-projects/${args.project_uuid}/project-guest-groups`, { guest_group_requests: args.guest_groups });
79
+ return jsonResult(response.data);
80
+ }
81
+ export async function previewCardTemplate(args) {
82
+ const substitutions = {};
83
+ if (args.first_name !== undefined)
84
+ substitutions.first_name = args.first_name;
85
+ if (args.last_name !== undefined)
86
+ substitutions.last_name = args.last_name;
87
+ if (args.partner_first_name !== undefined)
88
+ substitutions.partner_first_name = args.partner_first_name;
89
+ if (args.partner_last_name !== undefined)
90
+ substitutions.partner_last_name = args.partner_last_name;
91
+ if (args.wedding_date !== undefined) {
92
+ substitutions.wedding_date = args.wedding_date;
93
+ }
94
+ else {
95
+ const { weddingDate } = await client.getContext();
96
+ if (weddingDate)
97
+ substitutions.wedding_date = weddingDate;
98
+ }
99
+ const response = await client.requestMobile('POST', '/v3/card-templates/preview', {
100
+ variation_uuids: args.variation_uuids,
101
+ customizable: true,
102
+ substitutions,
103
+ });
104
+ return jsonResult(response.data);
105
+ }
106
+ // ─── QR code ─────────────────────────────────────────────────────────────────
107
+ export async function previewQrcode(args) {
108
+ const { bytes, contentType } = await client.requestMobileBinary('PUT', '/v3/card-projects/qrcode/preview', {
109
+ dimension: args.dimension ?? 'MEDIUM',
110
+ url_type: args.url_type ?? 'CUSTOM',
111
+ enabled: args.enabled ?? true,
112
+ url: args.url,
113
+ });
114
+ // Zola's qrcode/preview returns PNG bytes with `Content-Type: image/jpeg`. Sniff magic bytes.
115
+ return imageResult(bytes, sniffImageType(bytes) ?? contentType);
116
+ }
117
+ function sniffImageType(bytes) {
118
+ if (bytes.length >= 8 &&
119
+ bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
120
+ return 'image/png';
121
+ }
122
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
123
+ return 'image/jpeg';
124
+ }
125
+ return null;
126
+ }
127
+ export async function setCardProjectQrcode(args) {
128
+ const response = await client.requestMobile('PUT', `/v3/card-projects/${args.project_uuid}/customization/page/${args.page_uuid}/qrcode`, {
129
+ dimension: args.dimension ?? 'MEDIUM',
130
+ url_type: args.url_type ?? 'CUSTOM',
131
+ color: args.color ?? '000000',
132
+ enabled: args.enabled ?? true,
133
+ url: args.url,
134
+ });
135
+ return jsonResult(response.data);
136
+ }
137
+ // ─── MCP registration ────────────────────────────────────────────────────────
138
+ export function registerInvitationTools(server) {
139
+ server.registerTool('list_card_projects', {
140
+ description: 'List your invitation / save-the-date / shower-invite "card" projects (paper or digital). Returns project UUID, name, customizations, suite, and quantity.',
141
+ inputSchema: {
142
+ include_completed: z.boolean().optional().describe('Include orders that have already been placed. Default: false (drafts only).'),
143
+ limit: z.number().optional().describe('Max projects to return. Default: 30.'),
144
+ },
145
+ annotations: { readOnlyHint: true },
146
+ }, listCardProjects);
147
+ server.registerTool('get_card_project', {
148
+ description: 'Get full details for one invitation project including all customizations (invitation, envelope, RSVP card, details card), paper/color options, and per-customization pages.',
149
+ inputSchema: {
150
+ project_uuid: z.string().describe('Project UUID from list_card_projects'),
151
+ },
152
+ annotations: { readOnlyHint: true },
153
+ }, getCardProject);
154
+ server.registerTool('validate_card_project', {
155
+ description: 'Validate an invitation project — reports any text-fit, image, or guest-addressing errors per customization. Use before placing an order.',
156
+ inputSchema: {
157
+ project_uuid: z.string().describe('Project UUID from list_card_projects'),
158
+ },
159
+ annotations: { readOnlyHint: true },
160
+ }, validateCardProject);
161
+ server.registerTool('get_card_project_guests', {
162
+ description: 'List the guest groups assigned to an invitation project, including per-group font-size overrides for printed addressing.',
163
+ inputSchema: {
164
+ project_uuid: z.string().describe('Project UUID from list_card_projects'),
165
+ },
166
+ annotations: { readOnlyHint: true },
167
+ }, getCardProjectGuests);
168
+ server.registerTool('get_card_suite', {
169
+ description: 'Get details for an invitation design "suite" (the family of matching invitation + RSVP + details cards), including paper types, sizes, and price range.',
170
+ inputSchema: {
171
+ suite_uuid: z.string().describe('Suite UUID (e.g. from search_card_catalog or list_favorite_card_suites)'),
172
+ },
173
+ annotations: { readOnlyHint: true },
174
+ }, getCardSuite);
175
+ server.registerTool('search_card_catalog', {
176
+ description: 'Search the invitation design catalog. Faceted search returning suites matching the requested card type.',
177
+ inputSchema: {
178
+ card_type: z.string().optional().describe('Lead card type: INVITATION (default), SAVE_THE_DATE, WEDDING_SHOWER_INVITATION, REHEARSAL_DINNER_INVITATION, THANK_YOU_CARD, etc.'),
179
+ limit: z.number().optional().describe('Max suites to return. Default: 50.'),
180
+ },
181
+ annotations: { readOnlyHint: true },
182
+ }, searchCardCatalog);
183
+ server.registerTool('list_favorite_card_suites', {
184
+ description: 'List invitation design suites you have favorited (hearted).',
185
+ annotations: { readOnlyHint: true },
186
+ }, listFavoriteCardSuites);
187
+ server.registerTool('get_rsvp_page', {
188
+ description: 'Get the RSVP page settings on the wedding website (title, intro copy, visibility, customization).',
189
+ annotations: { readOnlyHint: true },
190
+ }, getRsvpPage);
191
+ server.registerTool('create_card_project', {
192
+ description: 'Create a new invitation project from a design suite and a lead variation (specific size/paper).',
193
+ inputSchema: {
194
+ suite_uuid: z.string().describe('Suite UUID from search_card_catalog or get_card_suite'),
195
+ lead_variation_uuid: z.string().describe('Lead variation UUID (specific size/paper/color from the suite)'),
196
+ quantity: z.number().optional().describe('Quantity to order. Default: 150.'),
197
+ },
198
+ annotations: { destructiveHint: false },
199
+ }, createCardProject);
200
+ server.registerTool('swap_card_project_variation', {
201
+ description: 'Swap one or more customizations on a project to different variations (e.g. switch paper type, color, or size). Pass a map of customization UUID → new variation UUID.',
202
+ inputSchema: {
203
+ project_uuid: z.string().describe('Project UUID from list_card_projects'),
204
+ customizations: z.record(z.string(), z.string()).describe('{ customization_uuid: new_variation_uuid }'),
205
+ },
206
+ annotations: { destructiveHint: false },
207
+ }, swapCardProjectVariation);
208
+ server.registerTool('set_card_project_guests', {
209
+ description: 'Enable / disable guest groups for an invitation project and optionally override font sizes for printed names and addresses. Pass the full list of guest groups you want recorded.',
210
+ inputSchema: {
211
+ project_uuid: z.string().describe('Project UUID from list_card_projects'),
212
+ guest_groups: z.array(z.object({
213
+ guest_group_id: z.number().describe('Guest group ID from list_guests / get_card_project_guests'),
214
+ enabled: z.boolean().describe('Whether to include this group in printed addressing'),
215
+ guest_name_font_size_override: z.number().optional().describe('Override font size for the guest name (pt)'),
216
+ address_font_size_override: z.number().optional().describe('Override font size for the address (pt)'),
217
+ })).describe('Full list of guest groups to record. Omitted groups are not affected by this call.'),
218
+ },
219
+ annotations: { destructiveHint: false },
220
+ }, setCardProjectGuests);
221
+ server.registerTool('preview_card_template', {
222
+ description: 'Render an invitation template preview with text substituted in. Use to see how a design would look with your couple\'s names and wedding date. Returns the template structure including page layouts.',
223
+ inputSchema: {
224
+ variation_uuids: z.array(z.string()).describe('One or more variation UUIDs to preview (e.g. a specific size+paper of an invitation)'),
225
+ first_name: z.string().optional().describe('Substitute for {{first_name}} placeholders'),
226
+ last_name: z.string().optional().describe('Substitute for {{last_name}}'),
227
+ partner_first_name: z.string().optional().describe('Substitute for {{partner_first_name}}'),
228
+ partner_last_name: z.string().optional().describe('Substitute for {{partner_last_name}}'),
229
+ wedding_date: z.string().optional().describe('Substitute for {{wedding_date}}, YYYY-MM-DD. Defaults to the wedding date on file.'),
230
+ },
231
+ annotations: { readOnlyHint: true },
232
+ }, previewCardTemplate);
233
+ server.registerTool('preview_qrcode', {
234
+ description: 'Generate a QR-code PNG for an invitation. Returns the image so it can be inspected. Use set_card_project_qrcode to actually place it on a card.',
235
+ inputSchema: {
236
+ url: z.string().describe('URL the QR code will resolve to'),
237
+ dimension: z.string().optional().describe('SMALL | MEDIUM (default) | LARGE'),
238
+ url_type: z.string().optional().describe('CUSTOM (default) | WEDDING_WEBSITE | WEDDING_WEBSITE_RSVP'),
239
+ enabled: z.boolean().optional().describe('Whether the QR code is enabled. Default: true.'),
240
+ },
241
+ annotations: { readOnlyHint: true },
242
+ }, previewQrcode);
243
+ server.registerTool('set_card_project_qrcode', {
244
+ description: 'Place (or update) a QR code on a specific page of an invitation project. The page UUID comes from get_card_project (look under the customization\'s pages array).',
245
+ inputSchema: {
246
+ project_uuid: z.string().describe('Project UUID from list_card_projects'),
247
+ page_uuid: z.string().describe('Page UUID of the customization to put the QR on'),
248
+ url: z.string().describe('URL the QR code will resolve to'),
249
+ dimension: z.string().optional().describe('SMALL | MEDIUM (default) | LARGE'),
250
+ url_type: z.string().optional().describe('CUSTOM (default) | WEDDING_WEBSITE | WEDDING_WEBSITE_RSVP'),
251
+ color: z.string().optional().describe('Hex color (no #). Default: 000000'),
252
+ enabled: z.boolean().optional().describe('Whether to enable the QR code. Default: true.'),
253
+ },
254
+ annotations: { destructiveHint: false },
255
+ }, setCardProjectQrcode);
256
+ }
package/dist/types.js CHANGED
@@ -2,6 +2,10 @@
2
2
  export function jsonResult(data) {
3
3
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
4
4
  }
5
+ /** Wrap raw image bytes as an MCP image-content tool result. */
6
+ export function imageResult(bytes, mimeType) {
7
+ return { content: [{ type: 'image', data: Buffer.from(bytes).toString('base64'), mimeType }] };
8
+ }
5
9
  /**
6
10
  * Build a partial-update body containing only keys from `args` that are not undefined.
7
11
  * Used by tools that send PATCH-style updates where omitting a key means "leave unchanged".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zola-mcp",
3
- "version": "1.2.3",
3
+ "version": "1.3.1",
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,8 @@
46
46
  "test:watch": "vitest"
47
47
  },
48
48
  "dependencies": {
49
- "@fetchproxy/bootstrap": "^0.6.0",
49
+ "@fetchproxy/bootstrap": "^0.8.0",
50
+ "@fetchproxy/server": "^0.8.0",
50
51
  "@modelcontextprotocol/sdk": "^1.29.0",
51
52
  "dotenv": "^17.4.2"
52
53
  },
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.2.3",
9
+ "version": "1.3.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "zola-mcp",
14
- "version": "1.2.3",
14
+ "version": "1.3.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },