@joy-loyalty/mcp 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { createRequire } from "node:module";
5
+ import { randomUUID as randomUUID3 } from "node:crypto";
5
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
8
 
@@ -67,17 +68,368 @@ function loadConfig(env = process.env) {
67
68
  deductDryRunThreshold
68
69
  };
69
70
  }
71
+ var DEFAULT_TELEMETRY_MODE = "headers";
72
+ function telemetryMode(env = process.env) {
73
+ const mode = (env.JOY_MCP_TELEMETRY ?? "").trim().toLowerCase();
74
+ const doNotTrack = (env.DO_NOT_TRACK ?? "").trim().toLowerCase();
75
+ if (["1", "true"].includes(doNotTrack) || ["off", "false", "0"].includes(mode)) return "off";
76
+ if (mode === "on") return "full";
77
+ return DEFAULT_TELEMETRY_MODE;
78
+ }
79
+
80
+ // ../mcp-core/build/telemetry/callContext.js
81
+ import { AsyncLocalStorage } from "node:async_hooks";
82
+
83
+ // ../mcp-core/build/logger.js
84
+ var log = {
85
+ info(message) {
86
+ console.error(`[joy-mcp] ${message}`);
87
+ },
88
+ error(message) {
89
+ console.error(`[joy-mcp:error] ${message}`);
90
+ }
91
+ };
92
+
93
+ // ../mcp-core/build/telemetry/schema.js
94
+ var SCHEMA_VERSION = 1;
95
+ var UUID = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$";
96
+ var SHOP_ID = "^[A-Za-z0-9]{1,40}$";
97
+ var SHOP_DOMAIN = "^[a-z0-9][a-z0-9.-]{0,254}$";
98
+ var PLAN = "^[A-Za-z0-9_. -]{1,64}$";
99
+ var VERSION = "^[A-Za-z0-9._+-]{1,64}$";
100
+ var TOOL = "^[a-z0-9_]{1,64}$";
101
+ var ERROR_CODE = "^[A-Za-z][A-Za-z0-9_]{0,63}$";
102
+ var CLIENT_NAME = "^[\\x20-\\x7E]{1,64}$";
103
+ var CLIENT_VERSION = "^[A-Za-z0-9._+-]{1,32}$";
104
+ var CAPABILITIES = "^[a-z][a-zA-Z0-9_]{0,31}(,[a-z][a-zA-Z0-9_]{0,31}){0,9}$";
105
+ var MAX_MS = 36e5;
106
+ var MCP = ["remote", "stdio"];
107
+ var REMOTE = ["remote"];
108
+ var STDIO = ["stdio"];
109
+ var BACKEND = ["backend"];
110
+ var OUTCOMES = ["success", "tool_error", "rejected", "internal_error", "cancelled"];
111
+ var ERROR_KINDS = [
112
+ // JoyApiError kinds (client/joyApi.ts ErrorKind)
113
+ "auth",
114
+ "plan_gate",
115
+ "forbidden",
116
+ "rate_limit",
117
+ "not_found",
118
+ "validation",
119
+ "conflict",
120
+ "server",
121
+ "timeout",
122
+ "network",
123
+ // handler / toolkit
124
+ "exception",
125
+ "store_mismatch",
126
+ "writes_disabled",
127
+ "config_writes_disabled",
128
+ // rejected before the handler ran
129
+ "invalid_input",
130
+ "tool_not_exposed",
131
+ "unknown_tool",
132
+ // recorder
133
+ "unclassified",
134
+ "jsonrpc_error",
135
+ "duplicate_request_id",
136
+ "client_cancelled",
137
+ "no_response"
138
+ ];
139
+ function envelope(event, origins) {
140
+ return {
141
+ event: { type: "string", required: true, transports: origins, enum: [event] },
142
+ schema_version: { type: "integer", required: true, transports: origins, min: SCHEMA_VERSION, max: SCHEMA_VERSION },
143
+ event_id: { type: "string", required: true, transports: origins, pattern: UUID }
144
+ };
145
+ }
146
+ var MCP_IDENTITY = {
147
+ transport: { type: "string", required: true, transports: MCP, server_stamped: true, enum: ["remote", "stdio"] },
148
+ server_version: { type: "string", required: true, transports: MCP, pattern: VERSION },
149
+ shop_id: { type: "string", required: true, transports: MCP, server_stamped: true, pattern: SHOP_ID },
150
+ shop_domain: { type: "string", transports: MCP, server_stamped: true, pattern: SHOP_DOMAIN },
151
+ joy_plan: { type: "string", transports: MCP, server_stamped: true, pattern: PLAN },
152
+ shopify_plan: { type: "string", transports: MCP, server_stamped: true, pattern: PLAN },
153
+ auth_mode: { type: "string", required: true, transports: MCP, server_stamped: true, enum: ["oauth", "api_key", "private_bot"] },
154
+ access_scope: { type: "string", required: true, transports: MCP, server_stamped: true, enum: ["full", "read"] },
155
+ oauth_client_id: { type: "string", transports: REMOTE, pattern: "^[\\x21-\\x7E]{1,512}$" },
156
+ connection_id: { type: "string", transports: REMOTE, pattern: UUID },
157
+ user_id: { type: "string", transports: REMOTE, pattern: "^[A-Za-z0-9_-]{1,64}$" },
158
+ session_id: { type: "string", transports: STDIO, pattern: UUID },
159
+ connector_mode: { type: "string", transports: REMOTE, enum: ["soft", "strict"] }
160
+ };
161
+ var TELEMETRY_SCHEMA = {
162
+ schema_version: SCHEMA_VERSION,
163
+ ingest_events: ["mcp.tool_call", "mcp.session_start"],
164
+ forbidden_keys: [
165
+ "args",
166
+ "arguments",
167
+ "params",
168
+ "input",
169
+ "result",
170
+ "content",
171
+ "text",
172
+ "prompt",
173
+ "sql",
174
+ "query",
175
+ "message",
176
+ "severity",
177
+ "model_message",
178
+ "error_message",
179
+ "path",
180
+ "url",
181
+ "store_name",
182
+ "email",
183
+ "ip",
184
+ "user_agent",
185
+ "secret",
186
+ "secret_key",
187
+ "token",
188
+ "authorization"
189
+ ],
190
+ events: {
191
+ "mcp.tool_call": {
192
+ fields: {
193
+ ...envelope("mcp.tool_call", MCP),
194
+ ...MCP_IDENTITY,
195
+ mcp_client_name: { type: "string", transports: STDIO, pattern: CLIENT_NAME },
196
+ mcp_client_version: { type: "string", transports: STDIO, pattern: CLIENT_VERSION },
197
+ call_id: { type: "string", required: true, transports: MCP, pattern: UUID },
198
+ tool: { type: "string", required: true, transports: MCP, pattern: TOOL },
199
+ tool_kind: { type: "string", required: true, transports: MCP, enum: ["read", "write", "config_write", "unknown"] },
200
+ outcome: { type: "string", required: true, transports: MCP, enum: OUTCOMES },
201
+ error_kind: { type: "string", transports: MCP, enum: ERROR_KINDS },
202
+ error_code: { type: "string", transports: MCP, pattern: ERROR_CODE },
203
+ http_status: { type: "integer", transports: MCP, min: 100, max: 599 },
204
+ duration_ms: { type: "integer", required: true, transports: MCP, min: 0, max: MAX_MS },
205
+ pre_ms: { type: "integer", transports: REMOTE, min: 0, max: MAX_MS },
206
+ api_calls: { type: "integer", required: true, transports: MCP, min: 0, max: 1e4 },
207
+ api_ms: { type: "integer", required: true, transports: MCP, min: 0, max: MAX_MS },
208
+ result_bytes: { type: "integer", required: true, transports: MCP, min: 0, max: 1e9 }
209
+ }
210
+ },
211
+ "mcp.session_start": {
212
+ fields: {
213
+ ...envelope("mcp.session_start", MCP),
214
+ ...MCP_IDENTITY,
215
+ mcp_client_name: { type: "string", transports: MCP, pattern: CLIENT_NAME },
216
+ mcp_client_version: { type: "string", transports: MCP, pattern: CLIENT_VERSION },
217
+ protocol_version: { type: "string", transports: MCP, pattern: "^\\d{4}-\\d{2}-\\d{2}$" },
218
+ client_capabilities: { type: "string", transports: MCP, pattern: CAPABILITIES },
219
+ tools_exposed: { type: "integer", transports: MCP, min: 0, max: 1e4 }
220
+ }
221
+ },
222
+ "api.request": {
223
+ fields: {
224
+ ...envelope("api.request", BACKEND),
225
+ source: { type: "string", required: true, transports: BACKEND, enum: ["mcp", "direct_api", "private_bot", "internal"] },
226
+ call_id: { type: "string", transports: BACKEND, pattern: UUID },
227
+ tool: { type: "string", transports: BACKEND, pattern: TOOL },
228
+ transport: { type: "string", transports: BACKEND, enum: ["remote", "stdio"] },
229
+ server_version: { type: "string", transports: BACKEND, pattern: VERSION },
230
+ mcp_client_name: { type: "string", transports: BACKEND, pattern: CLIENT_NAME },
231
+ session_id: { type: "string", transports: BACKEND, pattern: UUID },
232
+ shop_verified: { type: "boolean", required: true, transports: BACKEND },
233
+ shop_id: { type: "string", transports: BACKEND, pattern: SHOP_ID },
234
+ claimed_shop_id: { type: "string", transports: BACKEND, pattern: SHOP_ID },
235
+ shop_domain: { type: "string", transports: BACKEND, pattern: SHOP_DOMAIN },
236
+ joy_plan: { type: "string", transports: BACKEND, pattern: PLAN },
237
+ shopify_plan: { type: "string", transports: BACKEND, pattern: PLAN },
238
+ auth_mode: { type: "string", transports: BACKEND, enum: ["api_key", "private_bot"] },
239
+ access_scope: { type: "string", transports: BACKEND, enum: ["full", "read"] },
240
+ access_policy: {
241
+ type: "string",
242
+ transports: BACKEND,
243
+ enum: ["plan_quota", "unlimited_plan", "manual_grant", "shop_override", "private_bot", "no_access"]
244
+ },
245
+ method: { type: "string", required: true, transports: BACKEND, enum: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] },
246
+ route: { type: "string", transports: BACKEND, pattern: "^/[A-Za-z0-9/:_.()*-]{0,199}$" },
247
+ http_status: { type: "integer", required: true, transports: BACKEND, min: 100, max: 599 },
248
+ error_code: { type: "string", transports: BACKEND, pattern: ERROR_CODE },
249
+ response_error: { type: "boolean", transports: BACKEND },
250
+ duration_ms: { type: "integer", required: true, transports: BACKEND, min: 0, max: MAX_MS },
251
+ bq_jobs: { type: "integer", transports: BACKEND, min: 0, max: 1e4 },
252
+ bq_bytes_processed: { type: "integer", transports: BACKEND, min: 0, max: 1e15 },
253
+ bq_bytes_billed_est: { type: "integer", transports: BACKEND, min: 0, max: 1e15 }
254
+ }
255
+ }
256
+ }
257
+ };
258
+
259
+ // ../mcp-core/build/telemetry/validate.js
260
+ var patternCache = /* @__PURE__ */ new Map();
261
+ function regexFor(pattern) {
262
+ let regex = patternCache.get(pattern);
263
+ if (!regex) {
264
+ regex = new RegExp(pattern);
265
+ patternCache.set(pattern, regex);
266
+ }
267
+ return regex;
268
+ }
269
+ function isValidValue(spec, value) {
270
+ if (spec.type === "boolean")
271
+ return typeof value === "boolean";
272
+ if (spec.type === "integer") {
273
+ if (typeof value !== "number" || !Number.isInteger(value))
274
+ return false;
275
+ if (spec.min !== void 0 && value < spec.min)
276
+ return false;
277
+ return spec.max === void 0 || value <= spec.max;
278
+ }
279
+ if (typeof value !== "string")
280
+ return false;
281
+ if (spec.maxLength !== void 0 && value.length > spec.maxLength)
282
+ return false;
283
+ if (spec.enum && !spec.enum.includes(value))
284
+ return false;
285
+ return !spec.pattern || regexFor(spec.pattern).test(value);
286
+ }
287
+ function own(table, key) {
288
+ return Object.prototype.hasOwnProperty.call(table, key) ? table[key] : void 0;
289
+ }
290
+ function isApplicable(spec, transport) {
291
+ return !transport || spec.transports.includes(transport);
292
+ }
293
+ function validateEvent(schema, event, options) {
294
+ const errors = [];
295
+ const cleaned = {};
296
+ if (!event || typeof event !== "object" || Array.isArray(event)) {
297
+ return { ok: false, event: cleaned, errors: ["not_an_object"] };
298
+ }
299
+ const record = event;
300
+ const spec = typeof record.event === "string" ? own(schema.events, record.event) : void 0;
301
+ if (!spec)
302
+ return { ok: false, event: cleaned, errors: ["unknown_event"] };
303
+ for (const key of schema.forbidden_keys) {
304
+ if (Object.prototype.hasOwnProperty.call(record, key))
305
+ errors.push(`forbidden:${key}`);
306
+ }
307
+ for (const [key, value] of Object.entries(record)) {
308
+ const field = own(spec.fields, key);
309
+ if (!field || !isApplicable(field, options.transport) || value === void 0)
310
+ continue;
311
+ if (isValidValue(field, value)) {
312
+ cleaned[key] = value;
313
+ } else {
314
+ errors.push(`invalid:${key}`);
315
+ }
316
+ }
317
+ for (const [key, field] of Object.entries(spec.fields)) {
318
+ if (!field.required || !isApplicable(field, options.transport))
319
+ continue;
320
+ if (options.mode === "client" && field.server_stamped)
321
+ continue;
322
+ if (!(key in cleaned) && !errors.includes(`invalid:${key}`))
323
+ errors.push(`missing:${key}`);
324
+ }
325
+ return { ok: errors.length === 0, event: cleaned, errors };
326
+ }
327
+ function sanitizeString(schema, eventName, field, value) {
328
+ const eventSpec = own(schema.events, eventName);
329
+ const spec = eventSpec ? own(eventSpec.fields, field) : void 0;
330
+ if (!spec || spec.type !== "string" || typeof value !== "string")
331
+ return void 0;
332
+ return isValidValue(spec, value) ? value : void 0;
333
+ }
334
+
335
+ // ../mcp-core/build/telemetry/callContext.js
336
+ var als = new AsyncLocalStorage();
337
+ var reported = /* @__PURE__ */ new Set();
338
+ function reportOnce(message) {
339
+ if (reported.has(message))
340
+ return;
341
+ reported.add(message);
342
+ try {
343
+ log.error(message);
344
+ } catch {
345
+ }
346
+ }
347
+ function telemetrySafe(label, action) {
348
+ try {
349
+ return action();
350
+ } catch {
351
+ reportOnce(`Telemetry ${label} failed`);
352
+ return void 0;
353
+ }
354
+ }
355
+ function currentCall() {
356
+ return telemetrySafe("context", () => als.getStore());
357
+ }
358
+ function update(action) {
359
+ telemetrySafe("context", () => {
360
+ const record = currentCall();
361
+ if (record && !record.closed)
362
+ action(record);
363
+ });
364
+ }
365
+ function markEntered() {
366
+ update((record) => {
367
+ record.entered = true;
368
+ });
369
+ }
370
+ function setOutcome(value, kind) {
371
+ update((record) => {
372
+ record.outcome = value;
373
+ delete record.error_kind;
374
+ delete record.error_code;
375
+ delete record.http_status;
376
+ if (kind)
377
+ record.error_kind = kind;
378
+ });
379
+ }
380
+ function markSuccess() {
381
+ setOutcome("success");
382
+ }
383
+ function markRejected(kind) {
384
+ setOutcome("rejected", kind);
385
+ }
386
+ function markInternalError() {
387
+ setOutcome("internal_error", "exception");
388
+ }
389
+ function markToolError(error) {
390
+ update((record) => {
391
+ setOutcome("tool_error");
392
+ const kind = sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", "error_kind", error.kind);
393
+ record.error_kind = kind ?? "unclassified";
394
+ const code = sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", "error_code", error.serverCode);
395
+ if (code)
396
+ record.error_code = code;
397
+ const status = error.status;
398
+ if (typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599) {
399
+ record.http_status = status;
400
+ }
401
+ });
402
+ }
403
+ function apiStart() {
404
+ update((record) => {
405
+ record.api_calls++;
406
+ if (record.api_inflight++ === 0)
407
+ record.api_busy_since = record.now();
408
+ });
409
+ }
410
+ function apiEnd() {
411
+ update((record) => {
412
+ if (record.api_inflight === 0)
413
+ return;
414
+ if (--record.api_inflight !== 0)
415
+ return;
416
+ record.api_ms_acc += Math.max(0, record.now() - (record.api_busy_since ?? record.now()));
417
+ delete record.api_busy_since;
418
+ });
419
+ }
70
420
 
71
421
  // ../mcp-core/build/client/joyApi.js
72
422
  var JoyApiError = class extends Error {
73
423
  kind;
74
424
  modelMessage;
75
425
  status;
76
- constructor(kind, modelMessage, status) {
426
+ serverCode;
427
+ constructor(kind, modelMessage, status, serverCode) {
77
428
  super(modelMessage);
78
429
  this.kind = kind;
79
430
  this.modelMessage = modelMessage;
80
431
  this.status = status;
432
+ this.serverCode = serverCode;
81
433
  this.name = "JoyApiError";
82
434
  }
83
435
  };
@@ -110,20 +462,20 @@ function extractServerCode(body) {
110
462
  }
111
463
  return void 0;
112
464
  }
113
- function classifyError(status, serverMessage, retryAfter, credentialHint) {
465
+ function classifyError(status, serverMessage, retryAfter, credentialHint, serverCode) {
114
466
  const detail = serverMessage ? ` Server said: "${serverMessage}".` : "";
115
467
  if (status === 401) {
116
- return new JoyApiError("auth", `Authentication failed \u2014 the app key or credential is wrong.${detail} This is a server configuration problem; do NOT retry. Ask the merchant to re-check ${credentialHint} in the MCP config.`, status);
468
+ return new JoyApiError("auth", `Authentication failed \u2014 the app key or credential is wrong.${detail} This is a server configuration problem; do NOT retry. Ask the merchant to re-check ${credentialHint} in the MCP config.`, status, serverCode);
117
469
  }
118
470
  if (status === 403) {
119
471
  const text = (serverMessage ?? "").toLowerCase();
120
472
  if (text.includes("plan")) {
121
- return new JoyApiError("plan_gate", `The shop's subscription plan does not include REST API access (Advanced plan or higher is required).${detail} Do NOT retry \u2014 the merchant must upgrade their Joy plan first.`, status);
473
+ return new JoyApiError("plan_gate", `The shop's subscription plan does not include REST API access (Advanced plan or higher is required).${detail} Do NOT retry \u2014 the merchant must upgrade their Joy plan first.`, status, serverCode);
122
474
  }
123
- return new JoyApiError("forbidden", `This operation is not permitted with the current credential (read-only key or insufficient scope).${detail} Do NOT retry with the same credential.`, status);
475
+ return new JoyApiError("forbidden", `This operation is not permitted with the current credential (read-only key or insufficient scope).${detail} Do NOT retry with the same credential.`, status, serverCode);
124
476
  }
125
477
  if (status === 402) {
126
- return new JoyApiError("plan_gate", `The shop's plan does not include REST API or MCP access.${detail} Do NOT retry \u2014 tell the merchant to upgrade their Joy plan; the response's upgrade_url links straight to their subscription page.`, status);
478
+ return new JoyApiError("plan_gate", `The shop's plan does not include REST API or MCP access.${detail} Do NOT retry \u2014 tell the merchant to upgrade their Joy plan; the response's upgrade_url links straight to their subscription page.`, status, serverCode);
127
479
  }
128
480
  if (status === 429) {
129
481
  const wait = retryAfter ? `${retryAfter} seconds` : "~10 seconds";
@@ -133,22 +485,23 @@ function classifyError(status, serverMessage, retryAfter, credentialHint) {
133
485
  // window and a daily quota), so any number written into this string is wrong for most
134
486
  // shops. The server states the real one in `detail`, and Retry-After carries the wait.
135
487
  `Rate limited by the Joy API.${detail} Wait ${wait} before the next call, and prefer fewer, more specific queries (e.g. filter by email instead of listing pages). Do NOT retry immediately.`,
136
- status
488
+ status,
489
+ serverCode
137
490
  );
138
491
  }
139
492
  if (status === 404) {
140
493
  if (!serverMessage) {
141
- return new JoyApiError("not_found", "This endpoint does not exist on the Joy backend this shop is connected to. Nothing is wrong with the arguments and there is no identifier to re-check \u2014 the shop is on a deployment older than this tool. Do NOT retry, do NOT try a lookup tool, and do NOT substitute data from another tool. Tell the merchant this feature is unavailable on their current Joy version.", status);
494
+ return new JoyApiError("not_found", "This endpoint does not exist on the Joy backend this shop is connected to. Nothing is wrong with the arguments and there is no identifier to re-check \u2014 the shop is on a deployment older than this tool. Do NOT retry, do NOT try a lookup tool, and do NOT substitute data from another tool. Tell the merchant this feature is unavailable on their current Joy version.", status, serverCode);
142
495
  }
143
- return new JoyApiError("not_found", `Not found.${detail} Verify the identifier and try a lookup tool first (e.g. joy_find_member) instead of guessing IDs.`, status);
496
+ return new JoyApiError("not_found", `Not found.${detail} Verify the identifier and try a lookup tool first (e.g. joy_find_member) instead of guessing IDs.`, status, serverCode);
144
497
  }
145
498
  if (status === 400 || status === 422) {
146
- return new JoyApiError("validation", `Invalid request.${detail} Fix the named parameter and call again.`, status);
499
+ return new JoyApiError("validation", `Invalid request.${detail} Fix the named parameter and call again.`, status, serverCode);
147
500
  }
148
501
  if (status === 409) {
149
- return new JoyApiError("conflict", `Conflict.${detail} Another attempt with the same Idempotency-Key is still in flight \u2014 wait ~30 seconds and call again with the SAME idempotency_key to retrieve its result. Never switch to a new key for the same operation; if this persists for minutes, verify the outcome via joy_get_member_activity instead.`, status);
502
+ return new JoyApiError("conflict", `Conflict.${detail} Another attempt with the same Idempotency-Key is still in flight \u2014 wait ~30 seconds and call again with the SAME idempotency_key to retrieve its result. Never switch to a new key for the same operation; if this persists for minutes, verify the outcome via joy_get_member_activity instead.`, status, serverCode);
150
503
  }
151
- return new JoyApiError("server", `Joy API internal error (HTTP ${status}).${detail} Safe to retry ONCE after a short wait; if it persists, stop and report the failure to the merchant.`, status);
504
+ return new JoyApiError("server", `Joy API internal error (HTTP ${status}).${detail} Safe to retry ONCE after a short wait; if it persists, stop and report the failure to the merchant.`, status, serverCode);
152
505
  }
153
506
  function errorFromOkBody(body) {
154
507
  if (!body || typeof body !== "object")
@@ -160,12 +513,28 @@ function errorFromOkBody(body) {
160
513
  const message = extractServerMessage(record);
161
514
  const code = extractServerCode(record);
162
515
  if (code === "SHOP_UNINSTALLED" || /uninstall/i.test(message ?? "")) {
163
- return new JoyApiError("forbidden", "The shop has uninstalled the Joy app \u2014 no loyalty data is available. Stop and inform the merchant; do NOT retry.");
516
+ return new JoyApiError("forbidden", "The shop has uninstalled the Joy app \u2014 no loyalty data is available. Stop and inform the merchant; do NOT retry.", void 0, telemetryServerCode(record));
164
517
  }
165
518
  if (code === "VALIDATION_ERROR") {
166
- return new JoyApiError("validation", `Invalid request${message ? `: "${message}"` : ""}. Fix the named parameter and call again.`);
519
+ return new JoyApiError("validation", `Invalid request${message ? `: "${message}"` : ""}. Fix the named parameter and call again.`, void 0, telemetryServerCode(record));
167
520
  }
168
- return new JoyApiError("server", `The Joy API reported an error: "${message ?? "unknown error"}". Do not treat this as an empty result. First re-check your parameters against the message; if they look correct, retry ONCE, then report the failure to the merchant.`);
521
+ return new JoyApiError("server", `The Joy API reported an error: "${message ?? "unknown error"}". Do not treat this as an empty result. First re-check your parameters against the message; if they look correct, retry ONCE, then report the failure to the merchant.`, void 0, telemetryServerCode(record));
522
+ }
523
+ function telemetryServerCode(body) {
524
+ const record = body && typeof body === "object" ? body : {};
525
+ return sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", "error_code", extractServerCode(body)) ?? sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", "error_code", record.error);
526
+ }
527
+ function authHeaders(config) {
528
+ const headers = {
529
+ "Content-Type": "application/json",
530
+ "X-Joy-Loyalty-App-Key": config.appKey
531
+ };
532
+ if (config.privateBotKey) {
533
+ headers["X-Joy-Private-Bot-Key"] = config.privateBotKey;
534
+ } else if (config.secretKey) {
535
+ headers["X-Joy-Loyalty-Secret-Key"] = config.secretKey;
536
+ }
537
+ return headers;
169
538
  }
170
539
  var JoyApi = class {
171
540
  config;
@@ -176,16 +545,7 @@ var JoyApi = class {
176
545
  return this.config.mode === "internal-bot" ? "JOY_APP_KEY and JOY_PRIVATE_BOT_KEY" : "JOY_APP_KEY and JOY_SECRET_KEY";
177
546
  }
178
547
  headers() {
179
- const headers = {
180
- "Content-Type": "application/json",
181
- "X-Joy-Loyalty-App-Key": this.config.appKey
182
- };
183
- if (this.config.privateBotKey) {
184
- headers["X-Joy-Private-Bot-Key"] = this.config.privateBotKey;
185
- } else if (this.config.secretKey) {
186
- headers["X-Joy-Loyalty-Secret-Key"] = this.config.secretKey;
187
- }
188
- return headers;
548
+ return authHeaders(this.config);
189
549
  }
190
550
  async request(path, options = {}) {
191
551
  const { method = "GET", query, body, timeoutMs = DEFAULT_TIMEOUT_MS, headers } = options;
@@ -194,49 +554,57 @@ var JoyApi = class {
194
554
  if (value !== void 0)
195
555
  url.searchParams.set(key, String(value));
196
556
  }
197
- let response;
557
+ const record = currentCall();
558
+ if (record)
559
+ apiStart();
198
560
  try {
199
- response = await fetch(url, {
200
- method,
201
- headers: { ...headers, ...this.headers() },
202
- body: body === void 0 ? void 0 : JSON.stringify(body),
203
- // fetch strips authorization on cross-origin redirects but NOT custom
204
- // headers — an open redirect would forward the shop key elsewhere
205
- redirect: "error",
206
- signal: AbortSignal.timeout(timeoutMs)
207
- });
208
- } catch (error) {
209
- if (error instanceof Error && error.name === "TimeoutError") {
210
- throw new JoyApiError("timeout", `The Joy API did not respond within ${Math.round(timeoutMs / 1e3)}s. The first call after an idle period pays a Cloud Functions cold start, so a lone timeout is usually transient rather than an outage. Safe to retry ONCE; if it times out again, report the outage instead of retrying further.`);
561
+ let response;
562
+ try {
563
+ response = await fetch(url, {
564
+ method,
565
+ headers: { ...headers, ...record?.headers, ...this.headers() },
566
+ body: body === void 0 ? void 0 : JSON.stringify(body),
567
+ // fetch strips authorization on cross-origin redirects but NOT custom
568
+ // headers — an open redirect would forward the shop key elsewhere
569
+ redirect: "error",
570
+ signal: AbortSignal.timeout(timeoutMs)
571
+ });
572
+ } catch (error) {
573
+ if (error instanceof Error && error.name === "TimeoutError") {
574
+ throw new JoyApiError("timeout", `The Joy API did not respond within ${Math.round(timeoutMs / 1e3)}s. The first call after an idle period pays a Cloud Functions cold start, so a lone timeout is usually transient rather than an outage. Safe to retry ONCE; if it times out again, report the outage instead of retrying further.`);
575
+ }
576
+ throw new JoyApiError("network", `Could not reach the Joy API at ${this.config.apiBaseUrl}. Check network connectivity and JOY_API_BASE_URL. Safe to retry ONCE.`);
211
577
  }
212
- throw new JoyApiError("network", `Could not reach the Joy API at ${this.config.apiBaseUrl}. Check network connectivity and JOY_API_BASE_URL. Safe to retry ONCE.`);
213
- }
214
- let parsed;
215
- try {
216
- parsed = await response.json();
217
- } catch {
218
- parsed = void 0;
219
- }
220
- if (!response.ok) {
221
- throw classifyError(response.status, extractServerMessage(parsed), response.headers.get("Retry-After"), this.credentialHint());
578
+ let parsed;
579
+ try {
580
+ parsed = await response.json();
581
+ } catch {
582
+ parsed = void 0;
583
+ }
584
+ if (!response.ok) {
585
+ throw classifyError(response.status, extractServerMessage(parsed), response.headers.get("Retry-After"), this.credentialHint(), telemetryServerCode(parsed));
586
+ }
587
+ const okBodyError = errorFromOkBody(parsed);
588
+ if (okBodyError)
589
+ throw okBodyError;
590
+ return parsed ?? {};
591
+ } finally {
592
+ if (record)
593
+ apiEnd();
222
594
  }
223
- const okBodyError = errorFromOkBody(parsed);
224
- if (okBodyError)
225
- throw okBodyError;
226
- return parsed ?? {};
227
595
  }
228
596
  /** GET that unwraps the {success, data, meta} envelope — agent context never sees the wrapper. */
229
597
  async getData(path, options = {}) {
230
- const envelope = await this.request(path, options);
598
+ const envelope2 = await this.request(path, options);
231
599
  return {
232
- data: envelope.data,
233
- hasNext: envelope.meta?.pagination?.hasNext === true
600
+ data: envelope2.data,
601
+ hasNext: envelope2.meta?.pagination?.hasNext === true
234
602
  };
235
603
  }
236
604
  /** POST/PUT that unwraps the envelope — for write tools. */
237
605
  async mutateData(path, options = {}) {
238
- const envelope = await this.request(path, options);
239
- return envelope.data;
606
+ const envelope2 = await this.request(path, options);
607
+ return envelope2.data;
240
608
  }
241
609
  };
242
610
 
@@ -266,16 +634,6 @@ import { z as z2 } from "zod";
266
634
  // ../mcp-core/build/toolkit.js
267
635
  import { z } from "zod";
268
636
 
269
- // ../mcp-core/build/logger.js
270
- var log = {
271
- info(message) {
272
- console.error(`[joy-mcp] ${message}`);
273
- },
274
- error(message) {
275
- console.error(`[joy-mcp:error] ${message}`);
276
- }
277
- };
278
-
279
637
  // ../mcp-core/build/store.js
280
638
  function displayName(store) {
281
639
  return store.name.replace(/[\u0000-\u001f\u007f[\]]+/g, " ").trim() || store.myshopifyDomain;
@@ -330,12 +688,15 @@ var STORE_ARG_DESCRIPTION = `The store the USER asked to change \u2014 its name
330
688
  async function runHandler(name, run, store) {
331
689
  try {
332
690
  const text = await run();
691
+ markSuccess();
333
692
  return { content: [{ type: "text", text: store ? stampResult(text, store) : text }] };
334
693
  } catch (error) {
335
694
  if (error instanceof JoyApiError) {
695
+ markToolError(error);
336
696
  const text2 = store ? stampResult(error.modelMessage, store) : error.modelMessage;
337
697
  return { isError: true, content: [{ type: "text", text: text2 }] };
338
698
  }
699
+ markInternalError();
339
700
  const message = error instanceof Error ? error.message : String(error);
340
701
  log.error(`${name} failed unexpectedly: ${message}`);
341
702
  const text = `Unexpected error in ${name}: ${message}. This is likely a bug in the Joy MCP server, not your input \u2014 report it to the merchant rather than retrying.`;
@@ -345,8 +706,17 @@ async function runHandler(name, run, store) {
345
706
  };
346
707
  }
347
708
  }
709
+ function withTelemetry(ctx, name, callback) {
710
+ if (!ctx.telemetry)
711
+ return callback;
712
+ telemetrySafe("expose", () => ctx.telemetry?.expose(name));
713
+ return ((...args) => {
714
+ markEntered();
715
+ return callback(...args);
716
+ });
717
+ }
348
718
  function registerReadTool(server, ctx, spec) {
349
- const callback = (async (args) => runHandler(spec.name, () => spec.handler(args ?? {}), ctx.store));
719
+ const callback = withTelemetry(ctx, spec.name, (async (args) => runHandler(spec.name, () => spec.handler(args ?? {}), ctx.store)));
350
720
  server.registerTool(spec.name, {
351
721
  title: spec.title,
352
722
  description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
@@ -375,13 +745,16 @@ function stripStoreArg(args) {
375
745
  }
376
746
  function registerConfigWriteTool(server, ctx, spec) {
377
747
  const strictStore = ctx.store && ctx.requireStoreArg ? ctx.store : void 0;
378
- const callback = (async (args) => {
748
+ const callback = withTelemetry(ctx, spec.name, (async (args) => {
379
749
  if (strictStore) {
380
750
  const mismatch = checkStoreMismatch(spec.name, args, strictStore);
381
- if (mismatch)
751
+ if (mismatch) {
752
+ markRejected("store_mismatch");
382
753
  return mismatch;
754
+ }
383
755
  }
384
756
  if (!ctx.config.configWritesEnabled) {
757
+ markRejected("config_writes_disabled");
385
758
  const text = `${spec.name} is disabled: this server cannot change shop configuration. The merchant must set BOTH JOY_MCP_ENABLE_WRITES="true" and JOY_MCP_ENABLE_CONFIG_WRITES="true" (quoted strings, with their secret key) in the MCP config. Do not retry until they have \u2014 and do not fall back to a member-level write tool to approximate a configuration change.`;
386
759
  return {
387
760
  isError: true,
@@ -390,7 +763,7 @@ function registerConfigWriteTool(server, ctx, spec) {
390
763
  }
391
764
  const handlerArgs = strictStore ? stripStoreArg(args) : args ?? {};
392
765
  return runHandler(spec.name, () => spec.handler(handlerArgs), ctx.store);
393
- });
766
+ }));
394
767
  const registration = {
395
768
  title: spec.title,
396
769
  description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
@@ -410,13 +783,16 @@ function registerConfigWriteTool(server, ctx, spec) {
410
783
  }
411
784
  function registerWriteTool(server, ctx, spec) {
412
785
  const strictStore = ctx.store && ctx.requireStoreArg ? ctx.store : void 0;
413
- const callback = (async (args) => {
786
+ const callback = withTelemetry(ctx, spec.name, (async (args) => {
414
787
  if (strictStore) {
415
788
  const mismatch = checkStoreMismatch(spec.name, args, strictStore);
416
- if (mismatch)
789
+ if (mismatch) {
790
+ markRejected("store_mismatch");
417
791
  return mismatch;
792
+ }
418
793
  }
419
794
  if (!ctx.config.writesEnabled) {
795
+ markRejected("writes_disabled");
420
796
  const text = `${spec.name} is disabled: this server is running read-only. The merchant must set JOY_MCP_ENABLE_WRITES=true (with their secret key) in the MCP config to allow write operations. Do not retry until they have.`;
421
797
  return {
422
798
  isError: true,
@@ -425,7 +801,7 @@ function registerWriteTool(server, ctx, spec) {
425
801
  }
426
802
  const handlerArgs = strictStore ? stripStoreArg(args) : args ?? {};
427
803
  return runHandler(spec.name, () => spec.handler(handlerArgs), ctx.store);
428
- });
804
+ }));
429
805
  server.registerTool(spec.name, {
430
806
  title: spec.title,
431
807
  description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
@@ -503,6 +879,9 @@ var MEMBER_DETAIL_KEYS = [
503
879
  "pointsRemain",
504
880
  "tierPoint",
505
881
  "pendingPoint",
882
+ // REST v2 detail fields, plus computed pointExpiration on the single-customer GETs.
883
+ "pointExpiration",
884
+ "pointExpirationUnavailable",
506
885
  "totalSpent",
507
886
  "totalEarnedPoints",
508
887
  "tierId",
@@ -545,6 +924,8 @@ var REWARD_KEYS = [
545
924
  "orderReqAmount",
546
925
  "expiredAt",
547
926
  "usedAt",
927
+ "isRevokedCode",
928
+ "revokedAt",
548
929
  "createdAt"
549
930
  ];
550
931
  var memberIdentifierSchema = {
@@ -592,13 +973,24 @@ function registerMemberTools(server, ctx) {
592
973
  registerReadTool(server, ctx, {
593
974
  name: "joy_find_member",
594
975
  title: "Find a loyalty member",
595
- description: 'Look up ONE loyalty member by email or Shopify customer ID and return their points balance, VIP tier, and lifetime stats. Use this first whenever the merchant refers to a specific customer. Email matching is exact (case-insensitive) \u2014 if it misses, confirm spelling before concluding the customer is not a member. Field notes: "point" is the CURRENT spendable balance; "totalEarnedPoints" is lifetime earned (not spendable); "pointsRemain" is points needed to reach the next tier. Shopify store credit (money) is NOT included here \u2014 use joy_get_member_store_credit for that.',
976
+ description: `Look up ONE loyalty member by email or Shopify customer ID and return their points balance, VIP tier, and lifetime stats. Use this first whenever the merchant refers to a specific customer. Email matching is exact (case-insensitive) \u2014 if it misses, confirm spelling before concluding the customer is not a member. Field notes: "point" is the CURRENT spendable balance; "totalEarnedPoints" is lifetime earned (not spendable); "pointsRemain" is points needed to reach the next tier. Shopify store credit (money) is NOT included here \u2014 use joy_get_member_store_credit for that. "pointExpiration" is the NEXT scheduled expiry: nextExpiryAt (ISO date) and nextExpiryPoints (how many points expire then). It is omitted when the shop's points never expire, the balance is 0, or no expiry is scheduled. If "pointExpirationUnavailable" is true the expiry could not be loaded \u2014 do not claim points never expire.`,
596
977
  inputSchema: memberIdentifierSchema,
597
978
  handler: async (args) => {
598
- const member = await resolveMember(ctx.api, args);
979
+ let member = await resolveMember(ctx.api, args);
980
+ if (!args.shopify_customer_id && member.id && !Object.hasOwn(member, "pointExpiration")) {
981
+ try {
982
+ const { data } = await ctx.api.getData(`/customers/${encodeURIComponent(String(member.id))}`);
983
+ if (data && typeof data === "object")
984
+ member = data;
985
+ } catch {
986
+ member = { ...member, pointExpirationUnavailable: true };
987
+ }
988
+ }
599
989
  const shaped = compact(member, MEMBER_DETAIL_KEYS);
600
990
  const tier = member.tierName ? `, tier ${String(member.tierName)}` : "";
601
- return summaryWithJson(`Member ${memberDisplayName(member)}: ${formatPoints(member.point)} points${tier}.`, shaped);
991
+ const pointExpiration = member.pointExpiration && typeof member.pointExpiration === "object" ? member.pointExpiration : void 0;
992
+ const expiry = pointExpiration && typeof pointExpiration.nextExpiryAt === "string" && typeof pointExpiration.nextExpiryPoints === "number" ? `, ${formatPoints(pointExpiration.nextExpiryPoints)} expire on ${pointExpiration.nextExpiryAt.slice(0, 10)}` : "";
993
+ return summaryWithJson(`Member ${memberDisplayName(member)}: ${formatPoints(member.point)} points${tier}${expiry}.`, shaped);
602
994
  }
603
995
  });
604
996
  registerReadTool(server, ctx, {
@@ -647,9 +1039,9 @@ function registerMemberTools(server, ctx) {
647
1039
  const direction = args.direction ?? "desc";
648
1040
  const withTotal = args.include_total_count === true;
649
1041
  const sort = MEMBER_SORTS[sortBy];
650
- let envelope;
1042
+ let envelope2;
651
1043
  try {
652
- envelope = await ctx.api.request("/customers", {
1044
+ envelope2 = await ctx.api.request("/customers", {
653
1045
  query: {
654
1046
  limit,
655
1047
  after: args.after,
@@ -662,10 +1054,10 @@ function registerMemberTools(server, ctx) {
662
1054
  reclassifyCursorError(error, args.after);
663
1055
  }
664
1056
  const result = {
665
- data: envelope.data,
666
- hasNext: envelope.meta?.pagination?.hasNext === true
1057
+ data: envelope2.data,
1058
+ hasNext: envelope2.meta?.pagination?.hasNext === true
667
1059
  };
668
- const totalMembers = withTotal ? envelope.meta?.pagination?.total : void 0;
1060
+ const totalMembers = withTotal ? envelope2.meta?.pagination?.total : void 0;
669
1061
  const members = Array.isArray(result.data) ? result.data : [];
670
1062
  if (members.length === 0) {
671
1063
  if (args.after)
@@ -730,7 +1122,7 @@ function registerMemberTools(server, ctx) {
730
1122
  registerReadTool(server, ctx, {
731
1123
  name: "joy_get_member_rewards",
732
1124
  title: "Get a member's reward/coupon history",
733
- description: `Coupons and rewards ONE member has already redeemed \u2014 code, discount program, status (active/used/expired), expiry. This is the member's OWN history, NOT the shop's catalog of what CAN be redeemed (use joy_list_redemption_rules for that). Use to answer "what coupons does this customer have?" or "did they already use that discount code?".`,
1125
+ description: `Coupons and rewards ONE member has already redeemed \u2014 code, discount program, status (active/used/expired/revoked), expiry. A revoked coupon has discountStatus "revoked" plus isRevokedCode/revokedAt: its points were refunded and the code no longer works. This is the member's OWN history, NOT the shop's catalog of what CAN be redeemed (use joy_list_redemption_rules for that). Use to answer "what coupons does this customer have?" or "did they already use that discount code?".`,
734
1126
  inputSchema: {
735
1127
  ...memberIdentifierSchema,
736
1128
  limit: z2.number().int().min(1).max(50).optional().describe("Rewards per page (default 10, max 50)."),
@@ -1514,13 +1906,13 @@ var reportDateRangeSchema = {
1514
1906
  var SCOPE_HELP = "A 403 mentioning read_reports means the MERCHANT has not granted Shopify reports access to Joy \u2014 not a credential problem, do not retry; ask them to grant it from Joy settings.";
1515
1907
  function withScopeGuidance(error) {
1516
1908
  if (error instanceof JoyApiError && error.status === 403 && /read_reports/i.test(error.message)) {
1517
- return new JoyApiError("forbidden", "This report needs the Shopify read_reports permission, which the merchant has not granted to Joy yet. This is NOT a credential problem \u2014 do NOT retry. Ask the merchant to grant reports access from the Joy admin settings, then call again.", 403);
1909
+ return new JoyApiError("forbidden", "This report needs the Shopify read_reports permission, which the merchant has not granted to Joy yet. This is NOT a credential problem \u2014 do NOT retry. Ask the merchant to grant reports access from the Joy admin settings, then call again.", 403, error.serverCode);
1518
1910
  }
1519
1911
  return error;
1520
1912
  }
1521
1913
  function withV2EnablementGuidance(error) {
1522
1914
  if (error instanceof JoyApiError && error.status === 409 && /Analytics V2 is not enabled/i.test(error.message)) {
1523
- return new JoyApiError("conflict", "This shop is still on Analytics V1 \u2014 Analytics V2 report data does not exist yet, so there are NO numbers to quote (do not report zeros). Ask the merchant if they want to switch to the new Analytics; switching also backfills their historical data. If they agree and the joy_enable_analytics_v2 tool is available, call it; otherwise tell them to switch in Joy admin \u2192 Analytics. Do not retry this report until V2 is enabled.", 409);
1915
+ return new JoyApiError("conflict", "This shop is still on Analytics V1 \u2014 Analytics V2 report data does not exist yet, so there are NO numbers to quote (do not report zeros). Ask the merchant if they want to switch to the new Analytics; switching also backfills their historical data. If they agree and the joy_enable_analytics_v2 tool is available, call it; otherwise tell them to switch in Joy admin \u2192 Analytics. Do not retry this report until V2 is enabled.", 409, error.serverCode);
1524
1916
  }
1525
1917
  return error;
1526
1918
  }
@@ -1618,7 +2010,7 @@ ${summary}`);
1618
2010
  });
1619
2011
  } catch (error) {
1620
2012
  if (error instanceof JoyApiError && error.status === 422) {
1621
- throw new JoyApiError("validation", "Tier performance needs the Joy tier metafield definition, which is currently DISABLED on this shop. This is a setup gate, not empty data and not a parameter problem \u2014 do not retry. Ask the merchant to enable it in Joy \u2192 Settings \u2192 General \u2192 Metafield section, then call again.", 422);
2013
+ throw new JoyApiError("validation", "Tier performance needs the Joy tier metafield definition, which is currently DISABLED on this shop. This is a setup gate, not empty data and not a parameter problem \u2014 do not retry. Ask the merchant to enable it in Joy \u2192 Settings \u2192 General \u2192 Metafield section, then call again.", 422, error.serverCode);
1622
2014
  }
1623
2015
  throw error;
1624
2016
  }
@@ -1678,19 +2070,19 @@ function resolveIdempotencyKey(provided) {
1678
2070
  }
1679
2071
  function rethrowWithRetryKey(error, idempotencyKey) {
1680
2072
  if (error instanceof JoyApiError && (error.kind === "timeout" || error.kind === "network" || error.kind === "server")) {
1681
- throw new JoyApiError(error.kind, `${error.modelMessage} IMPORTANT: this was a write \u2014 it may or may not have been applied. Do NOT assume failure and do NOT retry with a fresh call. To retry safely, call the same tool again with idempotency_key: "${idempotencyKey}" within 24 hours (keys expire after that) \u2014 the backend will return the original result instead of applying the change twice.`, error.status);
2073
+ throw new JoyApiError(error.kind, `${error.modelMessage} IMPORTANT: this was a write \u2014 it may or may not have been applied. Do NOT assume failure and do NOT retry with a fresh call. To retry safely, call the same tool again with idempotency_key: "${idempotencyKey}" within 24 hours (keys expire after that) \u2014 the backend will return the original result instead of applying the change twice.`, error.status, error.serverCode);
1682
2074
  }
1683
2075
  throw error;
1684
2076
  }
1685
2077
  function rethrowRedeemUncertain(error) {
1686
2078
  if (error instanceof JoyApiError && (error.kind === "timeout" || error.kind === "network" || error.kind === "server")) {
1687
- throw new JoyApiError(error.kind, `The redeem request failed with an UNCERTAIN outcome (${error.kind}) \u2014 it may have completed anyway, and redeem has NO retry protection. Do NOT retry now. First call joy_get_member_activity for this member and check for a redemption in the last few minutes: if one exists, the redeem succeeded (do not repeat it); only if none exists is it safe to try again.`, error.status);
2079
+ throw new JoyApiError(error.kind, `The redeem request failed with an UNCERTAIN outcome (${error.kind}) \u2014 it may have completed anyway, and redeem has NO retry protection. Do NOT retry now. First call joy_get_member_activity for this member and check for a redemption in the last few minutes: if one exists, the redeem succeeded (do not repeat it); only if none exists is it safe to try again.`, error.status, error.serverCode);
1688
2080
  }
1689
2081
  throw error;
1690
2082
  }
1691
2083
  function rethrowRefundUncertain(error) {
1692
2084
  if (error instanceof JoyApiError && (error.kind === "timeout" || error.kind === "network" || error.kind === "server")) {
1693
- throw new JoyApiError(error.kind, `The refund request failed with an UNCERTAIN outcome (${error.kind}) \u2014 it may have completed anyway, and refund has NO retry protection. Do NOT retry now. First call joy_get_member_activity for this member and look for a refund_point_redeem entry with this coupon code: if one exists, the refund succeeded and points were already restored (do not award them again). If the retry itself returns "coupon does not exist or has expired", that most likely means the first attempt SUCCEEDED \u2014 a revoked coupon no longer shows up in lookups.`, error.status);
2085
+ throw new JoyApiError(error.kind, `The refund request failed with an UNCERTAIN outcome (${error.kind}) \u2014 it may have completed anyway, and refund has NO retry protection. Do NOT retry now. First call joy_get_member_activity for this member and look for a refund_point_redeem entry with this coupon code: if one exists, the refund succeeded and points were already restored (do not award them again). If the retry itself returns "coupon does not exist or has expired", that most likely means the first attempt SUCCEEDED \u2014 a revoked coupon no longer shows up in lookups.`, error.status, error.serverCode);
1694
2086
  }
1695
2087
  throw error;
1696
2088
  }
@@ -2268,19 +2660,19 @@ import { z as z13 } from "zod";
2268
2660
  function summarizeDesign(data) {
2269
2661
  const sections = data.design?.sections ?? [];
2270
2662
  const on = sections.filter((section) => section.enabled !== false).map((section) => section.id);
2271
- const legacyWarning = data.renderPath === "legacy-blocks" ? `\u26A0 The storefront loyalty page renders through legacy blocks, so Loyalty Page V2 design changes will not appear there. The merchant must open ${data.v2BlockAddUrl || "the Loyalty Page V2 block link from joy_check_theme_setup"}, add the Loyalty Page V2 block, and save the theme. ` : "";
2272
- let live = "The shop still serves the older Loyalty Page V1, so changes here are not visible to customers yet.";
2663
+ const legacyWarning = data.renderPath === "legacy-blocks" ? `\u26A0 The storefront loyalty page renders through legacy blocks, so new loyalty page design changes will not appear there. The merchant must open ${data.v2BlockAddUrl || "the new loyalty page block link from joy_check_theme_setup"}, add the Joy: New Loyalty Page block, and save the theme. ` : "";
2664
+ let live = "The shop still serves the classic loyalty page, so changes here are not visible to customers yet.";
2273
2665
  if (data.pageVersionActive && (data.renderPath === "v2-block" || data.renderPath === void 0)) {
2274
2666
  live = "This page IS the shop\u2019s live loyalty page \u2014 changes are visible to customers.";
2275
2667
  }
2276
2668
  if (data.pageVersionActive && data.renderPath === "legacy-blocks") {
2277
- live = "Loyalty Page V2 is active for this shop.";
2669
+ live = "The new loyalty page is active for this shop.";
2278
2670
  }
2279
2671
  if (data.pageVersionActive && data.renderPath === "none") {
2280
- live = "Loyalty Page V2 is active for this shop, but no loyalty page block was detected in the live theme, so changes are not visible to customers.";
2672
+ live = "The new loyalty page is active for this shop, but no loyalty page block was detected in the live theme, so changes are not visible to customers.";
2281
2673
  }
2282
2674
  const css = data.canEditCustomCss ? "" : " Custom CSS is not available on this shop\u2019s plan.";
2283
- return `${legacyWarning}Loyalty Page V2 design. Sections on: ${on.join(", ") || "none"}. ${live}${css}`;
2675
+ return `${legacyWarning}New loyalty page design. Sections on: ${on.join(", ") || "none"}. ${live}${css}`;
2284
2676
  }
2285
2677
  function summarizeAppliedChanges(applied2, published, warnings2 = []) {
2286
2678
  const headline = applied2.slice(0, 5).map((change) => `${change.field}: ${String(change.from ?? "\u2014")} \u2192 ${String(change.to)}`).join("; ");
@@ -2293,7 +2685,7 @@ function registerLoyaltyPageTools(server, ctx) {
2293
2685
  registerReadTool(server, ctx, {
2294
2686
  name: "joy_get_loyalty_page_design",
2295
2687
  title: "Get loyalty page design",
2296
- description: "Read the shop's Loyalty Page V2 appearance \u2014 page theme (colours, fonts, radii, widths), which sections are switched on, and each section's layout and card styling. It also audits whether the live theme renders this design through the V2 block, legacy blocks, or no loyalty page block; when legacy blocks are present, the result leads with the server-provided theme-editor link the merchant must use to add and save the V2 block. This read-only tool cannot add or enable a theme block itself. Also returns `writableFields`: the exact field names joy_update_loyalty_page_design accepts, per section. CALL THIS BEFORE any design change \u2014 the field vocabulary is large and specific, and guessing a name costs a failed call. This is appearance only: page text (headings, FAQ questions, program descriptions) is NOT here and cannot be changed through these tools.",
2688
+ description: "Read the shop's new loyalty page appearance \u2014 page theme (colours, fonts, radii, widths), which sections are switched on, and each section's layout and card styling. It also audits whether the live theme renders this design through the new loyalty page block, legacy blocks, or no loyalty page block; when legacy blocks are present, the result leads with the server-provided theme-editor link the merchant must use to add and save the new loyalty page block. This read-only tool cannot add or enable a theme block itself. Also returns `writableFields`: the exact field names joy_update_loyalty_page_design accepts, per section. CALL THIS BEFORE any design change \u2014 the field vocabulary is large and specific, and guessing a name costs a failed call. This is appearance only: page text (headings, FAQ questions, program descriptions) is NOT here and cannot be changed through these tools.",
2297
2689
  inputSchema: {},
2298
2690
  handler: async () => {
2299
2691
  const { data } = await ctx.api.getData("/loyalty-page/design");
@@ -2310,7 +2702,7 @@ function registerLoyaltyPageWriteTools(server, ctx) {
2310
2702
  // identical call is not a no-op for what revert would restore.
2311
2703
  destructiveHint: false,
2312
2704
  idempotentHint: false,
2313
- description: "Change the appearance of the shop's Loyalty Page V2, including its media and program or tier artwork. Send ONLY the fields you are changing \u2014 anything you omit keeps its current value. WARNING: the `icons` branch has no draft \u2014 program and tier artwork is live data, so those changes reach shoppers immediately even when publish is false, and they also appear anywhere else that reads the program or tier. Tier artwork is dual-written to `imageBlock` for the loyalty page and `icon` for the tier-achieved email, membership card Widget V4, and account block: changing a tier icon changes those email and widget surfaces too, not just this page. For image artwork, first call joy_upload_image, then pass its returned URL to `hero.backgroundImage`, `hero.mobileBackgroundImage`, `inlineBanner.backgroundImage`, `inlineBanner.mobileBackgroundImage`, `earn.bannerImage`, `referral.image`, or `icons`; the other media fields are `hero.backgroundVideo` and `hero.mobileBackgroundVideo`. Image URLs must use http: or https: and contain no username or password; video URLs must use https: and end in .mp4. For a storefront-based starting point, send `applyScan` inside `design` in this tool \u2014 there is no separate apply-scan tool. Call joy_get_loyalty_page_design first and use the field names from its `writableFields`; a name outside that list is refused with the offending path rather than silently ignored. Saves to the DRAFT: the merchant sees it in their loyalty page editor immediately, and customers see it when they publish. Iterate freely here \u2014 refining a design costs nothing. Only pass publish=true when the merchant has asked for the change to go live, because publishing writes a Shopify metafield and re-syncs the FAQ projection every time. Undo with joy_revert_loyalty_page_design. Appearance only \u2014 this cannot change any text on the page.",
2705
+ description: "Change the appearance of the shop's new loyalty page, including its media and program or tier artwork. Send ONLY the fields you are changing \u2014 anything you omit keeps its current value. WARNING: the `icons` branch has no draft \u2014 program and tier artwork is live data, so those changes reach shoppers immediately even when publish is false, and they also appear anywhere else that reads the program or tier. Tier artwork is dual-written to `imageBlock` for the loyalty page and `icon` for the tier-achieved email, membership card Widget V4, and account block: changing a tier icon changes those email and widget surfaces too, not just this page. For image artwork, first call joy_upload_image, then pass its returned URL to `hero.backgroundImage`, `hero.mobileBackgroundImage`, `inlineBanner.backgroundImage`, `inlineBanner.mobileBackgroundImage`, `earn.bannerImage`, `referral.image`, or `icons`; the other media fields are `hero.backgroundVideo` and `hero.mobileBackgroundVideo`. Image URLs must use http: or https: and contain no username or password; video URLs must use https: and end in .mp4. For a storefront-based starting point, send `applyScan` inside `design` in this tool \u2014 there is no separate apply-scan tool. Call joy_get_loyalty_page_design first and use the field names from its `writableFields`; a name outside that list is refused with the offending path rather than silently ignored. Saves to the DRAFT: the merchant sees it in their loyalty page editor immediately, and customers see it when they publish. Iterate freely here \u2014 refining a design costs nothing. Only pass publish=true when the merchant has asked for the change to go live, because publishing writes a Shopify metafield and re-syncs the FAQ projection every time. Undo with joy_revert_loyalty_page_design. Appearance only \u2014 this cannot change any text on the page.",
2314
2706
  inputSchema: {
2315
2707
  design: z13.record(z13.unknown()).describe('The fields to change, shaped like the `design` object from joy_get_loyalty_page_design. Branches: `theme` (page-wide tokens), `sections` (array of {id, enabled?, settings:{theme}}), `content` (per-section layout/style, keyed by section id), `advanced` ({customCss}), `icons` ({earning?:{...}, spending?:{...}, tiers?:{...}|[...]}, valued with an image URL or a built-in icon name), and `applyScan` ({url?: string, storefrontPassword?: string, colors?: boolean, images?: boolean}). Address earning/spending artwork by program id OR event; address tier artwork by tier id OR tier name (`name`/`title`), or pass an array in tier order. A tier artwork write updates both `imageBlock` (loyalty page) and `icon` (tier-achieved email, membership card Widget V4, and account block), so it changes email and widget surfaces too. Read current program artwork from joy_list_earning_rules / joy_list_redemption_rules. For `applyScan`, url defaults to this shop\'s storefront, storefrontPassword is only for a gated store, colors defaults to true, and images defaults to false so a scan does not unexpectedly replace artwork. If the scan returns 409 PASSWORD_REQUIRED, ask the merchant for the storefront password and pass it as `storefrontPassword`; never guess it. If it fails with 503, do not invent colours, fonts, or button styling; ask the merchant to retry later or configure the draft manually. Scan output is design input, not visual evidence of how the page looks after the change. NOTE on precedence: a value under `sections[].settings.theme` BEATS the same key in `theme`, so recolouring a page whose sections carry overrides means patching both. Example: {"theme":{"primary":"#0F4C3A"},"content":{"earn":{"columns":2}},"icons":{"earning":{"birthday":"https://cdn.../cake.png"}}}'),
2316
2708
  publish: z13.boolean().optional().describe("Default false \u2014 the change lands on the draft, which the merchant sees in the loyalty page editor and nobody else. Pass true only when they asked for it to go live; each publish costs a Shopify metafield write, so do not send it on every tweak.")
@@ -2369,13 +2761,13 @@ function registerLoyaltyPageTextTools(server, ctx) {
2369
2761
  registerReadTool(server, ctx, {
2370
2762
  name: "joy_get_loyalty_page_text",
2371
2763
  title: "Get loyalty page text",
2372
- description: "Loyalty Page V2 text has no draft: text edits are LIVE on the storefront immediately. Read the current text for one active locale, the placeholders each value must preserve, and the stable ids/keys for steps and tier benefits. CALL THIS BEFORE joy_update_loyalty_page_text; never guess keys or item positions. Editing the primary language automatically retranslates only those changed keys into every other active locale. `points.singular` and `points.plural` are shown for context but are read-only through this tool surface.",
2764
+ description: "New loyalty page text has no draft: text edits are LIVE on the storefront immediately. Read the current text for one active locale, the placeholders each value must preserve, and the stable ids/keys for steps and tier benefits. CALL THIS BEFORE joy_update_loyalty_page_text; never guess keys or item positions. Editing the primary language automatically retranslates only those changed keys into every other active locale. `points.singular` and `points.plural` are shown for context but are read-only through this tool surface.",
2373
2765
  inputSchema: { locale: localeSchema },
2374
2766
  handler: async (args) => {
2375
2767
  const { data } = await ctx.api.getData("/loyalty-page/text", {
2376
2768
  query: { locale: args.locale }
2377
2769
  });
2378
- return summaryWithJson(`${Object.keys(data.values).length} live Loyalty Page V2 text values for ${data.locale}. Primary language: ${data.primaryLanguage}; active locales: ${data.activeLocales.join(", ")}.`, data);
2770
+ return summaryWithJson(`${Object.keys(data.values).length} live new loyalty page text values for ${data.locale}. Primary language: ${data.primaryLanguage}; active locales: ${data.activeLocales.join(", ")}.`, data);
2379
2771
  }
2380
2772
  });
2381
2773
  }
@@ -2385,7 +2777,7 @@ function registerLoyaltyPageTextWriteTools(server, ctx) {
2385
2777
  title: "Update loyalty page text",
2386
2778
  destructiveHint: true,
2387
2779
  idempotentHint: false,
2388
- description: "Loyalty Page V2 text has no draft: every accepted change is LIVE on the storefront immediately. Update only exact keys returned by joy_get_loyalty_page_text; unknown or duplicate keys are skipped. Preserve every returned placeholder unless the merchant explicitly authored the replacement, in which case pass manual: true. Editing the primary language automatically retranslates the changed keys into every other active locale in the same operation; editing a secondary locale changes only that locale. This can replace merchant-authored translations for those same keys. Undo the latest text or design update with joy_revert_loyalty_page_design.",
2780
+ description: "New loyalty page text has no draft: every accepted change is LIVE on the storefront immediately. Update only exact keys returned by joy_get_loyalty_page_text; unknown or duplicate keys are skipped. Preserve every returned placeholder unless the merchant explicitly authored the replacement, in which case pass manual: true. Editing the primary language automatically retranslates the changed keys into every other active locale in the same operation; editing a secondary locale changes only that locale. This can replace merchant-authored translations for those same keys. Undo the latest text or design update with joy_revert_loyalty_page_design.",
2389
2781
  inputSchema: {
2390
2782
  locale: localeSchema,
2391
2783
  changes: z14.array(z14.object({
@@ -2402,13 +2794,151 @@ function registerLoyaltyPageTextWriteTools(server, ctx) {
2402
2794
  });
2403
2795
  const propagation = result.translatedLocales.length ? ` Retranslated in ${result.translatedLocales.join(", ")}.` : "";
2404
2796
  const skipped = result.skipped.length ? ` ${result.skipped.length} change(s) skipped.` : "";
2405
- return summaryWithJson(`\u26A0 Loyalty Page V2 text has no draft \u2014 ${result.applied.length} text change(s) are LIVE now.${propagation}${skipped} Undo with joy_revert_loyalty_page_design.`, result);
2797
+ return summaryWithJson(`\u26A0 New loyalty page text has no draft \u2014 ${result.applied.length} text change(s) are LIVE now.${propagation}${skipped} Undo with joy_revert_loyalty_page_design.`, result);
2406
2798
  }
2407
2799
  });
2408
2800
  }
2409
2801
 
2410
- // ../mcp-core/build/tools/programsWrite.js
2802
+ // ../mcp-core/build/tools/loyaltyHub.js
2411
2803
  import { z as z15 } from "zod";
2804
+ function summarizeHubDesign(data) {
2805
+ const sections = data.design?.sections ?? [];
2806
+ const on = sections.filter((s) => s.enabled !== false).map((s) => s.id);
2807
+ return `Loyalty Hub design. Sections on: ${on.join(", ") || "none"}. The Hub is LIVE \u2014 every logged-in customer sees changes immediately. Programs, tier icons and program icons are shared with the Loyalty Page, widget and emails.`;
2808
+ }
2809
+ function summarizeHubAppliedChanges(applied2, warnings2 = []) {
2810
+ const headline = applied2.slice(0, 5).join("; ");
2811
+ const more = applied2.length > 5 ? ` (+${applied2.length - 5} more)` : "";
2812
+ const caveat = warnings2.length ? `\u26A0 ${warnings2.join(" \u26A0 ")} ` : "";
2813
+ return `${caveat}${applied2.length} design field${applied2.length === 1 ? "" : "s"} changed \u2014 ${headline}${more}. LIVE now for every logged-in customer. Undo with joy_revert_loyalty_hub_design.`;
2814
+ }
2815
+ function registerLoyaltyHubTools(server, ctx) {
2816
+ registerReadTool(server, ctx, {
2817
+ name: "joy_get_loyalty_hub_design",
2818
+ title: "Get Loyalty Hub design",
2819
+ description: "Read the shop's Loyalty Hub appearance \u2014 which sections are switched on and their layout and styling, plus the shared program and tier icons. Also returns `writableFields`: the exact field names joy_update_loyalty_hub_design accepts, with the allowed values of each. redeemButtonVariant (secondary by default, or primary) changes only voucher-card Redeem buttons. Section card border overrides are rewardsCardBorderStyle (My coupons), earnCardBorderStyle (Ways to earn), redeemCardBorderStyle (Redeem), and vipCardBorderStyle (VIP); each accepts inherit (default), solid, or dashed. Inherit follows the global border style. IMPORTANT: the Loyalty Hub has NO draft \u2014 every change is LIVE for every logged-in customer immediately. Programs, program icons and tier icons are SHARED with the Loyalty Page, widget and emails: changing them here also changes those other surfaces. CALL THIS BEFORE any design change \u2014 the field vocabulary is large and specific, and guessing a name costs a failed call. This tool reads appearance only: Hub text (headings, descriptions) is read by joy_get_loyalty_hub_text.",
2820
+ inputSchema: {},
2821
+ handler: async () => {
2822
+ const { data } = await ctx.api.getData("/loyalty-hub/design");
2823
+ return summaryWithJson(summarizeHubDesign(data), data);
2824
+ }
2825
+ });
2826
+ }
2827
+ function registerLoyaltyHubWriteTools(server, ctx) {
2828
+ registerWriteTool(server, ctx, {
2829
+ name: "joy_update_loyalty_hub_design",
2830
+ title: "Update Loyalty Hub design",
2831
+ destructiveHint: false,
2832
+ idempotentHint: true,
2833
+ description: "Change the Loyalty Hub's appearance. WARNING: the Hub has NO draft \u2014 every accepted change is LIVE for every logged-in customer immediately. Send ONLY the fields you are changing; anything omitted keeps its current value. redeemButtonVariant (secondary by default, or primary) changes only voucher-card Redeem buttons. Section card border overrides: rewardsCardBorderStyle (My coupons), earnCardBorderStyle (Ways to earn), redeemCardBorderStyle (Redeem), vipCardBorderStyle (VIP). Each accepts inherit (default), solid, or dashed and changes only card border line style. Inherit follows the global border style; border width, radius, background, density, inner separators and current-tier highlights remain unchanged. Programs, tier icons and program icons are SHARED with the Loyalty Page, widget and emails \u2014 changing them here also changes those other surfaces. If the merchant has the Hub editor open, it will ask them to reload. Call joy_get_loyalty_hub_design first and use the field names from its `writableFields`; a name outside that list is refused with the offending path rather than silently ignored. Undo with joy_revert_loyalty_hub_design. Appearance only \u2014 this cannot change any text on the Hub.",
2834
+ inputSchema: {
2835
+ design: z15.record(z15.unknown()).describe('The fields to change. Flat Hub keys named in `writableFields` from joy_get_loyalty_hub_design (section switches, layouts, visible counts, hero image, Redeem-only redeemButtonVariant: secondary|primary, section border overrides rewardsCardBorderStyle/earnCardBorderStyle/redeemCardBorderStyle/vipCardBorderStyle: inherit|solid|dashed (default inherit follows global), and the Design tab: cardBorderWidth, cardBorderStyle, cardRadius, cardBackground, density, earnIconColor as #RRGGBB), plus optional `programs` ({earning?:[{id, showLoyaltyPage}], spending?:[...]}), `programOrder` ({earning?:[program ids], spending?:[...]}, position sets the order) and `icons` ({earning?:{...}, spending?:{...}, tiers?:{...}|[...]}, valued with an image URL or a built-in icon name; address earning/spending artwork by program id OR event, tiers by tier id, tier name or array position). Read current program artwork from joy_list_earning_rules / joy_list_redemption_rules. Example: {"cardRadius":"max","earnIconColor":"#0F4C3A","icons":{"earning":{"birthday":"https://cdn.../cake.png"}}}')
2836
+ },
2837
+ handler: async (args) => {
2838
+ const result = await ctx.api.mutateData("/loyalty-hub/design", {
2839
+ method: "PATCH",
2840
+ timeoutMs: 2e4,
2841
+ body: { design: args.design }
2842
+ });
2843
+ const applied2 = result?.applied ?? [];
2844
+ if (!applied2.length) {
2845
+ return "No change \u2014 every field you sent already had that value.";
2846
+ }
2847
+ const warnings2 = result?.warnings ?? [];
2848
+ return summaryWithJson(summarizeHubAppliedChanges(applied2, warnings2), {
2849
+ applied: applied2,
2850
+ ...warnings2.length ? { warnings: warnings2 } : {}
2851
+ });
2852
+ }
2853
+ });
2854
+ registerWriteTool(server, ctx, {
2855
+ name: "joy_revert_loyalty_hub_design",
2856
+ title: "Revert the last Loyalty Hub design change",
2857
+ destructiveHint: true,
2858
+ idempotentHint: true,
2859
+ description: "Undo the last design change made through joy_update_loyalty_hub_design, restoring the Hub exactly as it was before that call \u2014 LIVE immediately for every logged-in customer. ONE step only \u2014 a second call restores the same snapshot again rather than going further back. Program and tier icons go back LIVE immediately because they are shared data with no draft. WARNING: it restores a whole saved design, so any edit the merchant made in the Hub editor AFTER that update is discarded too \u2014 if they may have been editing, ask before calling this. Use it promptly; the next update overwrites the snapshot.",
2860
+ inputSchema: {},
2861
+ handler: async () => {
2862
+ const result = await ctx.api.mutateData("/loyalty-hub/design/revert", {
2863
+ method: "POST",
2864
+ timeoutMs: 2e4,
2865
+ body: {}
2866
+ });
2867
+ const programIcons = result?.programIconsRestored ?? 0;
2868
+ const tierIcons = result?.tierIconsRestored ?? 0;
2869
+ const icons = programIcons || tierIcons ? ` ${[
2870
+ programIcons ? `${programIcons} program icon${programIcons === 1 ? "" : "s"}` : "",
2871
+ tierIcons ? `${tierIcons} tier icon${tierIcons === 1 ? "" : "s"}` : ""
2872
+ ].filter(Boolean).join(" and ")} restored \u2014 LIVE now (shared data, no draft).` : "";
2873
+ return summaryWithJson(`Reverted to the Loyalty Hub design from before the last update. LIVE immediately for every logged-in customer.${icons}`, { design: result?.design, programIconsRestored: programIcons, tierIconsRestored: tierIcons });
2874
+ }
2875
+ });
2876
+ }
2877
+
2878
+ // ../mcp-core/build/tools/loyaltyHubText.js
2879
+ import { z as z16 } from "zod";
2880
+ var localeSchema2 = z16.string().min(2).optional().describe("Active locale code. Omit to use the shop's primary language.");
2881
+ function registerLoyaltyHubTextTools(server, ctx) {
2882
+ registerReadTool(server, ctx, {
2883
+ name: "joy_get_loyalty_hub_text",
2884
+ title: "Get Loyalty Hub text",
2885
+ description: "Loyalty Hub text has no draft: text edits are LIVE on the Hub immediately. Read the current text for one active locale, the placeholders each value must preserve, and the stable ids/keys for steps and tier benefits. CALL THIS BEFORE joy_update_loyalty_hub_text; never guess keys or item positions. Editing the primary language automatically retranslates only those changed keys into every other active locale. `points.singular` and `points.plural` are shown for context but are read-only through this tool surface.",
2886
+ inputSchema: { locale: localeSchema2 },
2887
+ handler: async (args) => {
2888
+ const { data } = await ctx.api.getData("/loyalty-hub/text", {
2889
+ query: { locale: args.locale }
2890
+ });
2891
+ return summaryWithJson(`${Object.keys(data.values).length} live Loyalty Hub text values for ${data.locale}. Primary language: ${data.primaryLanguage}; active locales: ${data.activeLocales.join(", ")}.`, data);
2892
+ }
2893
+ });
2894
+ }
2895
+ function registerLoyaltyHubTextWriteTools(server, ctx) {
2896
+ registerWriteTool(server, ctx, {
2897
+ name: "joy_update_loyalty_hub_text",
2898
+ title: "Update Loyalty Hub text",
2899
+ destructiveHint: true,
2900
+ idempotentHint: false,
2901
+ description: "Loyalty Hub text has no draft: every accepted change is LIVE on the Hub immediately. Update only exact keys returned by joy_get_loyalty_hub_text; unknown or duplicate keys are skipped. Preserve every returned placeholder unless the merchant explicitly authored the replacement, in which case pass manual: true. Editing the primary language automatically retranslates the changed keys into every other active locale in the same operation; editing a secondary locale changes only that locale. This can replace merchant-authored translations for those same keys. Undo the latest Hub text change with joy_revert_loyalty_hub_text.",
2902
+ inputSchema: {
2903
+ locale: localeSchema2,
2904
+ changes: z16.array(z16.object({
2905
+ key: z16.string().min(1),
2906
+ value: z16.string(),
2907
+ manual: z16.boolean().optional().describe("Set true only when the merchant authored this exact replacement.")
2908
+ })).min(1).describe("Exact key/value edits from joy_get_loyalty_hub_text.")
2909
+ },
2910
+ handler: async (args) => {
2911
+ const result = await ctx.api.mutateData("/loyalty-hub/text", {
2912
+ method: "PATCH",
2913
+ timeoutMs: 12e4,
2914
+ body: { changes: args.changes, ...args.locale ? { locale: args.locale } : {} }
2915
+ });
2916
+ const propagation = result.translatedLocales.length ? ` Retranslated in ${result.translatedLocales.join(", ")}.` : "";
2917
+ const skipped = result.skipped.length ? ` ${result.skipped.length} change(s) skipped.` : "";
2918
+ return summaryWithJson(`\u26A0 Loyalty Hub text has no draft \u2014 ${result.applied.length} text change(s) are LIVE now.${propagation}${skipped} Undo with joy_revert_loyalty_hub_text.`, result);
2919
+ }
2920
+ });
2921
+ registerWriteTool(server, ctx, {
2922
+ name: "joy_revert_loyalty_hub_text",
2923
+ title: "Revert the last Loyalty Hub text change",
2924
+ destructiveHint: true,
2925
+ idempotentHint: true,
2926
+ description: "Undo the last text change made through joy_update_loyalty_hub_text, restoring the Loyalty Hub text exactly as it was before that call \u2014 LIVE immediately. ONE step only \u2014 a second call restores the same snapshot again rather than going further back. This undoes Hub text only; use joy_revert_loyalty_hub_design to undo Hub design changes.",
2927
+ inputSchema: {},
2928
+ handler: async () => {
2929
+ const result = await ctx.api.mutateData("/loyalty-hub/text/revert", {
2930
+ method: "POST",
2931
+ timeoutMs: 2e4,
2932
+ body: {}
2933
+ });
2934
+ const count = result?.textKeysRestored ?? 0;
2935
+ return summaryWithJson(`Reverted Loyalty Hub text \u2014 ${count} value${count === 1 ? "" : "s"} restored. LIVE immediately.`, result);
2936
+ }
2937
+ });
2938
+ }
2939
+
2940
+ // ../mcp-core/build/tools/programsWrite.js
2941
+ import { z as z17 } from "zod";
2412
2942
  var PROGRAM_TYPE_BY_KIND = {
2413
2943
  earning: "earning",
2414
2944
  redemption: "spending"
@@ -2436,7 +2966,7 @@ var REVIEW_APPS = [
2436
2966
  "okendoApp",
2437
2967
  "ReviewsIoApp",
2438
2968
  "growaveApp",
2439
- "typdalApp"
2969
+ "segunoReviewApp"
2440
2970
  ];
2441
2971
  var REVIEW_FLOW_APPS = /* @__PURE__ */ new Set([
2442
2972
  "stampedApp",
@@ -2444,6 +2974,7 @@ var REVIEW_FLOW_APPS = /* @__PURE__ */ new Set([
2444
2974
  "okendoApp",
2445
2975
  "ReviewsIoApp",
2446
2976
  "growaveApp",
2977
+ "segunoReviewApp",
2447
2978
  "typdalApp"
2448
2979
  ]);
2449
2980
  var REVIEW_PHOTO_VIDEO_APPS = /* @__PURE__ */ new Set(["reviewRocketApp", "looxApp"]);
@@ -2511,8 +3042,8 @@ var REDEMPTION_FIELDS = [
2511
3042
  function withoutUnsetFields(body) {
2512
3043
  return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== void 0));
2513
3044
  }
2514
- function missingFields(event, fields) {
2515
- return { ok: false, error: `Creating ${event} requires: ${fields.join(", ")}.` };
3045
+ function missingFields(event, fields2) {
3046
+ return { ok: false, error: `Creating ${event} requires: ${fields2.join(", ")}.` };
2516
3047
  }
2517
3048
  function unsupportedCreate(event) {
2518
3049
  return {
@@ -2805,45 +3336,45 @@ function isProgramChangeNoop(current, prepared) {
2805
3336
  return diffFields(current, prepared.body).length === 0;
2806
3337
  }
2807
3338
  var programConfigInputSchema = {
2808
- program_id: z15.string().min(1).optional().describe("Existing Joy program ID. Omit to create a new program."),
2809
- event: z15.string().optional().describe("Program event. Required for create; optional for update."),
2810
- title: z15.string().min(1).optional().describe("Customer-facing program title."),
2811
- status: z15.boolean().optional().describe("Set false to disable this rule instead of deleting it."),
2812
- priority: z15.number().int().optional().describe("Optional program priority."),
2813
- start_date: z15.string().optional().describe("Optional ISO start date."),
2814
- end_date: z15.string().optional().describe("Optional ISO end date."),
2815
- earn_by: z15.enum(["price", "fixed_order", "quantity", "order"]).optional(),
2816
- rate_money: z15.number().optional(),
2817
- rate_item: z15.number().optional(),
2818
- earn_point: z15.number().optional(),
2819
- date_format: z15.enum(["MM/DD", "DD/MM", "MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"]).optional(),
2820
- type_reward_timing: z15.enum(["exact_birthday", "begin_of_month", "around_birthday"]).optional(),
2821
- limit_unit: z15.string().optional(),
2822
- limit_interval: z15.number().int().positive().optional(),
2823
- url_account: z15.string().regex(/^https?:\/\//, "url_account must start with http:// or https://").optional().describe("REQUIRED when creating a social rule (follow_instagram, subscribe_youtube, like_facebook, follow_tiktok, follow_twitter, follow_pinterest, join_discord, join_telegram, join_reddit). The full URL of the shop's profile on that network \u2014 customers are sent here to complete the action."),
2824
- review_app: z15.enum(REVIEW_APPS).optional().describe("REQUIRED when creating a write_review rule: the review app the shop actually runs. Points cannot be awarded for reviews unless this matches the installed app."),
2825
- description_review: z15.string().optional().describe("Required only when review_app is a Shopify Flow app (stampedApp, looxApp, okendoApp, ReviewsIoApp, growaveApp, typdalApp) \u2014 the merchant-facing note describing how the Flow awards points."),
2826
- extra_points_for_images: z15.number().int().nonnegative().optional().describe("Bonus points for a review with a photo. Only reviewRocketApp and looxApp report photos; ignored for every other review app."),
2827
- extra_points_for_videos: z15.number().int().nonnegative().optional().describe("Bonus points for a review with a video. Only reviewRocketApp and looxApp report videos; ignored for every other review app."),
2828
- spend_point: z15.number().int().positive().optional(),
2829
- earn_amount: z15.number().optional(),
2830
- shipping_discount_type: z15.enum(["percentage", "fixedAmount"]).optional(),
2831
- shipping_discount_amount: z15.number().optional(),
2832
- applied_to: z15.enum(FREE_GIFT_PROGRAM_APPLIED_TO).optional().describe("free_gift only \u2014 sf_product, specific, or exclude_collection. This is a program-level enum and differs from tier rewards; it has no all value."),
2833
- specific_products: z15.array(z15.unknown()).optional().describe("free_gift with applied_to sf_product only \u2014 the merchant-provided product records/IDs. Do not invent product IDs."),
2834
- specific_collections: z15.array(z15.unknown()).optional(),
2835
- applied_collection_ids: z15.array(z15.string()).optional().describe("free_gift with applied_to specific or exclude_collection only \u2014 merchant-provided collection IDs."),
2836
- collection_quantity: z15.number().int().positive().optional().describe("Required for collection-based free_gift rules (applied_to specific or exclude_collection)."),
2837
- product_url: z15.array(z15.string()).optional().describe("free_gift with applied_to sf_product only \u2014 Shopify product URLs to resolve into product IDs. Accepts /products/<handle> or /collections/<collection>/products/<handle>; do not invent IDs."),
2838
- applied_place_order_to: z15.enum(["all", "vip-tier", "member_and_guest"]).optional().describe('place_order "Who to reward" radio: all = All loyalty members, vip-tier = VIP Tier only, member_and_guest = All customers. The admin UI and the earning engine both key off this field, not is_applied_vip_tier. Enabling is_applied_vip_tier or earn_points_tiers FORCES vip-tier \u2014 sending all here is why per-tier rates can save while the radio stays on All loyalty members.'),
2839
- is_applied_vip_tier: z15.boolean().optional().describe("Turn per-VIP-tier earning on for this rule. MUST be true for earn_points_tiers to have any effect \u2014 the backend reads the per-tier map only when this flag is set, and otherwise silently uses the flat earn_point."),
2840
- earn_points_tiers: z15.record(z15.object({
2841
- earn_point: z15.number().optional().describe("Points this tier earns, replacing the flat earn_point."),
2842
- rate_money: z15.number().optional().describe("place_order only: money amount that earns earn_point for this tier."),
2843
- type_earn: z15.enum(["point", "store_credit"]).optional()
3339
+ program_id: z17.string().min(1).optional().describe("Existing Joy program ID. Omit to create a new program."),
3340
+ event: z17.string().optional().describe("Program event. Required for create; optional for update."),
3341
+ title: z17.string().min(1).optional().describe("Customer-facing program title."),
3342
+ status: z17.boolean().optional().describe("Set false to disable this rule instead of deleting it."),
3343
+ priority: z17.number().int().optional().describe("Optional program priority."),
3344
+ start_date: z17.string().optional().describe("Optional ISO start date."),
3345
+ end_date: z17.string().optional().describe("Optional ISO end date."),
3346
+ earn_by: z17.enum(["price", "fixed_order", "quantity", "order"]).optional(),
3347
+ rate_money: z17.number().optional(),
3348
+ rate_item: z17.number().optional(),
3349
+ earn_point: z17.number().optional(),
3350
+ date_format: z17.enum(["MM/DD", "DD/MM", "MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"]).optional(),
3351
+ type_reward_timing: z17.enum(["exact_birthday", "begin_of_month", "around_birthday"]).optional(),
3352
+ limit_unit: z17.string().optional(),
3353
+ limit_interval: z17.number().int().positive().optional(),
3354
+ url_account: z17.string().regex(/^https?:\/\//, "url_account must start with http:// or https://").optional().describe("REQUIRED when creating a social rule (follow_instagram, subscribe_youtube, like_facebook, follow_tiktok, follow_twitter, follow_pinterest, join_discord, join_telegram, join_reddit). The full URL of the shop's profile on that network \u2014 customers are sent here to complete the action."),
3355
+ review_app: z17.enum(REVIEW_APPS).optional().describe("REQUIRED when creating a write_review rule: the review app the shop actually runs. Points cannot be awarded for reviews unless this matches the installed app."),
3356
+ description_review: z17.string().optional().describe("Required only when review_app is a Shopify Flow app (stampedApp, looxApp, okendoApp, ReviewsIoApp, growaveApp, segunoReviewApp, typdalApp) \u2014 the merchant-facing note describing how the Flow awards points."),
3357
+ extra_points_for_images: z17.number().int().nonnegative().optional().describe("Bonus points for a review with a photo. Only reviewRocketApp and looxApp report photos; ignored for every other review app."),
3358
+ extra_points_for_videos: z17.number().int().nonnegative().optional().describe("Bonus points for a review with a video. Only reviewRocketApp and looxApp report videos; ignored for every other review app."),
3359
+ spend_point: z17.number().int().positive().optional(),
3360
+ earn_amount: z17.number().optional(),
3361
+ shipping_discount_type: z17.enum(["percentage", "fixedAmount"]).optional(),
3362
+ shipping_discount_amount: z17.number().optional(),
3363
+ applied_to: z17.enum(FREE_GIFT_PROGRAM_APPLIED_TO).optional().describe("free_gift only \u2014 sf_product, specific, or exclude_collection. This is a program-level enum and differs from tier rewards; it has no all value."),
3364
+ specific_products: z17.array(z17.unknown()).optional().describe("free_gift with applied_to sf_product only \u2014 the merchant-provided product records/IDs. Do not invent product IDs."),
3365
+ specific_collections: z17.array(z17.unknown()).optional(),
3366
+ applied_collection_ids: z17.array(z17.string()).optional().describe("free_gift with applied_to specific or exclude_collection only \u2014 merchant-provided collection IDs."),
3367
+ collection_quantity: z17.number().int().positive().optional().describe("Required for collection-based free_gift rules (applied_to specific or exclude_collection)."),
3368
+ product_url: z17.array(z17.string()).optional().describe("free_gift with applied_to sf_product only \u2014 Shopify product URLs to resolve into product IDs. Accepts /products/<handle> or /collections/<collection>/products/<handle>; do not invent IDs."),
3369
+ applied_place_order_to: z17.enum(["all", "vip-tier", "member_and_guest"]).optional().describe('place_order "Who to reward" radio: all = All loyalty members, vip-tier = VIP Tier only, member_and_guest = All customers. The admin UI and the earning engine both key off this field, not is_applied_vip_tier. Enabling is_applied_vip_tier or earn_points_tiers FORCES vip-tier \u2014 sending all here is why per-tier rates can save while the radio stays on All loyalty members.'),
3370
+ is_applied_vip_tier: z17.boolean().optional().describe("Turn per-VIP-tier earning on for this rule. MUST be true for earn_points_tiers to have any effect \u2014 the backend reads the per-tier map only when this flag is set, and otherwise silently uses the flat earn_point."),
3371
+ earn_points_tiers: z17.record(z17.object({
3372
+ earn_point: z17.number().optional().describe("Points this tier earns, replacing the flat earn_point."),
3373
+ rate_money: z17.number().optional().describe("place_order only: money amount that earns earn_point for this tier."),
3374
+ type_earn: z17.enum(["point", "store_credit"]).optional()
2844
3375
  })).optional().describe("Per-VIP-tier earning, keyed by TIER ID (from joy_list_tiers) \u2014 not tier name. For place_order a \xD71.25 multiplier on a 1-point-per-$1 base is earn_point 1.25, not 1.25 as a factor: the value REPLACES the base rate. Requires is_applied_vip_tier: true."),
2845
- reason: z15.string().min(3).describe("Why the merchant wants this configuration change."),
2846
- apply: z15.boolean().optional().describe("Set true only after reviewing this tool's dry-run preview.")
3376
+ reason: z17.string().min(3).describe("Why the merchant wants this configuration change."),
3377
+ apply: z17.boolean().optional().describe("Set true only after reviewing this tool's dry-run preview.")
2847
3378
  };
2848
3379
  var PLACE_ORDER_EVENTS = /* @__PURE__ */ new Set(["place_order", "place_order_subscription"]);
2849
3380
  function placeOrderVipTierPatch(event, args) {
@@ -2987,7 +3518,7 @@ function registerProgramConfigTools(server, ctx) {
2987
3518
  }
2988
3519
 
2989
3520
  // ../mcp-core/build/tools/tiersWrite.js
2990
- import { z as z16 } from "zod";
3521
+ import { z as z18 } from "zod";
2991
3522
  var TIER_THRESHOLD_WARNING = "Threshold changes are STAGED, not live. Members are not re-evaluated until the merchant relaunches tiers in Joy admin \u2014 this tool cannot trigger that.";
2992
3523
  var ENTRY_METHOD_WARNING = "Level-up rule (entryMethod) is saved immediately. NEW points, spend, and orders start counting in the new unit at once. Existing members keep their current tierPoint until the merchant relaunches tiers in Joy admin \u2014 this tool cannot trigger that. Review each tier threshold (target_point): 0/150/350 means points, currency, or orders depending on this rule.";
2993
3524
  var START_DATE_WARNING = "Program start date (startDate) is saved immediately and is the calculation window for NEW activity. Existing members keep their current tierPoint until the merchant relaunches tiers in Joy admin \u2014 this tool cannot trigger that. This is the VIP program start date, not reset_start_date.";
@@ -3076,7 +3607,7 @@ var TIER_SETTINGS_FIELDS = [
3076
3607
  "notificationDowngradeBefore",
3077
3608
  "notificationDowngradeBeforeUnit"
3078
3609
  ];
3079
- var rewardEventSchema = z16.enum([
3610
+ var rewardEventSchema = z18.enum([
3080
3611
  "amount_discount",
3081
3612
  "percentage_discount",
3082
3613
  "free_shipping",
@@ -3084,101 +3615,101 @@ var rewardEventSchema = z16.enum([
3084
3615
  "bonus_point",
3085
3616
  "buy_x_get_y"
3086
3617
  ]);
3087
- var finiteNumber = () => z16.number().finite();
3088
- var tierRewardInputSchema = z16.object({
3618
+ var finiteNumber = () => z18.number().finite();
3619
+ var tierRewardInputSchema = z18.object({
3089
3620
  event: rewardEventSchema.describe("Required reward kind."),
3090
- title: z16.string().min(1).optional(),
3091
- type: z16.enum(["tier_spending", "tier_privileges", "pre_tier_reward"]).optional(),
3092
- status: z16.boolean().optional(),
3621
+ title: z18.string().min(1).optional(),
3622
+ type: z18.enum(["tier_spending", "tier_privileges", "pre_tier_reward"]).optional(),
3623
+ status: z18.boolean().optional(),
3093
3624
  earn_amount: finiteNumber().optional(),
3094
3625
  spend_point: finiteNumber().optional(),
3095
3626
  bonus_points: finiteNumber().optional(),
3096
- code_name: z16.string().optional(),
3097
- redeem_type: z16.string().optional(),
3098
- applied_to: z16.enum(["all", "specific_product", "specific"]).optional().describe("Tier-reward scope only: all, specific_product, or specific. These literals differ from program-level reward tools."),
3099
- order_req: z16.enum(["none", "min_amount", "min_quantity"]).optional(),
3627
+ code_name: z18.string().optional(),
3628
+ redeem_type: z18.string().optional(),
3629
+ applied_to: z18.enum(["all", "specific_product", "specific"]).optional().describe("Tier-reward scope only: all, specific_product, or specific. These literals differ from program-level reward tools."),
3630
+ order_req: z18.enum(["none", "min_amount", "min_quantity"]).optional(),
3100
3631
  order_req_amount: finiteNumber().optional(),
3101
3632
  min_spend_point: finiteNumber().optional(),
3102
3633
  max_spend_point: finiteNumber().optional(),
3103
- specific_products: z16.array(z16.unknown()).optional(),
3104
- specific_collections: z16.array(z16.unknown()).optional(),
3105
- applied_collection_ids: z16.array(z16.unknown()).optional(),
3106
- combined_with: z16.array(z16.enum(["orderDiscounts", "shippingDiscounts", "productDiscounts"])).optional(),
3107
- has_limit_shipping: z16.boolean().optional(),
3634
+ specific_products: z18.array(z18.unknown()).optional(),
3635
+ specific_collections: z18.array(z18.unknown()).optional(),
3636
+ applied_collection_ids: z18.array(z18.unknown()).optional(),
3637
+ combined_with: z18.array(z18.enum(["orderDiscounts", "shippingDiscounts", "productDiscounts"])).optional(),
3638
+ has_limit_shipping: z18.boolean().optional(),
3108
3639
  limit_shipping: finiteNumber().optional(),
3109
3640
  expired_after: finiteNumber().optional(),
3110
- expired_unit: z16.enum(["day", "month"]).optional(),
3111
- is_refund_coupon: z16.boolean().optional(),
3641
+ expired_unit: z18.enum(["day", "month"]).optional(),
3642
+ is_refund_coupon: z18.boolean().optional(),
3112
3643
  priority: finiteNumber().optional(),
3113
- show_loyalty_page: z16.boolean().optional(),
3114
- perk_title_language: z16.record(z16.unknown()).optional(),
3115
- reward_product_selection_mode: z16.string().optional(),
3644
+ show_loyalty_page: z18.boolean().optional(),
3645
+ perk_title_language: z18.record(z18.unknown()).optional(),
3646
+ reward_product_selection_mode: z18.string().optional(),
3116
3647
  reward_product_quantity: finiteNumber().optional()
3117
3648
  });
3118
3649
  var tierConfigInputSchema = {
3119
- tier_id: z16.string().min(1).optional().describe("Existing tier ID. Omit to create a tier."),
3120
- name: z16.string().min(1).optional(),
3650
+ tier_id: z18.string().min(1).optional().describe("Existing tier ID. Omit to create a tier."),
3651
+ name: z18.string().min(1).optional(),
3121
3652
  target_point: finiteNumber().nonnegative().optional().describe("Tier threshold. On an existing tier this is staged; it does not immediately re-evaluate members."),
3122
- icon: z16.string().optional(),
3123
- icon_custom: z16.string().optional(),
3124
- bg_image: z16.string().optional().describe("Membership card background image URL for this ONE tier. Pass bg_type: 'image' alongside bg_image for it to show, or '' to clear. Use a durable URL the merchant controls; joy_upload_image uploads to their Shopify Files CDN and returns one. Widget-wide membershipCard.beforeSignIn.bgImage and membershipCard.afterSignIn.bgImage are different fields, managed by joy_set_widget_fields."),
3125
- default_avatar: z16.string().optional().describe("Membership card avatar image URL for members in this ONE tier. Pass '' to clear. Use a durable URL the merchant controls; joy_upload_image uploads to their Shopify Files CDN and returns one. Widget-wide membershipCard.beforeSignIn.bgImage and membershipCard.afterSignIn.bgImage are different fields, managed by joy_set_widget_fields."),
3126
- image_block: z16.string().optional(),
3127
- is_exclusive_tier: z16.boolean().optional(),
3128
- hide_default_tier: z16.boolean().optional(),
3129
- text_color: z16.string().optional(),
3130
- progress_bar_color: z16.string().optional(),
3131
- box_bg_color: z16.string().optional(),
3132
- box_text_color: z16.string().optional(),
3133
- bg_card_color: z16.string().optional(),
3653
+ icon: z18.string().optional(),
3654
+ icon_custom: z18.string().optional(),
3655
+ bg_image: z18.string().optional().describe("Membership card background image URL for this ONE tier. Pass bg_type: 'image' alongside bg_image for it to show, or '' to clear. Use a durable URL the merchant controls; joy_upload_image uploads to their Shopify Files CDN and returns one. Widget-wide membershipCard.beforeSignIn.bgImage and membershipCard.afterSignIn.bgImage are different fields, managed by joy_set_widget_fields."),
3656
+ default_avatar: z18.string().optional().describe("Membership card avatar image URL for members in this ONE tier. Pass '' to clear. Use a durable URL the merchant controls; joy_upload_image uploads to their Shopify Files CDN and returns one. Widget-wide membershipCard.beforeSignIn.bgImage and membershipCard.afterSignIn.bgImage are different fields, managed by joy_set_widget_fields."),
3657
+ image_block: z18.string().optional(),
3658
+ is_exclusive_tier: z18.boolean().optional(),
3659
+ hide_default_tier: z18.boolean().optional(),
3660
+ text_color: z18.string().optional(),
3661
+ progress_bar_color: z18.string().optional(),
3662
+ box_bg_color: z18.string().optional(),
3663
+ box_text_color: z18.string().optional(),
3664
+ bg_card_color: z18.string().optional(),
3134
3665
  card_opacity: finiteNumber().optional(),
3135
- icon_color: z16.string().optional(),
3136
- bg_type: z16.string().optional(),
3137
- tier_names_language: z16.record(z16.unknown()).optional(),
3138
- rewards: z16.array(tierRewardInputSchema).optional().describe("Optional rewards to create with a NEW tier in this one configuration write."),
3139
- reason: z16.string().min(3).describe("Why the merchant wants this tier configuration."),
3140
- apply: z16.boolean().optional().describe("Set true only after the dry-run preview is approved.")
3666
+ icon_color: z18.string().optional(),
3667
+ bg_type: z18.string().optional(),
3668
+ tier_names_language: z18.record(z18.unknown()).optional(),
3669
+ rewards: z18.array(tierRewardInputSchema).optional().describe("Optional rewards to create with a NEW tier in this one configuration write."),
3670
+ reason: z18.string().min(3).describe("Why the merchant wants this tier configuration."),
3671
+ apply: z18.boolean().optional().describe("Set true only after the dry-run preview is approved.")
3141
3672
  };
3142
3673
  var tierRewardToolInputSchema = {
3143
- tier_id: z16.string().min(1).describe("Tier ID from joy_list_tiers."),
3144
- reward_id: z16.string().min(1).optional().describe("Existing reward ID. Omit to create."),
3674
+ tier_id: z18.string().min(1).describe("Tier ID from joy_list_tiers."),
3675
+ reward_id: z18.string().min(1).optional().describe("Existing reward ID. Omit to create."),
3145
3676
  ...tierRewardInputSchema.shape,
3146
- product_url: z16.array(z16.string()).optional().describe("free_gift with applied_to specific_product only \u2014 Shopify product URLs to resolve into product IDs. Accepts /products/<handle> or /collections/<collection>/products/<handle>; do not invent IDs."),
3147
- reason: z16.string().min(3).describe("Why the merchant wants this reward configuration."),
3148
- apply: z16.boolean().optional().describe("Set true only after the dry-run preview is approved.")
3677
+ product_url: z18.array(z18.string()).optional().describe("free_gift with applied_to specific_product only \u2014 Shopify product URLs to resolve into product IDs. Accepts /products/<handle> or /collections/<collection>/products/<handle>; do not invent IDs."),
3678
+ reason: z18.string().min(3).describe("Why the merchant wants this reward configuration."),
3679
+ apply: z18.boolean().optional().describe("Set true only after the dry-run preview is approved.")
3149
3680
  };
3150
3681
  var tierSettingsInputSchema = {
3151
- entry_method: z16.enum(["pointEarned", "moneySpent", "numberOfOrders"]).optional().describe("Tier calculation / level-up rule: how members climb the ladder. pointEarned = points earned, moneySpent = amount spent, numberOfOrders = number of orders. Saved immediately \u2014 NEW activity counts in the new unit at once. Existing members keep their current tierPoint until the merchant relaunches in Joy admin; this tool cannot trigger that. After changing, review each tier's target_point: the same 0/150/350 numbers mean points, currency, or orders depending on this rule."),
3152
- start_date: z16.string().min(1).optional().describe('VIP program start date \u2014 the calculation window in the admin "Tier calculation" card (ISO date, YYYY-MM-DD). Distinct from reset_start_date, which is the custom reset-cycle anchor. Saved immediately; members keep their current tierPoint until the merchant relaunches in Joy admin.'),
3153
- prefix: z16.string().optional().describe("Custom discount-code prefix for tier rewards. Supports {{tier_name}}. Pair with use_prefix_discount_code: true, or the custom prefix is stored but unused."),
3154
- use_prefix_discount_code: z16.boolean().optional().describe("When true, tier reward codes use prefix instead of the shop default."),
3155
- show_tier_point_settings: z16.boolean().optional().describe("Show tier-point editing on the customer admin page."),
3156
- allow_give_entry_reward_again: z16.boolean().optional().describe("When true (Joy default), a member who is demoted and later re-enters a tier can receive that tier entry reward again. False grants it only once."),
3157
- use_demotion_tier: z16.boolean().optional(),
3158
- type_demotion: z16.enum(["downgrade_tier", "reset_tier"]).optional(),
3159
- downgrade_after: z16.number().int().positive().optional(),
3160
- downgrade_after_unit: z16.enum(["day", "month", "year"]).optional(),
3161
- use_reset_tier: z16.boolean().optional(),
3162
- reset_after: z16.number().int().positive().optional(),
3163
- reset_after_unit: z16.enum(["day", "month", "year"]).optional(),
3164
- reset_frequency: z16.enum(["monthly", "quarterly", "half_yearly", "yearly", "custom"]).optional(),
3165
- reset_interval_value: z16.number().int().positive().optional(),
3166
- reset_interval_unit: z16.enum(["day", "month", "year"]).optional(),
3167
- reset_start_date: z16.string().min(1).optional(),
3168
- month_reset_tier: z16.number().int().min(1).max(12).optional(),
3169
- day_reset_tier: z16.number().int().min(1).max(31).optional(),
3170
- is_sync_tags_customer_tier: z16.boolean().optional(),
3171
- customer_tier_tag_prefix: z16.string().optional(),
3172
- notification_downgrade_before: z16.number().int().positive().optional(),
3173
- notification_downgrade_before_unit: z16.enum(["day", "month"]).optional(),
3174
- reason: z16.string().min(3).describe("Why the merchant wants these tier settings."),
3175
- apply: z16.boolean().optional().describe("Set true only after the dry-run preview is approved.")
3682
+ entry_method: z18.enum(["pointEarned", "moneySpent", "numberOfOrders"]).optional().describe("Tier calculation / level-up rule: how members climb the ladder. pointEarned = points earned, moneySpent = amount spent, numberOfOrders = number of orders. Saved immediately \u2014 NEW activity counts in the new unit at once. Existing members keep their current tierPoint until the merchant relaunches in Joy admin; this tool cannot trigger that. After changing, review each tier's target_point: the same 0/150/350 numbers mean points, currency, or orders depending on this rule."),
3683
+ start_date: z18.string().min(1).optional().describe('VIP program start date \u2014 the calculation window in the admin "Tier calculation" card (ISO date, YYYY-MM-DD). Distinct from reset_start_date, which is the custom reset-cycle anchor. Saved immediately; members keep their current tierPoint until the merchant relaunches in Joy admin.'),
3684
+ prefix: z18.string().optional().describe("Custom discount-code prefix for tier rewards. Supports {{tier_name}}. Pair with use_prefix_discount_code: true, or the custom prefix is stored but unused."),
3685
+ use_prefix_discount_code: z18.boolean().optional().describe("When true, tier reward codes use prefix instead of the shop default."),
3686
+ show_tier_point_settings: z18.boolean().optional().describe("Show tier-point editing on the customer admin page."),
3687
+ allow_give_entry_reward_again: z18.boolean().optional().describe("When true (Joy default), a member who is demoted and later re-enters a tier can receive that tier entry reward again. False grants it only once."),
3688
+ use_demotion_tier: z18.boolean().optional(),
3689
+ type_demotion: z18.enum(["downgrade_tier", "reset_tier"]).optional(),
3690
+ downgrade_after: z18.number().int().positive().optional(),
3691
+ downgrade_after_unit: z18.enum(["day", "month", "year"]).optional(),
3692
+ use_reset_tier: z18.boolean().optional(),
3693
+ reset_after: z18.number().int().positive().optional(),
3694
+ reset_after_unit: z18.enum(["day", "month", "year"]).optional(),
3695
+ reset_frequency: z18.enum(["monthly", "quarterly", "half_yearly", "yearly", "custom"]).optional(),
3696
+ reset_interval_value: z18.number().int().positive().optional(),
3697
+ reset_interval_unit: z18.enum(["day", "month", "year"]).optional(),
3698
+ reset_start_date: z18.string().min(1).optional(),
3699
+ month_reset_tier: z18.number().int().min(1).max(12).optional(),
3700
+ day_reset_tier: z18.number().int().min(1).max(31).optional(),
3701
+ is_sync_tags_customer_tier: z18.boolean().optional(),
3702
+ customer_tier_tag_prefix: z18.string().optional(),
3703
+ notification_downgrade_before: z18.number().int().positive().optional(),
3704
+ notification_downgrade_before_unit: z18.enum(["day", "month"]).optional(),
3705
+ reason: z18.string().min(3).describe("Why the merchant wants these tier settings."),
3706
+ apply: z18.boolean().optional().describe("Set true only after the dry-run preview is approved.")
3176
3707
  };
3177
3708
  var tierPerksInputSchema = {
3178
- tier_id: z16.string().min(1).describe("Tier ID from joy_list_tiers."),
3179
- perks: z16.array(z16.string().min(1)).describe("The COMPLETE replacement list of display-only perks. Include every existing perk that should remain; omitted entries are removed, and [] clears the list. Each entry is perk text or an http(s) URL rendered as a tick/cross icon in the storefront benefits table."),
3180
- reason: z16.string().min(3).describe("Why the merchant wants to replace this tier perk list."),
3181
- apply: z16.boolean().optional().describe("Set true only after the dry-run preview is approved.")
3709
+ tier_id: z18.string().min(1).describe("Tier ID from joy_list_tiers."),
3710
+ perks: z18.array(z18.string().min(1)).describe("The COMPLETE replacement list of display-only perks. Include every existing perk that should remain; omitted entries are removed, and [] clears the list. Each entry is perk text or an http(s) URL rendered as a tick/cross icon in the storefront benefits table."),
3711
+ reason: z18.string().min(3).describe("Why the merchant wants to replace this tier perk list."),
3712
+ apply: z18.boolean().optional().describe("Set true only after the dry-run preview is approved.")
3182
3713
  };
3183
3714
  function tierFreeGiftSelectionError(args, requireAppliedTo = false) {
3184
3715
  if (args.event !== "free_gift")
@@ -3277,7 +3808,7 @@ function buildTierConfigPreview(params) {
3277
3808
  }
3278
3809
  function rethrowTierCreateConflict(error) {
3279
3810
  if (error instanceof JoyApiError && error.status === 409) {
3280
- throw new JoyApiError("conflict", "Cannot create this tier because another tier already has this name or target_point. Call joy_list_tiers, then update the existing tier with tier_id instead of retrying the create.", error.status);
3811
+ throw new JoyApiError("conflict", "Cannot create this tier because another tier already has this name or target_point. Call joy_list_tiers, then update the existing tier with tier_id instead of retrying the create.", error.status, error.serverCode);
3281
3812
  }
3282
3813
  throw error;
3283
3814
  }
@@ -3597,7 +4128,7 @@ async function readTierSettings(ctx) {
3597
4128
  }
3598
4129
  function rethrowTierSettingsNotLaunched(error) {
3599
4130
  if (error instanceof JoyApiError && error.kind === "not_found") {
3600
- throw new JoyApiError("not_found", "This shop has never launched tiers, so tier settings do not exist yet. Launch the tier program in Joy admin first; this MCP tool cannot launch it.", error.status);
4131
+ throw new JoyApiError("not_found", "This shop has never launched tiers, so tier settings do not exist yet. Launch the tier program in Joy admin first; this MCP tool cannot launch it.", error.status, error.serverCode);
3601
4132
  }
3602
4133
  throw error;
3603
4134
  }
@@ -3787,7 +4318,7 @@ function isAfterOrEqual(candidate, cursor, inclusive) {
3787
4318
  }
3788
4319
 
3789
4320
  // ../mcp-core/build/tools/shopConfig.js
3790
- import { z as z17 } from "zod";
4321
+ import { z as z19 } from "zod";
3791
4322
  var SHOP_SETTINGS_FIELDS = [
3792
4323
  "primaryLanguageInApp",
3793
4324
  "dateFormat",
@@ -3885,8 +4416,8 @@ function toShopSettingsBody(args) {
3885
4416
  function getDroppedShopSettingsFields(args) {
3886
4417
  return getUnknownArguments(args, SHOP_SETTINGS_VALID_ARGUMENTS);
3887
4418
  }
3888
- function selectedFields(body, fields) {
3889
- const selected = fields.filter((field) => body[field] !== void 0);
4419
+ function selectedFields(body, fields2) {
4420
+ const selected = fields2.filter((field) => body[field] !== void 0);
3890
4421
  return selected.length > 0 ? selected.join(", ") : "none selected";
3891
4422
  }
3892
4423
  function buildShopSettingsPreview(current, args) {
@@ -3930,20 +4461,20 @@ function registerShopConfigTools(server, ctx) {
3930
4461
  title: "Configure safe shop-level loyalty settings",
3931
4462
  description: "WRITE \u2014 updates a deliberately limited set of shop-level loyalty settings. The backend splits this request between the shops collection and a separate settings document without atomic rollback, so a partial failure is possible. SSO controls, tier-specific earning limits, and weekly-report BCC recipients are intentionally unavailable through this tool.",
3932
4463
  inputSchema: {
3933
- primary_language_in_app: z17.enum(["en", "en-US", "fr", "es", "de", "it", "ja", "id", "zh-CN"]).optional(),
3934
- date_format: z17.enum(["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD", "YYYY-MM-DD"]).optional(),
3935
- date_format_for_activity: z17.enum(["HH:mm", "hh:mm A"]).optional(),
3936
- point_position: z17.enum(["before", "after"]).optional(),
3937
- customer_apply_discount_behavior: z17.enum(["stay_current", "go_to_cart", "open_cart_drawer"]).optional(),
3938
- customer_redeem_coupon_behavior: z17.enum(["auto_apply", "save_for_later"]).optional(),
3939
- customer_free_gift_behavior: z17.enum(["manual_add_to_cart", "auto_add_to_cart"]).optional(),
3940
- status_for_review: z17.enum(["pending", "approveAuto"]).optional(),
3941
- earn_by_order_status: z17.enum(["financial_status", "fulfillment_status"]).optional(),
3942
- enable_global_earning_limit: z17.boolean().optional(),
3943
- global_earning_limit_timeframe: z17.enum(["yearly", "monthly", "rolling"]).optional(),
3944
- global_earning_limit_rolling_days: z17.number().int().min(1).max(365).optional(),
3945
- reason: z17.string().min(3).describe("Why the merchant wants these shop-wide settings changed."),
3946
- apply: z17.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
4464
+ primary_language_in_app: z19.enum(["en", "en-US", "fr", "es", "de", "it", "ja", "id", "zh-CN"]).optional(),
4465
+ date_format: z19.enum(["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD", "YYYY-MM-DD"]).optional(),
4466
+ date_format_for_activity: z19.enum(["HH:mm", "hh:mm A"]).optional(),
4467
+ point_position: z19.enum(["before", "after"]).optional(),
4468
+ customer_apply_discount_behavior: z19.enum(["stay_current", "go_to_cart", "open_cart_drawer"]).optional(),
4469
+ customer_redeem_coupon_behavior: z19.enum(["auto_apply", "save_for_later"]).optional(),
4470
+ customer_free_gift_behavior: z19.enum(["manual_add_to_cart", "auto_add_to_cart"]).optional(),
4471
+ status_for_review: z19.enum(["pending", "approveAuto"]).optional(),
4472
+ earn_by_order_status: z19.enum(["financial_status", "fulfillment_status"]).optional(),
4473
+ enable_global_earning_limit: z19.boolean().optional(),
4474
+ global_earning_limit_timeframe: z19.enum(["yearly", "monthly", "rolling"]).optional(),
4475
+ global_earning_limit_rolling_days: z19.number().int().min(1).max(365).optional(),
4476
+ reason: z19.string().min(3).describe("Why the merchant wants these shop-wide settings changed."),
4477
+ apply: z19.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
3947
4478
  },
3948
4479
  destructiveHint: false,
3949
4480
  strictArguments: true,
@@ -3965,10 +4496,10 @@ function registerShopConfigTools(server, ctx) {
3965
4496
  title: "Add a storefront translation language",
3966
4497
  description: "WRITE \u2014 adds one storefront language by machine-translating the shop's entire existing primary-language content. This can be expensive and is hard to undo, so it always requires a dry-run preview and explicit approval before applying.",
3967
4498
  inputSchema: {
3968
- primary_language: z17.enum(TRANSLATION_LOCALES),
3969
- additional_language: z17.enum(TRANSLATION_LOCALES),
3970
- reason: z17.string().min(3).describe("Why the merchant wants this storefront language added."),
3971
- apply: z17.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
4499
+ primary_language: z19.enum(TRANSLATION_LOCALES),
4500
+ additional_language: z19.enum(TRANSLATION_LOCALES),
4501
+ reason: z19.string().min(3).describe("Why the merchant wants this storefront language added."),
4502
+ apply: z19.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
3972
4503
  },
3973
4504
  destructiveHint: false,
3974
4505
  strictArguments: true,
@@ -3987,7 +4518,7 @@ function registerShopConfigTools(server, ctx) {
3987
4518
  }
3988
4519
 
3989
4520
  // ../mcp-core/build/tools/brandingWrite.js
3990
- import { z as z18 } from "zod";
4521
+ import { z as z20 } from "zod";
3991
4522
  var WIDGET_DRAFT_SAVED_MESSAGE = "Saved to the V4 draft. The storefront is unchanged until you publish it in Joy admin.";
3992
4523
  var HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
3993
4524
  var COLOR_ROLE_PATHS = {
@@ -4030,26 +4561,26 @@ var BUTTON_STYLE_VALUES = ["store", "preset", "custom"];
4030
4561
  var CONTENT_STYLE_VALUES = ["icons_and_text", "text_only"];
4031
4562
  var DISPLAY_TYPE_VALUES = ["widget", "drawer", "floating-drawer"];
4032
4563
  var widgetDraftInputSchema = {
4033
- version: z18.enum(["v3", "v4"]).describe("Widget version to change. A live target is accepted only when this is the shop\u2019s currently live version."),
4034
- target: z18.enum(["draft", "live"]).default("draft").describe("Draft by default. Set live only after the merchant approves a dry-run preview of the immediate storefront change."),
4035
- apply_scan: z18.boolean().optional().describe("Apply a storefront scan through Joy\u2019s shared backend design merge. Omit scan_url to scan this shop\u2019s own storefront."),
4036
- scan_url: z18.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL to scan when apply_scan is true."),
4037
- storefront_password: z18.string().min(1).optional().describe("Optional storefront password used only when apply_scan is true. It is sent only in the PUT body and is never returned."),
4038
- apply_images: z18.boolean().optional().describe("When apply_scan is true, also replace detected logo and card imagery. Defaults to false."),
4039
- color_overrides: z18.record(z18.string()).optional().describe(`Optional hex overrides for supported V4 color roles: ${COLOR_ROLES.join(", ")}.`),
4040
- design_overrides: z18.object({
4041
- corner_radius: z18.enum(CORNER_RADIUS_VALUES).optional().describe("Corner style. radiusNone is square/not rounded; radiusExtra is the most rounded."),
4042
- button_style: z18.enum(BUTTON_STYLE_VALUES).optional().describe("store matches the storefront, custom uses the widget\u2019s own button colours."),
4043
- content_style: z18.enum(CONTENT_STYLE_VALUES).optional().describe("text_only removes the icons beside earn/redeem rows; icons_and_text shows them."),
4044
- display_type: z18.enum(DISPLAY_TYPE_VALUES).optional().describe("widget is the inline panel; drawer slides in from the edge; floating-drawer is the floating panel with a launcher button.")
4564
+ version: z20.enum(["v3", "v4"]).describe("Widget version to change. A live target is accepted only when this is the shop\u2019s currently live version."),
4565
+ target: z20.enum(["draft", "live"]).default("draft").describe("Draft by default. Set live only after the merchant approves a dry-run preview of the immediate storefront change."),
4566
+ apply_scan: z20.boolean().optional().describe("Apply a storefront scan through Joy\u2019s shared backend design merge. Omit scan_url to scan this shop\u2019s own storefront."),
4567
+ scan_url: z20.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL to scan when apply_scan is true."),
4568
+ storefront_password: z20.string().min(1).optional().describe("Optional storefront password used only when apply_scan is true. It is sent only in the PUT body and is never returned."),
4569
+ apply_images: z20.boolean().optional().describe("When apply_scan is true, also replace detected logo and card imagery. Defaults to false."),
4570
+ color_overrides: z20.record(z20.string()).optional().describe(`Optional hex overrides for supported V4 color roles: ${COLOR_ROLES.join(", ")}.`),
4571
+ design_overrides: z20.object({
4572
+ corner_radius: z20.enum(CORNER_RADIUS_VALUES).optional().describe("Corner style. radiusNone is square/not rounded; radiusExtra is the most rounded."),
4573
+ button_style: z20.enum(BUTTON_STYLE_VALUES).optional().describe("store matches the storefront, custom uses the widget\u2019s own button colours."),
4574
+ content_style: z20.enum(CONTENT_STYLE_VALUES).optional().describe("text_only removes the icons beside earn/redeem rows; icons_and_text shows them."),
4575
+ display_type: z20.enum(DISPLAY_TYPE_VALUES).optional().describe("widget is the inline panel; drawer slides in from the edge; floating-drawer is the floating panel with a launcher button.")
4045
4576
  }).optional().describe("Optional non-colour design settings. Values are enums, not numbers or CSS lengths."),
4046
- font_overrides: z18.object({ heading: z18.string().min(1).optional(), body: z18.string().min(1).optional() }).optional().describe("Optional heading and body font overrides."),
4047
- reason: z18.string().min(3).describe("Why the merchant wants this widget draft."),
4048
- apply: z18.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
4577
+ font_overrides: z20.object({ heading: z20.string().min(1).optional(), body: z20.string().min(1).optional() }).optional().describe("Optional heading and body font overrides."),
4578
+ reason: z20.string().min(3).describe("Why the merchant wants this widget draft."),
4579
+ apply: z20.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
4049
4580
  };
4050
4581
  var brandScanInputSchema = {
4051
- url: z18.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL. Omit it to scan this shop\u2019s own storefront."),
4052
- storefront_password: z18.string().min(1).optional().describe("Optional storefront password for a gated shop. It is sent only in the POST body and is never returned.")
4582
+ url: z20.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL. Omit it to scan this shop\u2019s own storefront."),
4583
+ storefront_password: z20.string().min(1).optional().describe("Optional storefront password for a gated shop. It is sent only in the POST body and is never returned.")
4053
4584
  };
4054
4585
  function isHexColor(value) {
4055
4586
  return typeof value === "string" && HEX_COLOR.test(value);
@@ -4074,19 +4605,19 @@ function brandingErrorGuidance(error, operation, target = "draft") {
4074
4605
  const passwordIncorrect = errorCode === "PASSWORD_INCORRECT" || /PASSWORD_INCORRECT|storefront password (?:did not unlock|was not accepted)|incorrect storefront password/i.test(error.modelMessage);
4075
4606
  const passwordRequired = errorCode === "PASSWORD_REQUIRED" || error.status === 409 || /PASSWORD_REQUIRED|storefront is password-protected|storefront password is required/i.test(error.modelMessage);
4076
4607
  if (operation === "scan" && passwordIncorrect) {
4077
- return new JoyApiError("validation", "The storefront password was not accepted. Ask the merchant to verify the storefront password, then pass the corrected value as storefront_password. Do not guess it.", error.status);
4608
+ return new JoyApiError("validation", "The storefront password was not accepted. Ask the merchant to verify the storefront password, then pass the corrected value as storefront_password. Do not guess it.", error.status, error.serverCode);
4078
4609
  }
4079
4610
  if (operation === "scan" && passwordRequired) {
4080
- return new JoyApiError("conflict", "The storefront is password-protected. Ask the merchant for the storefront password, then pass it as storefront_password to joy_scan_brand_design. Do not guess the password, and do not invent colors or fonts.", error.status);
4611
+ return new JoyApiError("conflict", "The storefront is password-protected. Ask the merchant for the storefront password, then pass it as storefront_password to joy_scan_brand_design. Do not guess the password, and do not invent colors or fonts.", error.status, error.serverCode);
4081
4612
  }
4082
4613
  if (operation === "scan" && error.status === 503) {
4083
- return new JoyApiError("server", "Joy could not complete the storefront design scan. No design has been returned, so do not invent colors, fonts, or button styling. Ask the merchant to retry later or configure the draft manually.", error.status);
4614
+ return new JoyApiError("server", "Joy could not complete the storefront design scan. No design has been returned, so do not invent colors, fonts, or button styling. Ask the merchant to retry later or configure the draft manually.", error.status, error.serverCode);
4084
4615
  }
4085
4616
  if (operation === "draft" && target === "live" && error.status === 409) {
4086
- return new JoyApiError("conflict", "The live widget document exists only for the version the shop is currently using. This target: 'live' request used a non-live version and was refused. Check which widget version is currently live before retrying; do not blindly retry the other version.", error.status);
4617
+ return new JoyApiError("conflict", "The live widget document exists only for the version the shop is currently using. This target: 'live' request used a non-live version and was refused. Check which widget version is currently live before retrying; do not blindly retry the other version.", error.status, error.serverCode);
4087
4618
  }
4088
4619
  if (operation === "draft" && error.status === 409) {
4089
- return new JoyApiError("conflict", "The requested widget version is currently live. This tool only writes the non-live draft so it cannot alter the storefront. Choose the other version or edit the live widget manually in Joy admin.", error.status);
4620
+ return new JoyApiError("conflict", "The requested widget version is currently live. This tool only writes the non-live draft so it cannot alter the storefront. Choose the other version or edit the live widget manually in Joy admin.", error.status, error.serverCode);
4090
4621
  }
4091
4622
  return void 0;
4092
4623
  }
@@ -4149,7 +4680,7 @@ function errorWithoutCredential(error, credential) {
4149
4680
  if (!credential)
4150
4681
  return error;
4151
4682
  if (error instanceof JoyApiError) {
4152
- return new JoyApiError(error.kind, redactCredential(error.modelMessage, credential), error.status);
4683
+ return new JoyApiError(error.kind, redactCredential(error.modelMessage, credential), error.status, error.serverCode);
4153
4684
  }
4154
4685
  if (error instanceof Error)
4155
4686
  return new Error(redactCredential(error.message, credential));
@@ -4390,13 +4921,13 @@ ${WIDGET_DRAFT_SAVED_MESSAGE}` : ""}`;
4390
4921
 
4391
4922
  // ../mcp-core/build/tools/widgetFields.js
4392
4923
  import { isDeepStrictEqual } from "node:util";
4393
- import { z as z19 } from "zod";
4924
+ import { z as z21 } from "zod";
4394
4925
  var fieldsPath = "/shop/branding/widget/fields";
4395
4926
  var HIDE_MASTER = "display.hideWidgetButton";
4396
4927
  var HIDE_ALL = "launcher.hideCompletely";
4397
4928
  var HIDE_MOBILE = "launcher.hideOnMobile";
4398
- function expandLauncherHideFields(fields, current) {
4399
- const byPath = new Map(fields.map(({ path, value }) => [path, value]));
4929
+ function expandLauncherHideFields(fields2, current) {
4930
+ const byPath = new Map(fields2.map(({ path, value }) => [path, value]));
4400
4931
  const currentOf = (path) => current.find((field) => field.path === path)?.current;
4401
4932
  const get = (path) => byPath.has(path) ? byPath.get(path) : currentOf(path);
4402
4933
  const hideMaster = get(HIDE_MASTER);
@@ -4410,20 +4941,20 @@ function expandLauncherHideFields(fields, current) {
4410
4941
  }
4411
4942
  return [...byPath.entries()].map(([path, value]) => ({ path, value }));
4412
4943
  }
4413
- var versionSchema = z19.literal("v4").default("v4").describe("Widget version. Field discovery currently supports V4.");
4944
+ var versionSchema = z21.literal("v4").default("v4").describe("Widget version. Field discovery currently supports V4.");
4414
4945
  var widgetFieldsInputSchema = {
4415
4946
  version: versionSchema,
4416
- target: z19.enum(["draft", "live"]).default("draft").describe("Draft by default. Use live only when the merchant explicitly requests and approves the immediate storefront change; there is no undo."),
4417
- fields: z19.array(z19.object({
4418
- path: z19.string().min(1),
4419
- value: z19.unknown().refine((value) => value !== void 0, "Each field must include a value; use null to clear it.")
4947
+ target: z21.enum(["draft", "live"]).default("draft").describe("Draft by default. Use live only when the merchant explicitly requests and approves the immediate storefront change; there is no undo."),
4948
+ fields: z21.array(z21.object({
4949
+ path: z21.string().min(1),
4950
+ value: z21.unknown().refine((value) => value !== void 0, "Each field must include a value; use null to clear it.")
4420
4951
  })).min(1).describe("Paths and values to set. Discover paths and allowed values with joy_get_widget_fields. The server validates every value."),
4421
- reason: z19.string().min(3).describe("Why the merchant wants these widget changes."),
4422
- apply: z19.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
4952
+ reason: z21.string().min(3).describe("Why the merchant wants these widget changes."),
4953
+ apply: z21.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
4423
4954
  };
4424
4955
  var MAX_FIELD_RESPONSE_CHARS = 12e3;
4425
- function estimateSize(fields) {
4426
- return fields.reduce((total, field) => total + field.path.length + field.label.length + 60 + (field.values?.join("").length ?? 0), 0);
4956
+ function estimateSize(fields2) {
4957
+ return fields2.reduce((total, field) => total + field.path.length + field.label.length + 60 + (field.values?.join("").length ?? 0), 0);
4427
4958
  }
4428
4959
  async function getWidgetFields(ctx, version2) {
4429
4960
  const { data } = await ctx.api.getData(fieldsPath, { query: { version: version2 } });
@@ -4439,24 +4970,24 @@ function registerWidgetFieldReadTools(server, ctx) {
4439
4970
  description: "READ \u2014 discovers settable V4 widget fields. With no arguments it returns the group index (names, counts, example paths); pass group or search to get those fields with merchant-facing labels, paths, types, current values and allowed enum values. Call before joy_set_widget_fields; never guess a field path or enum value.",
4440
4971
  inputSchema: {
4441
4972
  version: versionSchema,
4442
- group: z19.string().optional().describe("Return only this group. Omit to get the group index first."),
4443
- search: z19.string().optional().describe('Return only fields whose label or path contains this text, e.g. "corner" or "launcher".')
4973
+ group: z21.string().optional().describe("Return only this group. Omit to get the group index first."),
4974
+ search: z21.string().optional().describe('Return only fields whose label or path contains this text, e.g. "corner" or "launcher".')
4444
4975
  },
4445
4976
  handler: async (args) => {
4446
- const fields = await getWidgetFields(ctx, args.version);
4977
+ const fields2 = await getWidgetFields(ctx, args.version);
4447
4978
  const term = args.search?.trim().toLocaleLowerCase();
4448
- const matched = fields.filter((field) => (args.group === void 0 || field.group === args.group) && (term === void 0 || term === "" || field.label.toLocaleLowerCase().includes(term) || field.path.toLocaleLowerCase().includes(term)));
4979
+ const matched = fields2.filter((field) => (args.group === void 0 || field.group === args.group) && (term === void 0 || term === "" || field.label.toLocaleLowerCase().includes(term) || field.path.toLocaleLowerCase().includes(term)));
4449
4980
  const wouldNotFit = estimateSize(matched) > MAX_FIELD_RESPONSE_CHARS;
4450
4981
  if (args.group === void 0 && !term && wouldNotFit) {
4451
4982
  const index = {};
4452
- for (const field of fields) {
4983
+ for (const field of fields2) {
4453
4984
  const entry = index[field.group] ?? { fields: 0, examples: [] };
4454
4985
  entry.fields += 1;
4455
4986
  if (entry.examples.length < 2)
4456
4987
  entry.examples.push(field.path);
4457
4988
  index[field.group] = entry;
4458
4989
  }
4459
- return summaryWithJson(`${fields.length} settable ${args.version.toUpperCase()} widget fields across ${Object.keys(index).length} groups. Call again with group: "<name>" or search: "<text>" to see fields and their allowed values.`, { groups: index });
4990
+ return summaryWithJson(`${fields2.length} settable ${args.version.toUpperCase()} widget fields across ${Object.keys(index).length} groups. Call again with group: "<name>" or search: "<text>" to see fields and their allowed values.`, { groups: index });
4460
4991
  }
4461
4992
  if (matched.length === 0) {
4462
4993
  return `No ${args.version.toUpperCase()} widget field matches ${args.group === void 0 ? `search "${args.search}"` : `group "${args.group}"`}. Call joy_get_widget_fields with no arguments to list the groups.`;
@@ -4467,7 +4998,7 @@ function registerWidgetFieldReadTools(server, ctx) {
4467
4998
  entries.push({ label, path, type, current, ...values === void 0 ? {} : { values } });
4468
4999
  groups.set(group, entries);
4469
5000
  }
4470
- return summaryWithJson(`${matched.length} of ${fields.length} settable ${args.version.toUpperCase()} widget fields. Use the server-provided paths and values when preparing changes.`, { groups: Object.fromEntries(groups) });
5001
+ return summaryWithJson(`${matched.length} of ${fields2.length} settable ${args.version.toUpperCase()} widget fields. Use the server-provided paths and values when preparing changes.`, { groups: Object.fromEntries(groups) });
4471
5002
  }
4472
5003
  });
4473
5004
  }
@@ -4481,8 +5012,8 @@ function registerWidgetFieldWriteTools(server, ctx) {
4481
5012
  destructiveHint: true,
4482
5013
  handler: async (args) => {
4483
5014
  const current = await getWidgetFields(ctx, args.version);
4484
- const fields = expandLauncherHideFields(args.fields, current);
4485
- const changes = fields.map(({ path, value }) => ({
5015
+ const fields2 = expandLauncherHideFields(args.fields, current);
5016
+ const changes = fields2.map(({ path, value }) => ({
4486
5017
  path,
4487
5018
  from: current.find((field) => field.path === path)?.current,
4488
5019
  to: value
@@ -4497,7 +5028,7 @@ The server will validate every requested path and value when applied; this previ
4497
5028
  result = await ctx.api.mutateData(fieldsPath, {
4498
5029
  method: "PUT",
4499
5030
  query: { version: args.version, target: args.target },
4500
- body: { fields }
5031
+ body: { fields: fields2 }
4501
5032
  });
4502
5033
  } catch (error) {
4503
5034
  throw brandingErrorGuidance(error, "draft", args.target) ?? error;
@@ -4512,7 +5043,7 @@ The server will validate every requested path and value when applied; this previ
4512
5043
  const refusedPaths = new Set(result.refused.map((field) => field.path));
4513
5044
  const applied2 = [];
4514
5045
  const unconfirmed = [];
4515
- for (const field of fields) {
5046
+ for (const field of fields2) {
4516
5047
  if (refusedPaths.has(field.path))
4517
5048
  continue;
4518
5049
  const accepted = result.applied.find((entry) => entry.path === field.path);
@@ -4540,7 +5071,7 @@ Do not report refused or unconfirmed fields as saved. Inspect the current widget
4540
5071
  }
4541
5072
 
4542
5073
  // ../mcp-core/build/tools/referralBrandingWrite.js
4543
- import { z as z20 } from "zod";
5074
+ import { z as z22 } from "zod";
4544
5075
  var POPUP_DISPLAY_TYPES = ["auto_show_popup", "show_bubble_teaser", "do_not_show"];
4545
5076
  var REFERRAL_WIDGET_ARGUMENTS = {
4546
5077
  brand_color: "brandColor",
@@ -4556,24 +5087,24 @@ var REFERRAL_WIDGET_ARGUMENTS = {
4556
5087
  frequency_show_popup: "frequencyShowPopup",
4557
5088
  is_show_subscribe_newsletter: "isShowSubscribeNewsletter"
4558
5089
  };
4559
- var hexColor = (description) => z20.string().refine(isHexColor, { message: "Must be a 3- or 6-digit hex color such as #123 or #112233." }).optional().describe(description);
5090
+ var hexColor = (description) => z22.string().refine(isHexColor, { message: "Must be a 3- or 6-digit hex color such as #123 or #112233." }).optional().describe(description);
4560
5091
  var referralWidgetInputShape = {
4561
5092
  brand_color: hexColor("Primary accent color for the referral widget."),
4562
5093
  text_color: hexColor("Main referral-widget text color."),
4563
5094
  action_bg_color: hexColor("Referral-widget action button background color."),
4564
5095
  action_text_color: hexColor("Referral-widget action button text color."),
4565
- color_type: z20.enum(["template", "custom-solid", "custom-gradient"]).optional().describe("Color mode. This endpoint can select an existing template or gradient, but cannot define gradient stops."),
5096
+ color_type: z22.enum(["template", "custom-solid", "custom-gradient"]).optional().describe("Color mode. This endpoint can select an existing template or gradient, but cannot define gradient stops."),
4566
5097
  custom_solid_color: hexColor("Custom solid background color used when color_type is custom-solid."),
4567
- theme_text_font: z20.string().trim().min(1).optional().describe("Referral-widget font family, for example Open+Sans."),
4568
- type_show_popup: z20.enum(POPUP_DISPLAY_TYPES).optional().describe("How the referral popup appears: automatically, as a bubble teaser, or not at all."),
4569
- is_auto_close_pop_up: z20.boolean().optional().describe("Whether an automatically shown referral popup closes itself."),
4570
- closing_pop_up_time: z20.number().int().min(1).optional().describe("Auto-close delay in seconds; must be a positive integer."),
4571
- frequency_show_popup: z20.number().int().min(1).max(10).optional().describe("Number of automatic popup displays, from 1 through 10. The admin UI stores this as an integer."),
4572
- is_show_subscribe_newsletter: z20.boolean().optional().describe("Show the newsletter subscription option in the referral popup."),
4573
- reason: z20.string().min(3).describe("Why the merchant wants the live referral widget changed."),
4574
- apply: z20.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
5098
+ theme_text_font: z22.string().trim().min(1).optional().describe("Referral-widget font family, for example Open+Sans."),
5099
+ type_show_popup: z22.enum(POPUP_DISPLAY_TYPES).optional().describe("How the referral popup appears: automatically, as a bubble teaser, or not at all."),
5100
+ is_auto_close_pop_up: z22.boolean().optional().describe("Whether an automatically shown referral popup closes itself."),
5101
+ closing_pop_up_time: z22.number().int().min(1).optional().describe("Auto-close delay in seconds; must be a positive integer."),
5102
+ frequency_show_popup: z22.number().int().min(1).max(10).optional().describe("Number of automatic popup displays, from 1 through 10. The admin UI stores this as an integer."),
5103
+ is_show_subscribe_newsletter: z22.boolean().optional().describe("Show the newsletter subscription option in the referral popup."),
5104
+ reason: z22.string().min(3).describe("Why the merchant wants the live referral widget changed."),
5105
+ apply: z22.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
4575
5106
  };
4576
- var referralWidgetArgsSchema = z20.object(referralWidgetInputShape).strict();
5107
+ var referralWidgetArgsSchema = z22.object(referralWidgetInputShape).strict();
4577
5108
  function parseReferralWidgetArgs(input) {
4578
5109
  const parsed = referralWidgetArgsSchema.safeParse(input);
4579
5110
  if (parsed.success)
@@ -4640,7 +5171,7 @@ function registerReferralBrandingWriteTools(server, ctx) {
4640
5171
  }
4641
5172
 
4642
5173
  // ../mcp-core/build/tools/redeemInCart.js
4643
- import { z as z21 } from "zod";
5174
+ import { z as z23 } from "zod";
4644
5175
  var PATH = "/shop/branding/redeem-inline";
4645
5176
  var styles = [
4646
5177
  "minimal",
@@ -4654,18 +5185,18 @@ var styles = [
4654
5185
  ];
4655
5186
  var fills = ["solid", "gradient-br", "gradient-r", "gradient-b", "radial"];
4656
5187
  var gradients = ["gradient-br", "gradient-r", "gradient-b", "radial"];
4657
- var hex = z21.string().refine((value) => value === "" || isHexColor(value), "Use a hex color or an empty string to clear.");
4658
- var https = z21.string().refine((value) => value === "" || (() => {
5188
+ var hex = z23.string().refine((value) => value === "" || isHexColor(value), "Use a hex color or an empty string to clear.");
5189
+ var https = z23.string().refine((value) => value === "" || (() => {
4659
5190
  try {
4660
5191
  return new URL(value).protocol === "https:";
4661
5192
  } catch {
4662
5193
  return false;
4663
5194
  }
4664
5195
  })(), "Use an HTTPS URL or an empty string to clear.");
4665
- var fill = z21.object({
4666
- fillType: z21.enum(fills).optional(),
4667
- colors: z21.array(z21.string()).min(1).max(2).optional(),
4668
- lastGradient: z21.enum(gradients).optional()
5196
+ var fill = z23.object({
5197
+ fillType: z23.enum(fills).optional(),
5198
+ colors: z23.array(z23.string()).min(1).max(2).optional(),
5199
+ lastGradient: z23.enum(gradients).optional()
4669
5200
  }).strict();
4670
5201
  var colorFields = [
4671
5202
  "text",
@@ -4680,12 +5211,12 @@ var colorFields = [
4680
5211
  "icon",
4681
5212
  "dropdownAccent"
4682
5213
  ];
4683
- var colorsSchema = z21.object({
5214
+ var colorsSchema = z23.object({
4684
5215
  background: fill.optional(),
4685
5216
  button: fill.optional(),
4686
5217
  ...Object.fromEntries(colorFields.map((key) => [key, hex.optional()])),
4687
- buttonBorder: z21.string().optional(),
4688
- buttonHoverBorder: z21.string().optional()
5218
+ buttonBorder: z23.string().optional(),
5219
+ buttonHoverBorder: z23.string().optional()
4689
5220
  }).strict();
4690
5221
  var cssShapeFields = [
4691
5222
  "buttonHoverOpacity",
@@ -4708,20 +5239,20 @@ var cssShapeFields = [
4708
5239
  "buttonSweepEasing"
4709
5240
  ];
4710
5241
  var fonts = ["default", "inter", "space-grotesk", "playfair", "pt-serif", "nunito"];
4711
- var shapeSchema = z21.object({
4712
- ...Object.fromEntries(cssShapeFields.map((key) => [key, z21.string().optional()])),
5242
+ var shapeSchema = z23.object({
5243
+ ...Object.fromEntries(cssShapeFields.map((key) => [key, z23.string().optional()])),
4713
5244
  borderColor: hex.optional(),
4714
5245
  patternColor: hex.optional(),
4715
5246
  dividerColor: hex.optional(),
4716
- borderRadius: z21.enum(["none", "sharp", "soft", "rounded", "extra"]).optional(),
4717
- backgroundPattern: z21.enum(["none", "diagonal", "crosshatch", "grid", "column"]).optional(),
4718
- headingFont: z21.enum(fonts).optional(),
4719
- bodyFont: z21.enum(["", ...fonts]).optional(),
4720
- buttonFont: z21.enum(["", ...fonts]).optional(),
4721
- applyLoadingStyle: z21.enum(["spinner", "spinner-label", "dots"]).optional(),
4722
- buttonHoverEffect: z21.enum(["auto", "color", "fade", "lift", "none", "sweep"]).optional(),
4723
- boxShadow: z21.enum(["none", "small", "medium", "large", "hard"]).optional(),
4724
- iconName: z21.enum([
5247
+ borderRadius: z23.enum(["none", "sharp", "soft", "rounded", "extra"]).optional(),
5248
+ backgroundPattern: z23.enum(["none", "diagonal", "crosshatch", "grid", "column"]).optional(),
5249
+ headingFont: z23.enum(fonts).optional(),
5250
+ bodyFont: z23.enum(["", ...fonts]).optional(),
5251
+ buttonFont: z23.enum(["", ...fonts]).optional(),
5252
+ applyLoadingStyle: z23.enum(["spinner", "spinner-label", "dots"]).optional(),
5253
+ buttonHoverEffect: z23.enum(["auto", "color", "fade", "lift", "none", "sweep"]).optional(),
5254
+ boxShadow: z23.enum(["none", "small", "medium", "large", "hard"]).optional(),
5255
+ iconName: z23.enum([
4725
5256
  "gift",
4726
5257
  "crown",
4727
5258
  "shopping-bag",
@@ -4734,9 +5265,9 @@ var shapeSchema = z21.object({
4734
5265
  ]).optional(),
4735
5266
  iconImage: https.optional(),
4736
5267
  backgroundImage: https.optional(),
4737
- backgroundImageOverlay: z21.number().min(0).max(100).optional(),
4738
- dividerWidth: z21.enum(["thin", "medium", "thick"]).optional(),
4739
- dividerStyle: z21.enum(["solid", "dashed", "dotted"]).optional()
5268
+ backgroundImageOverlay: z23.number().min(0).max(100).optional(),
5269
+ dividerWidth: z23.enum(["thin", "medium", "thick"]).optional(),
5270
+ dividerStyle: z23.enum(["solid", "dashed", "dotted"]).optional()
4740
5271
  }).strict();
4741
5272
  var behaviorFields = {
4742
5273
  hide_for_guest: "hideForGuest",
@@ -4749,18 +5280,18 @@ var behaviorFields = {
4749
5280
  show_earn_back: "showEarnBack",
4750
5281
  has_reload_page: "hasReloadPage"
4751
5282
  };
4752
- var behaviorInput = Object.fromEntries(Object.keys(behaviorFields).map((key) => [key, z21.boolean().optional()]));
4753
- var common = { reason: z21.string().min(3), apply: z21.boolean().optional() };
4754
- var behaviorArgs = z21.object({
5283
+ var behaviorInput = Object.fromEntries(Object.keys(behaviorFields).map((key) => [key, z23.boolean().optional()]));
5284
+ var common = { reason: z23.string().min(3), apply: z23.boolean().optional() };
5285
+ var behaviorArgs = z23.object({
4755
5286
  ...behaviorInput,
4756
- position_mode: z21.enum(["above_cart_summary", "custom"]).optional(),
4757
- custom_type: z21.enum(["snippet", "selector"]).optional(),
4758
- css_selector: z21.string().optional(),
5287
+ position_mode: z23.enum(["above_cart_summary", "custom"]).optional(),
5288
+ custom_type: z23.enum(["snippet", "selector"]).optional(),
5289
+ css_selector: z23.string().optional(),
4759
5290
  ...common
4760
5291
  }).strict();
4761
- var designArgs = z21.object({
4762
- style: z21.enum(styles).optional(),
4763
- set_active: z21.boolean().optional(),
5292
+ var designArgs = z23.object({
5293
+ style: z23.enum(styles).optional(),
5294
+ set_active: z23.boolean().optional(),
4764
5295
  colors: colorsSchema.optional(),
4765
5296
  shape: shapeSchema.optional(),
4766
5297
  ...common
@@ -4809,7 +5340,7 @@ function applied(changes, data) {
4809
5340
  });
4810
5341
  }
4811
5342
  async function getRedeemInCart(ctx, input) {
4812
- const args = parse(z21.object({ style: z21.enum(styles).optional() }).strict(), input);
5343
+ const args = parse(z23.object({ style: z23.enum(styles).optional() }).strict(), input);
4813
5344
  const { data: raw } = await ctx.api.getData(PATH);
4814
5345
  const data = raw ?? {};
4815
5346
  const active = data.design?.activeStyle ?? "minimal";
@@ -4977,7 +5508,7 @@ function registerRedeemInCartReadTools(server, ctx) {
4977
5508
  name: "joy_get_redeem_in_cart",
4978
5509
  title: "Read Redeem-in-Cart settings",
4979
5510
  description: 'Read all effective behavior flags, position, active preset, its resolved colors and shape, and stored per-preset overrides. Admin "Redeem in cart V2" uses the internal V4 engine (redeemCartV4Ready); availability requires metafield publish and the app embed. Pass style to inspect one preset, including read-only legacy earnBack.show.',
4980
- inputSchema: { style: z21.enum(styles).optional() },
5511
+ inputSchema: { style: z23.enum(styles).optional() },
4981
5512
  handler: (args) => getRedeemInCart(ctx, args)
4982
5513
  });
4983
5514
  }
@@ -5061,26 +5592,26 @@ function registerThemeSetupTools(server, ctx) {
5061
5592
  }
5062
5593
 
5063
5594
  // ../mcp-core/build/tools/imageUpload.js
5064
- import { z as z22 } from "zod";
5595
+ import { z as z24 } from "zod";
5065
5596
  var imageUploadInputSchema = {
5066
- source_url: z22.string().url().optional().describe("Public URL of an already hosted image. Provide exactly one of source_url or data_url."),
5067
- data_url: z22.string().optional().describe("Image bytes as a data URL, for example an image the agent generated. Provide exactly one of data_url or source_url."),
5068
- filename: z22.string().min(1).max(200).optional().describe("Filename to use for the uploaded image."),
5069
- alt: z22.string().max(512).optional().describe("Merchant-provided alternative text for the image."),
5070
- reason: z22.string().min(3).describe("Why the merchant wants this image uploaded."),
5071
- apply: z22.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
5597
+ source_url: z24.string().url().optional().describe("Public URL of an already hosted image. Provide exactly one of source_url or data_url."),
5598
+ data_url: z24.string().optional().describe("Image bytes as a data URL, for example an image the agent generated. Provide exactly one of data_url or source_url."),
5599
+ filename: z24.string().min(1).max(200).optional().describe("Filename to use for the uploaded image."),
5600
+ alt: z24.string().max(512).optional().describe("Merchant-provided alternative text for the image."),
5601
+ reason: z24.string().min(3).describe("Why the merchant wants this image uploaded."),
5602
+ apply: z24.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
5072
5603
  };
5073
5604
  function imageUploadErrorGuidance(error) {
5074
5605
  if (!(error instanceof JoyApiError))
5075
5606
  return void 0;
5076
5607
  if (error.status === 400) {
5077
- return new JoyApiError("validation", "The image source was rejected (not an image, unsupported type, or a blocked URL). Do not retry the same input.", error.status);
5608
+ return new JoyApiError("validation", "The image source was rejected (not an image, unsupported type, or a blocked URL). Do not retry the same input.", error.status, error.serverCode);
5078
5609
  }
5079
5610
  if (error.status === 413) {
5080
- return new JoyApiError("validation", "The image is too large; the upload cap is 20 MB. Resize or compress it before trying again.", error.status);
5611
+ return new JoyApiError("validation", "The image is too large; the upload cap is 20 MB. Resize or compress it before trying again.", error.status, error.serverCode);
5081
5612
  }
5082
5613
  if (error.status === 502) {
5083
- return new JoyApiError("server", "Shopify rejected the upload or the file never went live. Safe to retry ONCE; if it fails again, stop and report the failure to the merchant.", error.status);
5614
+ return new JoyApiError("server", "Shopify rejected the upload or the file never went live. Safe to retry ONCE; if it fails again, stop and report the failure to the merchant.", error.status, error.serverCode);
5084
5615
  }
5085
5616
  return void 0;
5086
5617
  }
@@ -5113,14 +5644,14 @@ function registerImageUploadTools(server, ctx) {
5113
5644
  }
5114
5645
 
5115
5646
  // ../mcp-core/build/prompts.js
5116
- import { z as z23 } from "zod";
5647
+ import { z as z25 } from "zod";
5117
5648
  function registerAllPrompts(server) {
5118
5649
  server.registerPrompt("support_missing_points", {
5119
5650
  title: "Investigate missing points for an order",
5120
5651
  description: "Support workflow: verify whether a customer really is missing points for an order, explain why, and prepare a compensation proposal with a proper audit reason.",
5121
5652
  argsSchema: {
5122
- email: z23.string().describe("Customer's email address"),
5123
- order_number: z23.string().optional().describe("Order name/number the customer says earned nothing, e.g. #1234")
5653
+ email: z25.string().describe("Customer's email address"),
5654
+ order_number: z25.string().optional().describe("Order name/number the customer says earned nothing, e.g. #1234")
5124
5655
  }
5125
5656
  }, ({ email, order_number }) => ({
5126
5657
  messages: [
@@ -5147,7 +5678,7 @@ function registerAllPrompts(server) {
5147
5678
  title: "Loyalty program review (health + revenue + tiers + referral)",
5148
5679
  description: "Periodic business review: program health check, revenue impact, tier performance, and referral results \u2014 with data-source rules so numbers from different systems are never mixed.",
5149
5680
  argsSchema: {
5150
- period: z23.string().optional().describe("Reporting period, e.g. 'last 30 days', 'last month', 'this quarter'. Defaults to last 30 days.")
5681
+ period: z25.string().optional().describe("Reporting period, e.g. 'last 30 days', 'last month', 'this quarter'. Defaults to last 30 days.")
5151
5682
  }
5152
5683
  }, ({ period }) => ({
5153
5684
  messages: [
@@ -5178,7 +5709,7 @@ function registerAllPrompts(server) {
5178
5709
  title: "Investigate a member for abuse risk",
5179
5710
  description: "Fraud/abuse triage for one member: risk flags, point activity, and referral history \u2014 with the mandatory caveat about signal limitations and no punitive action.",
5180
5711
  argsSchema: {
5181
- email: z23.string().describe("Email of the member to investigate")
5712
+ email: z25.string().describe("Email of the member to investigate")
5182
5713
  }
5183
5714
  }, ({ email }) => ({
5184
5715
  messages: [
@@ -5205,8 +5736,8 @@ function registerAllPrompts(server) {
5205
5736
  title: "Set up a loyalty program safely",
5206
5737
  description: "Guided loyalty-program setup: inspect the current configuration, propose a minimal program, and make each approved configuration change one at a time.",
5207
5738
  argsSchema: {
5208
- business_type: z23.string().optional().describe("Business type, e.g. 'fashion' or 'coffee shop'."),
5209
- goal: z23.string().optional().describe("Primary loyalty goal, e.g. 'increase repeat purchases'.")
5739
+ business_type: z25.string().optional().describe("Business type, e.g. 'fashion' or 'coffee shop'."),
5740
+ goal: z25.string().optional().describe("Primary loyalty goal, e.g. 'increase repeat purchases'.")
5210
5741
  }
5211
5742
  }, ({ business_type, goal }) => ({
5212
5743
  messages: [
@@ -5231,7 +5762,7 @@ function registerAllPrompts(server) {
5231
5762
  title: "Apply storefront branding to a widget draft",
5232
5763
  description: "Guided widget-branding workflow: scan the storefront, explain the detected design, get approval, and save the approved changes to the V4 draft for the merchant to review and publish manually.",
5233
5764
  argsSchema: {
5234
- preferences: z23.string().optional().describe("Optional brand preferences or constraints, e.g. 'keep the current heading font' or 'use a softer button color'.")
5765
+ preferences: z25.string().optional().describe("Optional brand preferences or constraints, e.g. 'keep the current heading font' or 'use a softer button color'.")
5235
5766
  }
5236
5767
  }, ({ preferences }) => ({
5237
5768
  messages: [
@@ -5258,7 +5789,7 @@ function registerAllPrompts(server) {
5258
5789
  title: "Set up the storefront loyalty widget",
5259
5790
  description: "Guided theme-setup workflow: audit the live theme, explain each missing Joy embed or block, and hand the merchant server-provided deep links to finish setup manually.",
5260
5791
  argsSchema: {
5261
- focus: z23.string().optional().describe("Optional setup focus, e.g. 'loyalty widget', 'points calculator', or 'loyalty page'.")
5792
+ focus: z25.string().optional().describe("Optional setup focus, e.g. 'loyalty widget', 'points calculator', or 'loyalty page'.")
5262
5793
  }
5263
5794
  }, ({ focus }) => ({
5264
5795
  messages: [
@@ -5298,6 +5829,8 @@ function registerAllTools(server, ctx) {
5298
5829
  registerTierUpgradeTools(server, ctx);
5299
5830
  registerLoyaltyPageTools(server, ctx);
5300
5831
  registerLoyaltyPageTextTools(server, ctx);
5832
+ registerLoyaltyHubTools(server, ctx);
5833
+ registerLoyaltyHubTextTools(server, ctx);
5301
5834
  registerThemeSetupTools(server, ctx);
5302
5835
  registerBrandingReadTools(server, ctx);
5303
5836
  registerWidgetFieldReadTools(server, ctx);
@@ -5310,7 +5843,9 @@ function registerAllTools(server, ctx) {
5310
5843
  registerAnalyticsWriteTools(server, ctx);
5311
5844
  registerLoyaltyPageWriteTools(server, ctx);
5312
5845
  registerLoyaltyPageTextWriteTools(server, ctx);
5313
- log.info("write tools ENABLED (award/deduct/adjust/redeem/set-tier/refund-coupon/create-referral-invite/enable-analytics-v2). Requires the deployed REST v2 write-path (reason/source/Idempotency-Key support) \u2014 against an older backend, reason/source are silently dropped and idempotency is not enforced.");
5846
+ registerLoyaltyHubWriteTools(server, ctx);
5847
+ registerLoyaltyHubTextWriteTools(server, ctx);
5848
+ log.info("write tools ENABLED (award/deduct/adjust/redeem/set-tier/refund-coupon/create-referral-invite/enable-analytics-v2/loyalty-hub-design/loyalty-hub-text). Requires the deployed REST v2 write-path (reason/source/Idempotency-Key support) \u2014 against an older backend, reason/source are silently dropped and idempotency is not enforced.");
5314
5849
  }
5315
5850
  if (ctx.config.configWritesEnabled) {
5316
5851
  registerProgramConfigTools(server, ctx);
@@ -5326,19 +5861,486 @@ function registerAllTools(server, ctx) {
5326
5861
  }
5327
5862
  }
5328
5863
 
5864
+ // ../mcp-core/build/telemetry/knownTools.js
5865
+ var KNOWN_TOOLS = Object.freeze({
5866
+ joy_add_translation_language: "config_write",
5867
+ joy_adjust_points: "write",
5868
+ joy_award_points: "write",
5869
+ joy_check_theme_setup: "read",
5870
+ joy_configure_earning_program: "config_write",
5871
+ joy_configure_redeem_in_cart: "config_write",
5872
+ joy_configure_redeem_in_cart_design: "config_write",
5873
+ joy_configure_redemption_program: "config_write",
5874
+ joy_configure_referral_program: "config_write",
5875
+ joy_configure_referral_widget: "config_write",
5876
+ joy_configure_shop_settings: "config_write",
5877
+ joy_configure_tier: "config_write",
5878
+ joy_configure_tier_perks: "config_write",
5879
+ joy_configure_tier_reward: "config_write",
5880
+ joy_configure_tier_settings: "config_write",
5881
+ joy_configure_widget_draft: "config_write",
5882
+ joy_create_referral_invite: "write",
5883
+ joy_deduct_points: "write",
5884
+ joy_enable_analytics_v2: "write",
5885
+ joy_explain_order_points: "read",
5886
+ joy_find_member: "read",
5887
+ joy_get_analytics_report: "read",
5888
+ joy_get_loyalty_health_check: "read",
5889
+ joy_get_loyalty_hub_design: "read",
5890
+ joy_get_loyalty_hub_text: "read",
5891
+ joy_get_loyalty_overview: "read",
5892
+ joy_get_loyalty_page_design: "read",
5893
+ joy_get_loyalty_page_text: "read",
5894
+ joy_get_loyalty_revenue_report: "read",
5895
+ joy_get_member_activity: "read",
5896
+ joy_get_member_referrals: "read",
5897
+ joy_get_member_rewards: "read",
5898
+ joy_get_member_store_credit: "read",
5899
+ joy_get_redeem_in_cart: "read",
5900
+ joy_get_referral_performance: "read",
5901
+ joy_get_referral_program: "read",
5902
+ joy_get_retention_risk_members: "read",
5903
+ joy_get_tier_performance_breakdown: "read",
5904
+ joy_get_tier_upgrade_candidates: "read",
5905
+ joy_get_widget_fields: "read",
5906
+ joy_investigate_member_risk: "read",
5907
+ joy_list_activities: "read",
5908
+ joy_list_earning_rules: "read",
5909
+ joy_list_members: "read",
5910
+ joy_list_redemption_rules: "read",
5911
+ joy_list_tiers: "read",
5912
+ joy_redeem_reward: "write",
5913
+ joy_refund_coupon: "write",
5914
+ joy_revert_loyalty_hub_design: "write",
5915
+ joy_revert_loyalty_hub_text: "write",
5916
+ joy_revert_loyalty_page_design: "write",
5917
+ joy_scan_brand_design: "read",
5918
+ joy_set_member_tier: "write",
5919
+ joy_set_widget_fields: "config_write",
5920
+ joy_update_loyalty_hub_design: "write",
5921
+ joy_update_loyalty_hub_text: "write",
5922
+ joy_update_loyalty_page_design: "write",
5923
+ joy_update_loyalty_page_text: "write",
5924
+ joy_upload_image: "config_write"
5925
+ });
5926
+
5927
+ // ../mcp-core/build/telemetry/recorder.js
5928
+ import { randomUUID as randomUUID2 } from "node:crypto";
5929
+ var fields = TELEMETRY_SCHEMA.events["mcp.tool_call"].fields;
5930
+ var toMillis = (value) => Math.min(36e5, Math.max(0, Math.round(value)));
5931
+ function classify(record, response, { exposed, known = KNOWN_TOOLS }) {
5932
+ let outcome = record.outcome ?? "internal_error";
5933
+ let error_kind = record.error_kind;
5934
+ if ("error" in response) {
5935
+ outcome = "internal_error";
5936
+ error_kind = "jsonrpc_error";
5937
+ } else if (!record.entered && "result" in response && response.result.isError) {
5938
+ outcome = "rejected";
5939
+ error_kind = exposed.has(record.tool) ? "invalid_input" : Object.hasOwn(known, record.tool) ? "tool_not_exposed" : "unknown_tool";
5940
+ } else if (!record.outcome) {
5941
+ error_kind = "unclassified";
5942
+ }
5943
+ const result = "result" in response ? response.result : void 0;
5944
+ const content = Array.isArray(result?.content) ? result.content : [];
5945
+ const result_bytes = content.reduce((sum, item) => sum + (typeof item?.text === "string" ? Buffer.byteLength(item.text, "utf8") : 0), 0);
5946
+ return {
5947
+ tool_kind: Object.hasOwn(known, record.tool) ? known[record.tool] : "unknown",
5948
+ outcome,
5949
+ ...outcome !== "success" && error_kind ? { error_kind } : {},
5950
+ ...outcome === "tool_error" && record.error_code ? { error_code: record.error_code } : {},
5951
+ ...outcome === "tool_error" && record.http_status ? { http_status: record.http_status } : {},
5952
+ result_bytes: Math.min(1e9, result_bytes)
5953
+ };
5954
+ }
5955
+ function createRecorder({ sink, base: inputBase, receivedAt, sweepTtlMs, now = performance.now.bind(performance) }) {
5956
+ const base = {};
5957
+ telemetrySafe("base", () => {
5958
+ for (const [key, value] of Object.entries(inputBase)) {
5959
+ const clean = sanitizeString(TELEMETRY_SCHEMA, "mcp.session_start", key, value);
5960
+ if (clean !== void 0)
5961
+ base[key] = clean;
5962
+ }
5963
+ });
5964
+ const pending = /* @__PURE__ */ new Map();
5965
+ const exposed = /* @__PURE__ */ new Set();
5966
+ const instrumented = /* @__PURE__ */ new WeakSet();
5967
+ let closed = false;
5968
+ let initialized = false;
5969
+ function emit(event) {
5970
+ const transport = inputBase.transport;
5971
+ const checked = validateEvent(TELEMETRY_SCHEMA, event, { mode: transport === "stdio" ? "client" : "full", transport });
5972
+ if (!checked.ok) {
5973
+ reportOnce(`invalid ${checked.errors[0]}`);
5974
+ return;
5975
+ }
5976
+ sink.emit(checked.event);
5977
+ }
5978
+ function finish(id, record, response) {
5979
+ if (record.closed || pending.get(id) !== record)
5980
+ return;
5981
+ record.closed = true;
5982
+ pending.delete(id);
5983
+ const elapsed = now();
5984
+ const duration_ms = toMillis(elapsed - record.t0);
5985
+ const busy = record.api_inflight ? elapsed - (record.api_busy_since ?? elapsed) : 0;
5986
+ emit({
5987
+ ...base,
5988
+ event: "mcp.tool_call",
5989
+ schema_version: 1,
5990
+ event_id: randomUUID2(),
5991
+ call_id: record.call_id,
5992
+ tool: record.tool,
5993
+ ...classify(record, response, { exposed }),
5994
+ duration_ms,
5995
+ ...base.transport === "remote" && receivedAt !== void 0 ? { pre_ms: toMillis(record.t0 - receivedAt) } : {},
5996
+ api_calls: Math.min(1e4, record.api_calls),
5997
+ api_ms: Math.min(duration_ms, toMillis(record.api_ms_acc + busy))
5998
+ });
5999
+ }
6000
+ function cancel(id, record, outcome, kind) {
6001
+ record.outcome = outcome;
6002
+ record.error_kind = kind;
6003
+ finish(id, record, { jsonrpc: "2.0", id, result: {} });
6004
+ }
6005
+ function buildHeaders(call_id, tool) {
6006
+ const values = { call_id, tool, ...base };
6007
+ const mapping = {
6008
+ call_id: "Call-Id",
6009
+ tool: "Tool",
6010
+ transport: "Transport",
6011
+ server_version: "Version",
6012
+ ...base.transport === "stdio" ? { mcp_client_name: "Client", session_id: "Session" } : {}
6013
+ };
6014
+ const result = {};
6015
+ for (const [field, suffix] of Object.entries(mapping)) {
6016
+ const value = sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", field, values[field]);
6017
+ if (value !== void 0)
6018
+ result[`X-Joy-Mcp-${suffix}`] = value;
6019
+ }
6020
+ return Object.freeze(result);
6021
+ }
6022
+ const timer = sweepTtlMs === void 0 ? void 0 : setInterval(() => {
6023
+ for (const [id, record] of pending) {
6024
+ telemetrySafe("sweep", () => {
6025
+ if (now() - record.t0 >= sweepTtlMs)
6026
+ cancel(id, record, "cancelled", "no_response");
6027
+ });
6028
+ }
6029
+ }, 6e4);
6030
+ timer?.unref();
6031
+ return {
6032
+ expose(name) {
6033
+ telemetrySafe("expose", () => {
6034
+ if (sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", "tool", name))
6035
+ exposed.add(name);
6036
+ });
6037
+ },
6038
+ instrumentTransport(transport) {
6039
+ telemetrySafe("instrument", () => {
6040
+ if (instrumented.has(transport))
6041
+ return;
6042
+ instrumented.add(transport);
6043
+ if (!transport.onmessage)
6044
+ reportOnce("instrumentTransport called before connect");
6045
+ const onmessage = transport.onmessage;
6046
+ const send = transport.send;
6047
+ transport.onmessage = function(message, extra) {
6048
+ const record = telemetrySafe("receive", () => {
6049
+ if (closed || !("method" in message))
6050
+ return;
6051
+ const params = message.params;
6052
+ if (message.method === "initialize" && !(base.transport === "stdio" && initialized)) {
6053
+ initialized = true;
6054
+ const info = params?.clientInfo;
6055
+ const session = {
6056
+ ...base,
6057
+ event: "mcp.session_start",
6058
+ schema_version: 1,
6059
+ event_id: randomUUID2(),
6060
+ tools_exposed: exposed.size
6061
+ };
6062
+ const capabilities = Object.keys(params?.capabilities ?? {}).filter((key) => /^[a-z][a-zA-Z0-9_]{0,31}$/.test(key)).sort().slice(0, 10).join(",");
6063
+ for (const [key, value] of Object.entries({
6064
+ mcp_client_name: info?.name,
6065
+ mcp_client_version: info?.version,
6066
+ protocol_version: params?.protocolVersion,
6067
+ client_capabilities: capabilities
6068
+ })) {
6069
+ const clean = sanitizeString(TELEMETRY_SCHEMA, "mcp.session_start", key, value);
6070
+ if (clean === void 0)
6071
+ continue;
6072
+ session[key] = clean;
6073
+ if (base.transport === "stdio" && fields[key])
6074
+ base[key] = clean;
6075
+ }
6076
+ emit(session);
6077
+ }
6078
+ if (message.method === "notifications/cancelled" && base.transport === "stdio") {
6079
+ const id = params?.requestId;
6080
+ const previous2 = pending.get(id);
6081
+ if (previous2)
6082
+ cancel(id, previous2, "cancelled", "client_cancelled");
6083
+ }
6084
+ if (message.method !== "tools/call" || !("id" in message))
6085
+ return;
6086
+ const previous = pending.get(message.id);
6087
+ if (previous)
6088
+ telemetrySafe("duplicate", () => cancel(message.id, previous, "internal_error", "duplicate_request_id"));
6089
+ const tool = sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", "tool", params?.name) ?? "invalid_name";
6090
+ const call_id = randomUUID2();
6091
+ const record2 = {
6092
+ call_id,
6093
+ tool,
6094
+ t0: now(),
6095
+ entered: false,
6096
+ closed: false,
6097
+ api_calls: 0,
6098
+ api_inflight: 0,
6099
+ api_ms_acc: 0,
6100
+ headers: buildHeaders(call_id, tool),
6101
+ now
6102
+ };
6103
+ pending.set(message.id, record2);
6104
+ return record2;
6105
+ });
6106
+ if (record)
6107
+ return als.run(record, () => onmessage?.call(this, message, extra));
6108
+ return onmessage?.call(this, message, extra);
6109
+ };
6110
+ transport.send = function(...args) {
6111
+ telemetrySafe("send", () => {
6112
+ const message = args[0];
6113
+ if (!("id" in message) || message.id === void 0 || !("result" in message || "error" in message))
6114
+ return;
6115
+ const record = currentCall() ?? pending.get(message.id);
6116
+ if (record)
6117
+ finish(message.id, record, message);
6118
+ });
6119
+ return send.apply(this, args);
6120
+ };
6121
+ });
6122
+ },
6123
+ close() {
6124
+ closed = true;
6125
+ if (timer)
6126
+ clearInterval(timer);
6127
+ for (const [id, record] of pending) {
6128
+ telemetrySafe("close", () => cancel(id, record, "cancelled", "no_response"));
6129
+ }
6130
+ }
6131
+ };
6132
+ }
6133
+
6134
+ // ../mcp-core/build/telemetry/sinks.js
6135
+ var noopSink = { emit() {
6136
+ } };
6137
+ function createRelaySink({ apiBaseUrl, authHeaders: authHeaders2, fetchImpl = fetch, now = Date.now, timers = globalThis }) {
6138
+ const queue = [];
6139
+ let disabled = false;
6140
+ let dueAt = 0;
6141
+ let timer;
6142
+ let active;
6143
+ let flushing;
6144
+ const headers = { "Content-Type": "application/json" };
6145
+ telemetrySafe("relay_headers", () => {
6146
+ for (const [key, value] of Object.entries(authHeaders2)) {
6147
+ if (["x-joy-loyalty-app-key", "x-joy-loyalty-secret-key", "x-joy-private-bot-key"].includes(key.toLowerCase()))
6148
+ headers[key] = value;
6149
+ }
6150
+ });
6151
+ function clearTimer() {
6152
+ if (timer)
6153
+ timers.clearTimeout(timer);
6154
+ timer = void 0;
6155
+ }
6156
+ function schedule() {
6157
+ if (disabled || flushing || queue.length === 0)
6158
+ return;
6159
+ if (!active && queue.length >= 20) {
6160
+ startBatch();
6161
+ return;
6162
+ }
6163
+ if (timer)
6164
+ return;
6165
+ timer = timers.setTimeout(() => {
6166
+ timer = void 0;
6167
+ telemetrySafe("relay_timer", () => {
6168
+ if (!active)
6169
+ startBatch();
6170
+ });
6171
+ }, Math.max(0, dueAt - now()));
6172
+ timer.unref();
6173
+ }
6174
+ function startBatch() {
6175
+ if (active)
6176
+ return active.done;
6177
+ if (disabled || queue.length === 0)
6178
+ return Promise.resolve();
6179
+ clearTimer();
6180
+ const events = queue.splice(0, 50);
6181
+ const controller = new AbortController();
6182
+ let release;
6183
+ const cancelled = new Promise((resolve) => {
6184
+ release = resolve;
6185
+ });
6186
+ const abort = () => {
6187
+ controller.abort();
6188
+ release();
6189
+ };
6190
+ const timeout = timers.setTimeout(abort, 15e3);
6191
+ timeout.unref();
6192
+ const request = Promise.resolve().then(async () => {
6193
+ if (controller.signal.aborted)
6194
+ return;
6195
+ const response = await fetchImpl(`${apiBaseUrl.replace(/\/$/, "")}/rest_api/v2/internal/mcp-telemetry`, {
6196
+ method: "POST",
6197
+ redirect: "error",
6198
+ headers,
6199
+ body: JSON.stringify({ schema_version: 1, events }),
6200
+ signal: controller.signal
6201
+ });
6202
+ let dailyQuotaExceeded = false;
6203
+ if (response.status === 429) {
6204
+ const body = await response.json().catch(() => void 0);
6205
+ dailyQuotaExceeded = body !== null && typeof body === "object" && "error" in body && body.error === "daily_quota_exceeded";
6206
+ }
6207
+ if ([401, 403, 404, 410].includes(response.status) || dailyQuotaExceeded) {
6208
+ disabled = true;
6209
+ queue.length = 0;
6210
+ clearTimer();
6211
+ }
6212
+ if (!response.ok)
6213
+ throw new Error("relay rejected");
6214
+ await response.body?.cancel();
6215
+ }).catch(() => {
6216
+ telemetrySafe("relay_request", () => {
6217
+ throw new Error("relay request failed");
6218
+ });
6219
+ });
6220
+ const done = Promise.race([request, cancelled]).then(() => {
6221
+ timers.clearTimeout(timeout);
6222
+ active = void 0;
6223
+ telemetrySafe("relay_schedule", schedule);
6224
+ });
6225
+ active = { done, abort };
6226
+ return done;
6227
+ }
6228
+ async function flush(deadlineMs) {
6229
+ clearTimer();
6230
+ const deadline = now() + Math.max(0, deadlineMs);
6231
+ let expired = false;
6232
+ let release;
6233
+ const timeout = new Promise((resolve) => {
6234
+ release = resolve;
6235
+ });
6236
+ const end = timers.setTimeout(() => {
6237
+ expired = true;
6238
+ queue.length = 0;
6239
+ active?.abort();
6240
+ release();
6241
+ }, Math.max(0, deadlineMs));
6242
+ try {
6243
+ while (!expired && !disabled && (active || queue.length)) {
6244
+ if (now() >= deadline) {
6245
+ queue.length = 0;
6246
+ active?.abort();
6247
+ break;
6248
+ }
6249
+ await Promise.race([active?.done ?? startBatch(), timeout]);
6250
+ }
6251
+ } finally {
6252
+ timers.clearTimeout(end);
6253
+ }
6254
+ }
6255
+ return {
6256
+ get disabled() {
6257
+ return disabled;
6258
+ },
6259
+ emit(event) {
6260
+ telemetrySafe("relay_emit", () => {
6261
+ if (disabled)
6262
+ return;
6263
+ if (!TELEMETRY_SCHEMA.ingest_events.includes(event.event))
6264
+ return;
6265
+ const fields2 = TELEMETRY_SCHEMA.events[event.event].fields;
6266
+ const client = Object.fromEntries(Object.entries(event).filter(([key]) => !fields2[key]?.server_stamped));
6267
+ const checked = validateEvent(TELEMETRY_SCHEMA, client, { mode: "client", transport: "stdio" });
6268
+ if (!checked.ok)
6269
+ return;
6270
+ if (queue.length >= 200)
6271
+ queue.shift();
6272
+ if (queue.length === 0)
6273
+ dueAt = now() + 1e4;
6274
+ queue.push(checked.event);
6275
+ schedule();
6276
+ });
6277
+ },
6278
+ flushNow(deadlineMs) {
6279
+ if (flushing)
6280
+ return flushing;
6281
+ flushing = Promise.resolve().then(() => flush(deadlineMs)).catch(() => {
6282
+ telemetrySafe("relay_flush", () => {
6283
+ throw new Error("relay flush failed");
6284
+ });
6285
+ }).finally(() => {
6286
+ flushing = void 0;
6287
+ });
6288
+ return flushing;
6289
+ }
6290
+ };
6291
+ }
6292
+
5329
6293
  // src/index.ts
5330
6294
  var require2 = createRequire(import.meta.url);
5331
6295
  var { version } = require2("../package.json");
5332
6296
  async function main() {
5333
6297
  const config = loadConfig();
5334
6298
  const api = new JoyApi(config);
6299
+ const mode = telemetryMode();
6300
+ const relay = mode === "full" ? createRelaySink({ apiBaseUrl: config.apiBaseUrl, authHeaders: authHeaders(config) }) : void 0;
6301
+ const telemetry = mode === "off" ? void 0 : createRecorder({
6302
+ sink: relay ?? noopSink,
6303
+ base: { transport: "stdio", server_version: version, session_id: randomUUID3() },
6304
+ sweepTtlMs: 6e5
6305
+ });
6306
+ if (relay) {
6307
+ let exiting = false;
6308
+ let exitWork;
6309
+ const shutdown = async (signal = false) => {
6310
+ if (!exiting) {
6311
+ exiting = true;
6312
+ telemetry?.close();
6313
+ exitWork = relay.flushNow(2e3);
6314
+ }
6315
+ if (signal) {
6316
+ await exitWork;
6317
+ process.exit(0);
6318
+ }
6319
+ };
6320
+ process.stdin.on("end", () => {
6321
+ void shutdown();
6322
+ });
6323
+ process.stdin.on("close", () => {
6324
+ void shutdown();
6325
+ });
6326
+ process.on("beforeExit", () => {
6327
+ void shutdown();
6328
+ });
6329
+ process.on("SIGINT", () => {
6330
+ void shutdown(true);
6331
+ });
6332
+ process.on("SIGTERM", () => {
6333
+ void shutdown(true);
6334
+ });
6335
+ }
5335
6336
  const server = new McpServer({ name: "joy-loyalty", version });
5336
- registerAllTools(server, { api, config, cache: new TtlCache() });
6337
+ registerAllTools(server, { api, config, cache: new TtlCache(), telemetry });
5337
6338
  registerAllPrompts(server);
5338
6339
  const transport = new StdioServerTransport();
5339
6340
  await server.connect(transport);
6341
+ telemetry?.instrumentTransport(transport);
5340
6342
  log.info(
5341
- `joy-mcp v${version} ready \u2014 mode=${config.mode}, tools=${config.writesEnabled ? "read+write" : "read-only"}, api=${config.apiBaseUrl}`
6343
+ `joy-mcp v${version} ready \u2014 mode=${config.mode}, tools=${config.writesEnabled ? "read+write" : "read-only"}, api=${config.apiBaseUrl}, telemetry=${mode}`
5342
6344
  );
5343
6345
  }
5344
6346
  main().catch((error) => {