@joy-loyalty/mcp 0.3.8 → 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));
520
+ }
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;
167
536
  }
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.`);
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
 
@@ -255,6 +623,9 @@ var TtlCache = class {
255
623
  this.store.set(key, { expiresAt: now + ttlMs, promise });
256
624
  return promise;
257
625
  }
626
+ delete(key) {
627
+ this.store.delete(key);
628
+ }
258
629
  };
259
630
 
260
631
  // ../mcp-core/build/tools/members.js
@@ -263,65 +634,139 @@ import { z as z2 } from "zod";
263
634
  // ../mcp-core/build/toolkit.js
264
635
  import { z } from "zod";
265
636
 
266
- // ../mcp-core/build/logger.js
267
- var log = {
268
- info(message) {
269
- console.error(`[joy-mcp] ${message}`);
270
- },
271
- error(message) {
272
- console.error(`[joy-mcp:error] ${message}`);
637
+ // ../mcp-core/build/store.js
638
+ function displayName(store) {
639
+ return store.name.replace(/[\u0000-\u001f\u007f[\]]+/g, " ").trim() || store.myshopifyDomain;
640
+ }
641
+ function storeLabel(store, maxNameLength = 60) {
642
+ const safeName = displayName(store);
643
+ const name = safeName.length > maxNameLength ? `${safeName.slice(0, Math.max(0, maxNameLength - 1))}\u2026` : safeName;
644
+ return `${name} (${store.myshopifyDomain})`;
645
+ }
646
+ function storeDescriptionPrefix(store) {
647
+ return `[Store: ${storeLabel(store)}] `;
648
+ }
649
+ function stampResult(text, store) {
650
+ return `[Store: ${storeLabel(store)}]
651
+ ${text}`;
652
+ }
653
+ function normalizeStoreInput(raw) {
654
+ let normalized = raw.trim().toLowerCase();
655
+ normalized = normalized.replace(/^https?:\/\//, "");
656
+ normalized = normalized.replace(/^www\./, "");
657
+ const slashIdx = normalized.indexOf("/");
658
+ if (slashIdx !== -1) {
659
+ normalized = normalized.slice(0, slashIdx);
660
+ }
661
+ normalized = normalized.split(/[?#]/)[0];
662
+ normalized = normalized.replace(/[./]+$/, "");
663
+ return normalized;
664
+ }
665
+ function matchesStore(raw, store) {
666
+ if (!raw || !raw.trim()) {
667
+ return false;
273
668
  }
274
- };
669
+ const normalized = normalizeStoreInput(raw);
670
+ if (normalized === normalizeStoreInput(store.myshopifyDomain)) {
671
+ return true;
672
+ }
673
+ if (store.primaryDomain && normalized === normalizeStoreInput(store.primaryDomain)) {
674
+ return true;
675
+ }
676
+ if (raw.trim().toLowerCase() === store.name.trim().toLowerCase()) {
677
+ return true;
678
+ }
679
+ return false;
680
+ }
681
+ function storeMismatchMessage(raw, store) {
682
+ const target = raw.length > 100 ? `${raw.slice(0, 99)}\u2026` : raw;
683
+ return `Rejected \u2014 this connector is bound to ${storeLabel(store)} but the call targeted "${target}". Nothing was changed. Use the connector for that store. Only retry here if the user explicitly confirms they mean ${displayName(store)} \u2014 ask them, do not assume.`;
684
+ }
685
+ var STORE_ARG_DESCRIPTION = `The store the USER asked to change \u2014 its name or domain, taken from the user's own words or an earlier turn, never copied from this tool's description. If it is not clear which store the user means, ask them before calling. The server rejects the call when this does not match the store this connector is bound to. If the user has not named a store and this is the only Joy connector enabled, the store this connector is bound to is the one meant.`;
275
686
 
276
687
  // ../mcp-core/build/toolkit.js
277
- async function runHandler(name, run) {
688
+ async function runHandler(name, run, store) {
278
689
  try {
279
690
  const text = await run();
280
- return { content: [{ type: "text", text }] };
691
+ markSuccess();
692
+ return { content: [{ type: "text", text: store ? stampResult(text, store) : text }] };
281
693
  } catch (error) {
282
694
  if (error instanceof JoyApiError) {
283
- return { isError: true, content: [{ type: "text", text: error.modelMessage }] };
695
+ markToolError(error);
696
+ const text2 = store ? stampResult(error.modelMessage, store) : error.modelMessage;
697
+ return { isError: true, content: [{ type: "text", text: text2 }] };
284
698
  }
699
+ markInternalError();
285
700
  const message = error instanceof Error ? error.message : String(error);
286
701
  log.error(`${name} failed unexpectedly: ${message}`);
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.`;
287
703
  return {
288
704
  isError: true,
289
- content: [
290
- {
291
- type: "text",
292
- 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.`
293
- }
294
- ]
705
+ content: [{ type: "text", text: store ? stampResult(text, store) : text }]
295
706
  };
296
707
  }
297
708
  }
298
- function registerReadTool(server, spec) {
299
- const callback = (async (args) => runHandler(spec.name, () => spec.handler(args ?? {})));
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
+ }
718
+ function registerReadTool(server, ctx, spec) {
719
+ const callback = withTelemetry(ctx, spec.name, (async (args) => runHandler(spec.name, () => spec.handler(args ?? {}), ctx.store)));
300
720
  server.registerTool(spec.name, {
301
721
  title: spec.title,
302
- description: spec.description,
722
+ description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
303
723
  inputSchema: spec.inputSchema,
304
724
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
305
725
  }, callback);
306
726
  }
727
+ function withStoreArg(shape, store) {
728
+ if (!store)
729
+ return shape;
730
+ return { ...shape, store: z.string().describe(STORE_ARG_DESCRIPTION) };
731
+ }
732
+ function checkStoreMismatch(name, args, store) {
733
+ const raw = typeof args?.store === "string" ? args.store : "";
734
+ if (matchesStore(raw, store))
735
+ return void 0;
736
+ log.info(`${name} rejected: store mismatch (bound=${store.myshopifyDomain})`);
737
+ return {
738
+ isError: true,
739
+ content: [{ type: "text", text: stampResult(storeMismatchMessage(raw, store), store) }]
740
+ };
741
+ }
742
+ function stripStoreArg(args) {
743
+ const { store: _store, ...rest } = args ?? {};
744
+ return rest;
745
+ }
307
746
  function registerConfigWriteTool(server, ctx, spec) {
308
- const callback = (async (args) => {
747
+ const strictStore = ctx.store && ctx.requireStoreArg ? ctx.store : void 0;
748
+ const callback = withTelemetry(ctx, spec.name, (async (args) => {
749
+ if (strictStore) {
750
+ const mismatch = checkStoreMismatch(spec.name, args, strictStore);
751
+ if (mismatch) {
752
+ markRejected("store_mismatch");
753
+ return mismatch;
754
+ }
755
+ }
309
756
  if (!ctx.config.configWritesEnabled) {
757
+ markRejected("config_writes_disabled");
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.`;
310
759
  return {
311
760
  isError: true,
312
- content: [
313
- {
314
- type: "text",
315
- 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.`
316
- }
317
- ]
761
+ content: [{ type: "text", text: ctx.store ? stampResult(text, ctx.store) : text }]
318
762
  };
319
763
  }
320
- return runHandler(spec.name, () => spec.handler(args ?? {}));
321
- });
764
+ const handlerArgs = strictStore ? stripStoreArg(args) : args ?? {};
765
+ return runHandler(spec.name, () => spec.handler(handlerArgs), ctx.store);
766
+ }));
322
767
  const registration = {
323
768
  title: spec.title,
324
- description: spec.description,
769
+ description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
325
770
  annotations: {
326
771
  readOnlyHint: false,
327
772
  destructiveHint: spec.destructiveHint,
@@ -330,31 +775,37 @@ function registerConfigWriteTool(server, ctx, spec) {
330
775
  }
331
776
  };
332
777
  if (spec.strictArguments) {
333
- const inputSchema = z.object(spec.inputSchema).strict();
778
+ const inputSchema = z.object(withStoreArg(spec.inputSchema, strictStore)).strict();
334
779
  server.registerTool(spec.name, { ...registration, inputSchema }, callback);
335
780
  return;
336
781
  }
337
- server.registerTool(spec.name, { ...registration, inputSchema: spec.inputSchema }, callback);
782
+ server.registerTool(spec.name, { ...registration, inputSchema: withStoreArg(spec.inputSchema, strictStore) }, callback);
338
783
  }
339
784
  function registerWriteTool(server, ctx, spec) {
340
- const callback = (async (args) => {
785
+ const strictStore = ctx.store && ctx.requireStoreArg ? ctx.store : void 0;
786
+ const callback = withTelemetry(ctx, spec.name, (async (args) => {
787
+ if (strictStore) {
788
+ const mismatch = checkStoreMismatch(spec.name, args, strictStore);
789
+ if (mismatch) {
790
+ markRejected("store_mismatch");
791
+ return mismatch;
792
+ }
793
+ }
341
794
  if (!ctx.config.writesEnabled) {
795
+ markRejected("writes_disabled");
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.`;
342
797
  return {
343
798
  isError: true,
344
- content: [
345
- {
346
- type: "text",
347
- 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.`
348
- }
349
- ]
799
+ content: [{ type: "text", text: ctx.store ? stampResult(text, ctx.store) : text }]
350
800
  };
351
801
  }
352
- return runHandler(spec.name, () => spec.handler(args ?? {}));
353
- });
802
+ const handlerArgs = strictStore ? stripStoreArg(args) : args ?? {};
803
+ return runHandler(spec.name, () => spec.handler(handlerArgs), ctx.store);
804
+ }));
354
805
  server.registerTool(spec.name, {
355
806
  title: spec.title,
356
- description: spec.description,
357
- inputSchema: spec.inputSchema,
807
+ description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
808
+ inputSchema: withStoreArg(spec.inputSchema, strictStore),
358
809
  annotations: {
359
810
  readOnlyHint: false,
360
811
  destructiveHint: spec.destructiveHint,
@@ -428,6 +879,9 @@ var MEMBER_DETAIL_KEYS = [
428
879
  "pointsRemain",
429
880
  "tierPoint",
430
881
  "pendingPoint",
882
+ // REST v2 detail fields, plus computed pointExpiration on the single-customer GETs.
883
+ "pointExpiration",
884
+ "pointExpirationUnavailable",
431
885
  "totalSpent",
432
886
  "totalEarnedPoints",
433
887
  "tierId",
@@ -470,6 +924,8 @@ var REWARD_KEYS = [
470
924
  "orderReqAmount",
471
925
  "expiredAt",
472
926
  "usedAt",
927
+ "isRevokedCode",
928
+ "revokedAt",
473
929
  "createdAt"
474
930
  ];
475
931
  var memberIdentifierSchema = {
@@ -514,19 +970,30 @@ function reclassifyCursorError(error, after) {
514
970
  throw error;
515
971
  }
516
972
  function registerMemberTools(server, ctx) {
517
- registerReadTool(server, {
973
+ registerReadTool(server, ctx, {
518
974
  name: "joy_find_member",
519
975
  title: "Find a loyalty member",
520
- 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.`,
521
977
  inputSchema: memberIdentifierSchema,
522
978
  handler: async (args) => {
523
- 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
+ }
524
989
  const shaped = compact(member, MEMBER_DETAIL_KEYS);
525
990
  const tier = member.tierName ? `, tier ${String(member.tierName)}` : "";
526
- 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);
527
994
  }
528
995
  });
529
- registerReadTool(server, {
996
+ registerReadTool(server, ctx, {
530
997
  name: "joy_get_member_store_credit",
531
998
  title: "Get a member's store credit balance",
532
999
  description: "Shopify STORE CREDIT balance for ONE member (by email or Shopify customer ID). Store credit is real MONEY on the customer's Shopify account (e.g. from referral rewards or refunds) \u2014 it is NOT loyalty points; for the points balance use joy_find_member. A customer holds at most one account per currency, so multiple accounts mean multiple currencies. No account = zero store credit.",
@@ -554,7 +1021,7 @@ function registerMemberTools(server, ctx) {
554
1021
  });
555
1022
  }
556
1023
  });
557
- registerReadTool(server, {
1024
+ registerReadTool(server, ctx, {
558
1025
  name: "joy_list_members",
559
1026
  title: "List loyalty members",
560
1027
  description: 'List loyalty members a page at a time, with sorting and an optional VIP-tier filter. Default order is most RECENT ACTIVITY (updatedAt desc). For ranking questions use sort_by \u2014 "which customer has the most points?" / "top 10 members" is ONE call with sort_by "points" (the first page IS the leaderboard; never page through the whole list for this). "How many customers/members does the shop have?" is ONE call with include_total_count true and limit 1 \u2014 never sum tier counts or page the list for this. sort_by "created_at" answers newest/oldest signups; tier_id narrows any of these to one tier. To inspect ONE known customer, prefer joy_find_member (cheaper and more precise). Returns up to `limit` members plus `has_more` and `next_after`; pass `next_after` back as `after` for the next page. Do not page through the whole list to answer aggregate questions \u2014 that burns the shop\'s API rate limit.',
@@ -572,9 +1039,9 @@ function registerMemberTools(server, ctx) {
572
1039
  const direction = args.direction ?? "desc";
573
1040
  const withTotal = args.include_total_count === true;
574
1041
  const sort = MEMBER_SORTS[sortBy];
575
- let envelope;
1042
+ let envelope2;
576
1043
  try {
577
- envelope = await ctx.api.request("/customers", {
1044
+ envelope2 = await ctx.api.request("/customers", {
578
1045
  query: {
579
1046
  limit,
580
1047
  after: args.after,
@@ -587,10 +1054,10 @@ function registerMemberTools(server, ctx) {
587
1054
  reclassifyCursorError(error, args.after);
588
1055
  }
589
1056
  const result = {
590
- data: envelope.data,
591
- hasNext: envelope.meta?.pagination?.hasNext === true
1057
+ data: envelope2.data,
1058
+ hasNext: envelope2.meta?.pagination?.hasNext === true
592
1059
  };
593
- const totalMembers = withTotal ? envelope.meta?.pagination?.total : void 0;
1060
+ const totalMembers = withTotal ? envelope2.meta?.pagination?.total : void 0;
594
1061
  const members = Array.isArray(result.data) ? result.data : [];
595
1062
  if (members.length === 0) {
596
1063
  if (args.after)
@@ -615,7 +1082,7 @@ function registerMemberTools(server, ctx) {
615
1082
  });
616
1083
  }
617
1084
  });
618
- registerReadTool(server, {
1085
+ registerReadTool(server, ctx, {
619
1086
  name: "joy_get_member_activity",
620
1087
  title: "Get a member's point activity",
621
1088
  description: 'Point transactions (earned, spent, adjusted, refund deductions\u2026) for ONE member, most recent first. Use to answer "why is this customer\'s balance X?" or to audit changes. Each entry shows the point change, balance before/after (oldPoint \u2192 newPoint), the source system, and any notes. Returns `next_after` when more history exists \u2014 pass it back as `after` to keep auditing.',
@@ -652,10 +1119,10 @@ function registerMemberTools(server, ctx) {
652
1119
  });
653
1120
  }
654
1121
  });
655
- registerReadTool(server, {
1122
+ registerReadTool(server, ctx, {
656
1123
  name: "joy_get_member_rewards",
657
1124
  title: "Get a member's reward/coupon history",
658
- 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?".`,
659
1126
  inputSchema: {
660
1127
  ...memberIdentifierSchema,
661
1128
  limit: z2.number().int().min(1).max(50).optional().describe("Rewards per page (default 10, max 50)."),
@@ -708,23 +1175,68 @@ var TIER_KEYS = [
708
1175
  "systemType"
709
1176
  ];
710
1177
  var TIERS_TTL_MS = 15 * 60 * 1e3;
1178
+ var TIER_PROGRAM_KEYS = [
1179
+ "status",
1180
+ "entryMethod",
1181
+ "startDate",
1182
+ "prefix",
1183
+ "isUsePrefixDiscountCode",
1184
+ "showTierPointSettings",
1185
+ "allowGiveEntryRewardAgain",
1186
+ "useDemotionTier",
1187
+ "typeDemotion",
1188
+ "useResetTier",
1189
+ "resetFrequency"
1190
+ ];
1191
+ var LEVEL_UP_RULE_LABELS = {
1192
+ pointEarned: "points earned",
1193
+ moneySpent: "amount spent",
1194
+ numberOfOrders: "number of orders"
1195
+ };
1196
+ function calculationSummary(settings, tiers) {
1197
+ const entryMethod = typeof settings.entryMethod === "string" && settings.entryMethod || tiers.find((tier) => typeof tier.entryMethod === "string")?.entryMethod;
1198
+ const label = typeof entryMethod === "string" ? LEVEL_UP_RULE_LABELS[entryMethod] : void 0;
1199
+ if (!label)
1200
+ return "";
1201
+ const start = typeof settings.startDate === "string" && settings.startDate ? `, since ${settings.startDate.slice(0, 10)}` : "";
1202
+ return ` Level-up rule: ${label}${start}.`;
1203
+ }
1204
+ async function readTierProgramSettings(ctx) {
1205
+ try {
1206
+ const { data } = await ctx.api.getData("/tiers/settings");
1207
+ return data && typeof data === "object" ? data : {};
1208
+ } catch (error) {
1209
+ if (error instanceof JoyApiError && error.kind === "not_found")
1210
+ return {};
1211
+ throw error;
1212
+ }
1213
+ }
711
1214
  function getTiersCached(ctx) {
712
1215
  return ctx.cache.get("tiers", TIERS_TTL_MS, async () => {
713
- const { data } = await ctx.api.getData("/tiers");
714
- return Array.isArray(data) ? data : [];
1216
+ const [tiersResult, settings] = await Promise.all([
1217
+ ctx.api.getData("/tiers"),
1218
+ readTierProgramSettings(ctx)
1219
+ ]);
1220
+ return {
1221
+ tiers: Array.isArray(tiersResult.data) ? tiersResult.data : [],
1222
+ settings
1223
+ };
715
1224
  });
716
1225
  }
717
1226
  function registerTierTools(server, ctx) {
718
- registerReadTool(server, {
1227
+ registerReadTool(server, ctx, {
719
1228
  name: "joy_list_tiers",
720
1229
  title: "List VIP tiers",
721
- description: 'List the shop\'s VIP tiers with thresholds (targetPoint/targetAmount), earning multipliers, and per-tier benefits (tierRewards). Use to understand the tier ladder before answering tier questions. For "how many customers are in tier X?" set include_member_counts \u2014 it returns memberCount (live count of current members) per tier; the plain `members` field is a stale cached snapshot, NEVER quote it as the member count. Config-only calls are cached ~15 minutes \u2014 one call per conversation is enough.',
1230
+ description: 'List the shop\'s VIP tiers plus the program "Tier calculation" settings (entryMethod, startDate, prefix). Thresholds (targetPoint) are in the unit of entryMethod \u2014 150 means 150 points, $150 spent, or 150 orders depending on that rule. Also includes earning multipliers and per-tier benefits (tierRewards). For "how many customers are in tier X?" set include_member_counts \u2014 it returns memberCount (live count of current members) per tier; the plain `members` field is a stale cached snapshot, NEVER quote it as the member count. Config-only calls are cached ~15 minutes \u2014 one call per conversation is enough. To change calculation / demotion / reset settings, use joy_configure_tier_settings.',
722
1231
  inputSchema: {
723
1232
  include_member_counts: z3.boolean().optional().describe("Set true ONLY when the merchant asks how many customers/members are in tiers \u2014 adds memberCount (live count) per tier. Leave unset for tier-config questions; counting costs extra database reads.")
724
1233
  },
725
1234
  handler: async (args) => {
726
1235
  if (args.include_member_counts === true) {
727
- const { data } = await ctx.api.getData("/tiers", { query: { hasCount: true } });
1236
+ const [{ data }, settings2] = await Promise.all([
1237
+ ctx.api.getData("/tiers", { query: { hasCount: true } }),
1238
+ readTierProgramSettings(ctx)
1239
+ ]);
728
1240
  const tiers2 = Array.isArray(data) ? data : [];
729
1241
  if (tiers2.length === 0) {
730
1242
  return "This shop has no VIP tiers configured. Tier-related questions do not apply.";
@@ -732,13 +1244,19 @@ function registerTierTools(server, ctx) {
732
1244
  const keys = [...TIER_KEYS.filter((key) => key !== "members"), "memberCount"];
733
1245
  const total = tiers2.reduce((sum, tier) => sum + (Number(tier.memberCount) || 0), 0);
734
1246
  const top = [...tiers2].sort((a, b) => (Number(b.memberCount) || 0) - (Number(a.memberCount) || 0))[0];
735
- return summaryWithJson(`${tiers2.length} VIP tier(s), ${total} member(s) total \u2014 largest: "${String(top.name)}" (${Number(top.memberCount) || 0} members).`, tiers2.map((tier) => compact(tier, keys)));
1247
+ return summaryWithJson(`${tiers2.length} VIP tier(s), ${total} member(s) total \u2014 largest: "${String(top.name)}" (${Number(top.memberCount) || 0} members).${calculationSummary(settings2, tiers2)}`, {
1248
+ program: compact(settings2, TIER_PROGRAM_KEYS),
1249
+ tiers: tiers2.map((tier) => compact(tier, keys))
1250
+ });
736
1251
  }
737
- const tiers = await getTiersCached(ctx);
1252
+ const { tiers, settings } = await getTiersCached(ctx);
738
1253
  if (tiers.length === 0) {
739
1254
  return "This shop has no VIP tiers configured. Tier-related questions do not apply.";
740
1255
  }
741
- return summaryWithJson(`${tiers.length} VIP tier(s) configured.`, tiers.map((tier) => compact(tier, TIER_KEYS)));
1256
+ return summaryWithJson(`${tiers.length} VIP tier(s) configured.${calculationSummary(settings, tiers)}`, {
1257
+ program: compact(settings, TIER_PROGRAM_KEYS),
1258
+ tiers: tiers.map((tier) => compact(tier, TIER_KEYS))
1259
+ });
742
1260
  }
743
1261
  });
744
1262
  }
@@ -788,6 +1306,9 @@ var EARNING_KEYS = [
788
1306
  "rateMoney",
789
1307
  "earnPoint",
790
1308
  "earnPointsTiers",
1309
+ "isAppliedVipTier",
1310
+ "appliedPlaceOrderTo",
1311
+ "skipEarnPointGuest",
791
1312
  "conditions",
792
1313
  "typeMilestone",
793
1314
  "milestones",
@@ -795,7 +1316,15 @@ var EARNING_KEYS = [
795
1316
  "endDate",
796
1317
  "priority",
797
1318
  "isDraft",
798
- "expired"
1319
+ "expired",
1320
+ // The artwork on this program's card, wherever the loyalty page draws it.
1321
+ // Read-only here, and included because their ABSENCE was unreadable: the page
1322
+ // design tool offers iconColor/iconBackground/iconRadius, which tells an agent
1323
+ // a glyph exists without telling it where — one cold-context run spent three
1324
+ // refused calls guessing `cardStyle.icon` before concluding, correctly, that
1325
+ // artwork is not part of the page design at all.
1326
+ "loyaltyPageIcon",
1327
+ "loyaltyPageCustomIcon"
799
1328
  ];
800
1329
  var SPENDING_KEYS = [
801
1330
  "id",
@@ -821,13 +1350,49 @@ var SPENDING_KEYS = [
821
1350
  "hasLimitShipping",
822
1351
  "limitShipping",
823
1352
  "enableMaxCapShipping",
824
- "maxCapShipping"
1353
+ "maxCapShipping",
1354
+ // See EARNING_KEYS — same reason, same pair.
1355
+ "loyaltyPageIcon",
1356
+ "loyaltyPageCustomIcon"
825
1357
  ];
1358
+ var CATALOGUE_ARRAYS = ["specificProducts", "variantIds", "freeProductIds", "appliedCollectionIds"];
1359
+ function compactReward(reward) {
1360
+ if (!reward || typeof reward !== "object" || Array.isArray(reward))
1361
+ return reward;
1362
+ const out = {};
1363
+ for (const [key, value] of Object.entries(reward)) {
1364
+ if (CATALOGUE_ARRAYS.includes(key)) {
1365
+ if (Array.isArray(value) && value.length)
1366
+ out[`${key}Count`] = value.length;
1367
+ continue;
1368
+ }
1369
+ out[key] = value;
1370
+ }
1371
+ return out;
1372
+ }
1373
+ function compactMilestones(milestones) {
1374
+ if (!Array.isArray(milestones))
1375
+ return milestones;
1376
+ return milestones.map((milestone) => {
1377
+ if (!milestone || typeof milestone !== "object" || Array.isArray(milestone))
1378
+ return milestone;
1379
+ const entry = milestone;
1380
+ if (!Array.isArray(entry.rewards))
1381
+ return entry;
1382
+ return { ...entry, rewards: entry.rewards.map(compactReward) };
1383
+ });
1384
+ }
1385
+ function compactEarningProgram(program) {
1386
+ const compacted = compact(program, EARNING_KEYS);
1387
+ if (compacted.milestones === void 0)
1388
+ return compacted;
1389
+ return { ...compacted, milestones: compactMilestones(compacted.milestones) };
1390
+ }
826
1391
  function registerProgramTools(server, ctx) {
827
- registerReadTool(server, {
1392
+ registerReadTool(server, ctx, {
828
1393
  name: "joy_list_earning_rules",
829
1394
  title: "List point-earning rules",
830
- description: 'List the ways customers EARN points (place order, sign up, birthday, social follows\u2026). Use to answer "how do customers get points?". IMPORTANT: when a rule has earnPointsTiers, the per-VIP-tier multipliers there override the flat earnPoint \u2014 quote the tiered values, not just the base rate.',
1395
+ description: 'List the ways customers EARN points (place order, sign up, birthday, social follows\u2026). Use to answer "how do customers get points?". For place_order, appliedPlaceOrderTo is the admin "Who to reward" radio (all = All loyalty members, vip-tier = VIP Tier only, member_and_guest = All customers) \u2014 quote that, not just earnPointsTiers. When a rule has earnPointsTiers AND appliedPlaceOrderTo is vip-tier, the per-VIP-tier rates override the flat earnPoint.',
831
1396
  inputSchema: {},
832
1397
  handler: async () => {
833
1398
  const { data } = await ctx.api.getData("/programs/earning");
@@ -835,10 +1400,10 @@ function registerProgramTools(server, ctx) {
835
1400
  if (programs.length === 0) {
836
1401
  return "No earning rules configured \u2014 customers currently have no way to earn points.";
837
1402
  }
838
- return summaryWithJson(`${programs.length} earning rule(s) configured.`, programs.map((program) => compact(program, EARNING_KEYS)));
1403
+ return summaryWithJson(`${programs.length} earning rule(s) configured.`, programs.map(compactEarningProgram));
839
1404
  }
840
1405
  });
841
- registerReadTool(server, {
1406
+ registerReadTool(server, ctx, {
842
1407
  name: "joy_list_redemption_rules",
843
1408
  title: "List point-redemption rules",
844
1409
  description: 'List the ways customers SPEND points (discount amount, percentage off, free shipping, free gift\u2026), including point costs (spendPoint) and min/max limits. Use to answer "what can customers redeem?" and to find the programId for a redemption. For USAGE questions \u2014 "which reward is redeemed most?", "how many times was program X redeemed?" \u2014 set include_redeem_counts to get redeemCount (all-time REDEMPTIONS, not unique customers) per rule in the same call. Redeeming itself is a separate write operation, not available through this tool.',
@@ -1113,7 +1678,7 @@ function formatReferralReward(amount, type) {
1113
1678
  return `${String(amount)} (${String(type ?? "unknown type")})`;
1114
1679
  }
1115
1680
  function registerReferralTools(server, ctx) {
1116
- registerReadTool(server, {
1681
+ registerReadTool(server, ctx, {
1117
1682
  name: "joy_get_referral_program",
1118
1683
  title: "Get referral program status & configuration",
1119
1684
  description: 'Whether the referral program is ENABLED plus its reward configuration \u2014 use this FIRST for "is the referral program on or off?", "what do referrers/friends get?". `status` is the on/off toggle: true = customers can refer friends, false = the program is turned off. Field naming: customerReward* = what the REFERRER (the advocate) gets; friendReward* = what the referred FRIEND gets; reward type may be "percentage", "amount" (fixed discount), "point", or store-credit. When isAppliedVipTier is true, earnPointsTiers overrides the flat referrer reward per VIP tier. For usage numbers use joy_get_referral_performance; for one member\'s results use joy_get_member_referrals.',
@@ -1135,7 +1700,7 @@ function registerReferralTools(server, ctx) {
1135
1700
  return summaryWithJson(`Referral program is ${enabled ? "ENABLED (ON)" : "DISABLED (OFF)"} \u2014 referrer gets ${referrerReward}, referred friend gets ${friendReward}.`, compact(program, REFERRAL_PROGRAM_KEYS));
1136
1701
  }
1137
1702
  });
1138
- registerReadTool(server, {
1703
+ registerReadTool(server, ctx, {
1139
1704
  name: "joy_get_referral_performance",
1140
1705
  title: "Get referral program performance",
1141
1706
  description: "Shop-level referral analytics. NOTE: the underlying API currently reports one metric only \u2014 how many referral coupon codes were used (i.e. completed referrals). It does NOT report clicks, signups, or revenue, and it does NOT tell you whether the program is enabled \u2014 use joy_get_referral_program for status/config. Optionally filter by date range.",
@@ -1152,7 +1717,7 @@ function registerReferralTools(server, ctx) {
1152
1717
  return summaryWithJson(`${count} referral coupon(s) were used${range} (completed referrals).`, compact(data ?? {}));
1153
1718
  }
1154
1719
  });
1155
- registerReadTool(server, {
1720
+ registerReadTool(server, ctx, {
1156
1721
  name: "joy_get_member_referrals",
1157
1722
  title: "Get one member's referral results",
1158
1723
  description: "Referral results for ONE member (identified by email or Shopify customer ID). Returns (1) aggregate totals \u2014 coupons redeemed via their referrals and points they earned from referring \u2014 and (2) their recent referral activity records. To generate/fetch their personal invite LINK, use joy_create_referral_invite instead.",
@@ -1211,7 +1776,7 @@ function registerReferralWriteTools(server, ctx) {
1211
1776
  // ../mcp-core/build/tools/overview.js
1212
1777
  var OVERVIEW_TTL_MS = 15 * 60 * 1e3;
1213
1778
  function registerOverviewTools(server, ctx) {
1214
- registerReadTool(server, {
1779
+ registerReadTool(server, ctx, {
1215
1780
  name: "joy_get_loyalty_overview",
1216
1781
  title: "Get loyalty program overview",
1217
1782
  description: 'Orient yourself: shop identity (name, domain, plan, currency, timezone) plus the loyalty program settings. Call this FIRST in a conversation before other Joy tools \u2014 it tells you which shop you are operating on and how its program is configured. Cached ~15 minutes; repeat calls within a conversation are free. This is configuration only \u2014 for performance numbers (revenue impact, points issued, member growth, program health) use the joy_get_* analytics report tools instead, and for "how many members does the shop have?" use joy_list_members with include_total_count.',
@@ -1341,13 +1906,13 @@ var reportDateRangeSchema = {
1341
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.";
1342
1907
  function withScopeGuidance(error) {
1343
1908
  if (error instanceof JoyApiError && error.status === 403 && /read_reports/i.test(error.message)) {
1344
- 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);
1345
1910
  }
1346
1911
  return error;
1347
1912
  }
1348
1913
  function withV2EnablementGuidance(error) {
1349
1914
  if (error instanceof JoyApiError && error.status === 409 && /Analytics V2 is not enabled/i.test(error.message)) {
1350
- 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);
1351
1916
  }
1352
1917
  return error;
1353
1918
  }
@@ -1379,7 +1944,7 @@ async function getScopedReport(ctx, path, options = {}) {
1379
1944
  }
1380
1945
  }
1381
1946
  function registerAnalyticsTools(server, ctx) {
1382
- registerReadTool(server, {
1947
+ registerReadTool(server, ctx, {
1383
1948
  name: "joy_get_analytics_report",
1384
1949
  title: "Loyalty analytics report (Analytics V2)",
1385
1950
  description: 'THE primary tool for loyalty analytics questions \u2014 members, VIP tiers, points economy, rewards ROI, coupons, referrals, and loyalty revenue impact. Retrieves any Analytics V2 report using the SAME data and cache as the Analytics dashboard the merchant sees in the Joy admin, so your numbers always match theirs. Joy-internal data \u2014 no Shopify permission needed. Defaults to the last 30 days (max 366-day range, cached server-side ~1h). Returns a pre-digested text summary (time series are collapsed to count/total/latest; tables capped at 8 rows). A 409 error means the shop is still on Analytics V1 (no V2 data exists \u2014 follow the error guidance about migrating, never report zeros); a summary starting with "WARNING: \u2026 backfill" means the historical backfill is unfinished and numbers may be incomplete \u2014 relay that caveat. For Shopify-sales-attributed revenue (order-tag attribution) use joy_get_loyalty_revenue_report instead \u2014 a DIFFERENT data source whose numbers may legitimately differ from assisted_revenue.\n' + V2_REPORT_MENU,
@@ -1407,7 +1972,7 @@ function registerAnalyticsTools(server, ctx) {
1407
1972
  ${summary}`);
1408
1973
  }
1409
1974
  });
1410
- registerReadTool(server, {
1975
+ registerReadTool(server, ctx, {
1411
1976
  name: "joy_get_loyalty_revenue_report",
1412
1977
  title: "Loyalty revenue impact (Shopify sales data)",
1413
1978
  description: `Loyalty revenue impact measured from SHOPIFY'S OWN sales dataset (ShopifyQL), attributed by the shop's "Joy assisted" order tag. Use for: loyalty ROI, "how much revenue does the loyalty program drive?", "is the program making money?", loyalty AOV, year-over-year (YoY) comparison. Metrics: total_sales = revenue from orders tagged "Joy assisted"; aov = average order value of those orders; returning_rate = returning-customer rate among customers tagged as active redeemers; orders = count of Joy-assisted orders. compare_to_last_year applies to total_sales ONLY and is IGNORED when a custom range is given. Custom range = BOTH start_date and end_date (ISO YYYY-MM-DD), max 366 days, not in the future. ` + SCOPE_HELP + " joy_get_analytics_report (reportType assisted_revenue) measures assisted revenue from Joy's own event log \u2014 a DIFFERENT data source with different attribution; the two numbers may legitimately disagree.",
@@ -1427,7 +1992,7 @@ ${summary}`);
1427
1992
  return summaryWithJson(summary, compact(data, LOYALTY_REVENUE_KEYS));
1428
1993
  }
1429
1994
  });
1430
- registerReadTool(server, {
1995
+ registerReadTool(server, ctx, {
1431
1996
  name: "joy_get_tier_performance_breakdown",
1432
1997
  title: "Sales performance by VIP tier",
1433
1998
  description: 'Shopify sales performance broken down by VIP tier (ShopifyQL, grouped by the Joy tier metafield). Use for: "which tier spends the most?", "revenue by tier", "AOV per tier", "orders per customer by tier". Default window is ALL-TIME; pass BOTH start_date and end_date (YYYY-MM-DD, max 366 days) to bound it. Rows are tier names sorted by value; "(unassigned)" = customers without a tier. A 422 METAFIELD_NOT_CONFIGURED error means the Joy tier metafield definition is DISABLED on this shop \u2014 that is a setup gate, NOT empty data; ask the merchant to enable it in Joy \u2192 Settings \u2192 General \u2192 Metafield section. For loyalty-side tier reports (member counts, upgrades/downgrades) use joy_get_analytics_report with tier_performance / tier_trend / tier_transitions. ' + SCOPE_HELP,
@@ -1445,7 +2010,7 @@ ${summary}`);
1445
2010
  });
1446
2011
  } catch (error) {
1447
2012
  if (error instanceof JoyApiError && error.status === 422) {
1448
- 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);
1449
2014
  }
1450
2015
  throw error;
1451
2016
  }
@@ -1454,7 +2019,7 @@ ${summary}`);
1454
2019
  return summaryWithJson(summary, compact(data, TIER_PERFORMANCE_KEYS));
1455
2020
  }
1456
2021
  });
1457
- registerReadTool(server, {
2022
+ registerReadTool(server, ctx, {
1458
2023
  name: "joy_get_loyalty_health_check",
1459
2024
  title: "Loyalty program health check",
1460
2025
  description: `Loyalty program health diagnostic \u2014 use FIRST for open-ended questions: "how is my loyalty program doing?", "is anything wrong?", "why aren't customers earning points?". Compares the current window (default last 30 days) against the equal-length prior window using Joy-internal data only \u2014 aggregate numbers, no customer PII, and NO Shopify permission needed. healthScore = 100 minus a penalty per flag (low 5, medium 15, high 25; floor 0): >=80 Healthy, 60-79 Watch, <60 Needs attention. Flags check: sandbox/test earning mode (real orders are NOT earning points \u2014 the most urgent flag), redemption rate, points-earned trend, redeem-revenue trend, data availability, and tier concentration. Pass start_date+end_date (both, YYYY-MM-DD) to move the current window. For the underlying detail behind a flag, follow up with joy_get_analytics_report.`,
@@ -1505,19 +2070,19 @@ function resolveIdempotencyKey(provided) {
1505
2070
  }
1506
2071
  function rethrowWithRetryKey(error, idempotencyKey) {
1507
2072
  if (error instanceof JoyApiError && (error.kind === "timeout" || error.kind === "network" || error.kind === "server")) {
1508
- 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);
1509
2074
  }
1510
2075
  throw error;
1511
2076
  }
1512
2077
  function rethrowRedeemUncertain(error) {
1513
2078
  if (error instanceof JoyApiError && (error.kind === "timeout" || error.kind === "network" || error.kind === "server")) {
1514
- 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);
1515
2080
  }
1516
2081
  throw error;
1517
2082
  }
1518
2083
  function rethrowRefundUncertain(error) {
1519
2084
  if (error instanceof JoyApiError && (error.kind === "timeout" || error.kind === "network" || error.kind === "server")) {
1520
- 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);
1521
2086
  }
1522
2087
  throw error;
1523
2088
  }
@@ -1695,7 +2260,7 @@ function registerPointWriteTools(server, ctx) {
1695
2260
  inputSchema: {
1696
2261
  ...memberIdentifierSchema,
1697
2262
  program_id: z7.string().describe("Redemption program ID (from joy_list_redemption_rules)."),
1698
- redeem_points: z7.number().int().positive().describe("Points to spend on this redemption.")
2263
+ redeem_points: z7.number().int().positive().describe("Points to spend for dynamic rewards only. Fixed rewards use the program's server-side cost.")
1699
2264
  },
1700
2265
  destructiveHint: true,
1701
2266
  idempotentHint: false,
@@ -1717,11 +2282,14 @@ function registerPointWriteTools(server, ctx) {
1717
2282
  }
1718
2283
  const discount = result?.discount ?? {};
1719
2284
  const customer = result?.customer ?? {};
2285
+ const chargedPoints = typeof result.redeemPoint === "number" && Number.isFinite(result.redeemPoint) ? result.redeemPoint : typeof member.point === "number" && typeof customer.point === "number" && Number.isFinite(member.point) && Number.isFinite(customer.point) && member.point >= customer.point ? member.point - customer.point : void 0;
1720
2286
  if (!discount.code) {
1721
2287
  throw new JoyApiError("server", "The redeem response did not include a coupon code \u2014 treat this redemption as NOT completed. Common causes: insufficient points, program unavailable, or gift out of stock. Check the member balance and program via joy_find_member / joy_list_redemption_rules, and verify no redemption was recorded via joy_get_member_activity before trying again.");
1722
2288
  }
1723
- return summaryWithJson(`Redeemed ${formatPoints(args.redeem_points)} points from ${memberDisplayName(member)} for coupon "${String(discount.code)}" \u2014 balance now ${formatPoints(customer.point)}.`, {
2289
+ const redemptionSummary = Number.isFinite(chargedPoints) ? `Redeemed ${formatPoints(chargedPoints)} points from ${memberDisplayName(member)}` : `Redeemed a server-priced reward for ${memberDisplayName(member)}`;
2290
+ return summaryWithJson(`${redemptionSummary} for coupon "${String(discount.code)}" \u2014 balance now ${formatPoints(customer.point)}.`, {
1724
2291
  coupon: compact(discount, ["code", "title", "expiredAt"]),
2292
+ charged_points: chargedPoints,
1725
2293
  member_balance: customer.point
1726
2294
  });
1727
2295
  }
@@ -1798,7 +2366,7 @@ function toRetentionRiskQuery(args) {
1798
2366
  return query;
1799
2367
  }
1800
2368
  function registerRetentionTools(server, ctx) {
1801
- registerReadTool(server, {
2369
+ registerReadTool(server, ctx, {
1802
2370
  name: "joy_get_retention_risk_members",
1803
2371
  title: "Find members at risk of losing points or dropping a VIP tier",
1804
2372
  description: 'READ-ONLY retention/outreach tool \u2014 "which members are about to lose points or drop a VIP tier, so I can proactively reach out?" Two independent signals via `signal`: "points_expiring" = members with points expiring within `window_days` (ONLY covers shops on earn-time/FIFO point expiration \u2014 shops using all-at-once expiration are not covered by this signal, so an empty result here does not mean no one is losing points); "tier_drop_risk" = members the REAL tier-demotion engine will demote soon \u2014 Joy demotes on INACTIVITY (no order since the tier program\'s configured demotion window), so a member is at risk when their last order is already past that cutoff (`daysUntilDemotion: 0` = demotable on the very next demotion run) or will pass it within `window_days` unless they order again; each row carries `demotion`: "down_one_tier" or "reset_to_lowest_tier" per the program\'s setting. A shop can also (or instead) run a SCHEDULED STORE-WIDE reset on a fixed calendar day \u2014 then the response carries `tier_scheduled_reset` (resetAt + daysUntilReset): EVERY member resets to the lowest tier on that date at once, so the correct answer is that DATE, not a per-member list. If the shop has tier demotion disabled, the response includes `tierDropRiskNote` saying the signal does not apply \u2014 report that note, never conclude "no one is at risk" from the empty list. Known limit: shops using assessment-based demotion (periodic spending re-evaluation) are not predicted by this signal. Optionally scope to one tier with `tier_id` (the lowest tier is never at risk \u2014 nothing to drop to). Default "both" returns both signals in one call. Results are HARD-CAPPED: window_days max 30, limit max 50 per signal (values above the cap are silently clamped, not rejected) \u2014 this is a shortlist for outreach, never the full at-risk population. Returns only the fields needed to act (name/email, expiring points/days, last-order date/days-until-demotion) plus has_more \u2014 never the member\'s full activity history.',
@@ -1876,7 +2444,7 @@ function shapeSupportingData(supportingData) {
1876
2444
  return shaped;
1877
2445
  }
1878
2446
  function registerRiskTools(server, ctx) {
1879
- registerReadTool(server, {
2447
+ registerReadTool(server, ctx, {
1880
2448
  name: "joy_investigate_member_risk",
1881
2449
  title: "Investigate a member for referral/redemption abuse risk",
1882
2450
  description: `READ-ONLY fraud/abuse investigation for ONE member \u2014 "is this member doing anything suspicious?" Checks a FIXED, hard-coded set of flags (the same rules run every time, not a judgment call): referral_email_similarity (this member referred someone whose email closely matches their own \u2014 e.g. john+1@/john+2@ patterns); referral_device_match (this member's CURRENT last-known IP/device value matches their referrer's CURRENT last-known value); referee_had_prior_order (this member already had an order before being referred, so was not actually a new customer); high_daily_redemptions (redeemed more than a fixed threshold of rewards today). The response includes supporting data ONLY for flags that actually fired (e.g. the masked similar-email referrals, or today's redemption list) \u2014 if nothing is flagged, no supporting data is returned; this is NOT a full activity/history dump, so do not ask follow-up questions expecting more raw data from this same call. CRITICAL DATA LIMITS \u2014 do not claim more than this: there is NO IP/device HISTORY anywhere in Joy, only a single last-known value per customer that gets overwritten on every storefront visit \u2014 so referral_device_match is a live snapshot comparison, NOT a check of what the IP/device was at the actual moment the referral was claimed (that comparison happens live during the claim and is never stored). Do NOT say things like "this customer used N different IPs" or "here is their IP address" \u2014 that data does not exist and IPs are never returned, only a boolean match. Redemption velocity is checked at DAY granularity only \u2014 there is no minute/hour-level velocity check, so do not claim to detect rapid-fire redemption bursts within a single day. This tool never locks accounts, cancels rewards, or takes any action \u2014 it only surfaces flags for the merchant to review and decide next steps.`,
@@ -1917,7 +2485,7 @@ function describeRule(rule) {
1917
2485
  return `${title}: did not match${reasons ? ` (${reasons})` : ""}`;
1918
2486
  }
1919
2487
  function registerOrderExplainTools(server, ctx) {
1920
- registerReadTool(server, {
2488
+ registerReadTool(server, ctx, {
1921
2489
  name: "joy_explain_order_points",
1922
2490
  title: "Explain why an order did or didn't earn points",
1923
2491
  description: "READ-ONLY explanation of the loyalty points earning rule engine for ONE order \u2014 \"why did/didn't this order earn points (or earn fewer than expected)?\" The response's `explainSource` field tells you which of two modes produced it: `recorded` means the answer comes from the decision-time trace the REAL earn pipeline stored on the order's activity when it was processed (historical ground truth \u2014 per-rule pass/fail with the exact condition fields that failed, recorded at `recordedAt`); `replay` means no stored trace exists (order processed before tracing shipped, or a legacy calculation path) and this is a RECONSTRUCTION run against the order's CURRENT Shopify snapshot, reusing the exact same gate and rule-matching functions the real earn pipeline uses \u2014 NOT a historical record. If the order was modified after points were originally calculated (e.g. a partial refund applied after the initial award), this tool reports against the order's current state, which may not match what was actually awarded at the time \u2014 do not present this as infallible ground truth for what happened historically; treat it as \"what the rules say NOW\". Evaluation order: (1) pre-rule-engine gates \u2014 no customer matched on the order, guest checkout not opted in, customer left the loyalty program, order not paid/fulfilled per the shop's setting, order fully refunded, or an update arriving after the order was already closed; if any gate blocks, `blockedByGate` explains which one and no rules were evaluated. (2) If gates pass, every active place-order earning rule is evaluated in priority order (`rules`), each with `matched` true/false/null and human-readable `reasons` \u2014 `matched: null` means the rule was NEVER evaluated because a higher-priority rule matched first with \"stop processing\" enabled (distinct from `false`, which means it WAS evaluated and did not match). (3) `maxTheoreticalPoints` is the total before the global per-customer/tier earning cap; `totalEarnedPoints` is after; `cappedBy` is set only when that cap actually reduced the total. Scope: covers the place_order (checkout) earning event only \u2014 birthday, sign-up, and milestone rewards are NOT covered by this tool.",
@@ -2004,7 +2572,7 @@ function toActivityAuditQuery(args) {
2004
2572
  return query;
2005
2573
  }
2006
2574
  function registerActivityAuditTools(server, ctx) {
2007
- registerReadTool(server, {
2575
+ registerReadTool(server, ctx, {
2008
2576
  name: "joy_list_activities",
2009
2577
  title: "List loyalty activities across members (audit by activity group)",
2010
2578
  description: 'READ-ONLY shop-wide activity audit \u2014 "WHICH members did X in this period", the cross-member counterpart of joy_get_member_activity (one member\'s history) and of joy_get_analytics_report report_type=tier_transitions (aggregate COUNTS only, no names). Use this when the merchant asks WHO, use analytics when they ask HOW MANY. One semantic `group` per call, each spanning every raw activity type that feature area produces: "tier_downgraded" covers ALL THREE demotion mechanisms (reassessment demote_tier, inactivity downgrade_tier, relaunch reset_tier \u2014 never query one of these alone or you will miss mechanisms); "tier_upgraded" = tier upgrades; "points_earned" = program point earns (rows include orderName when the earn came from an order); "points_redeemed" = redemptions web + POS (each row carries programId/programTitle \u2014 combine with `program_id` for "who redeemed program A?"); "points_refunded" / "points_expired" / "points_adjusted" = the corresponding point movements; "referral" = referral outcomes (referred members, referred orders, referral rewards + revocations \u2014 rows carry referralCustomerEmail linking referrer and referee); "members_joined" = joins/rejoins. Time range: from_date/to_date (defaults to the last 30 days, range hard-capped at 366 days \u2014 an over-wide range is clamped to the most recent 366 days, not rejected). Results are a HARD-CAPPED shortlist: limit max 50 (default 20), newest first, plus has_more \u2014 never the full population; treat has_more:true as "narrow the range or filter, do not page exhaustively".',
@@ -2061,7 +2629,7 @@ function toTierUpgradeQuery(args) {
2061
2629
  return query;
2062
2630
  }
2063
2631
  function registerTierUpgradeTools(server, ctx) {
2064
- registerReadTool(server, {
2632
+ registerReadTool(server, ctx, {
2065
2633
  name: "joy_get_tier_upgrade_candidates",
2066
2634
  title: "Find members close to reaching the next VIP tier",
2067
2635
  description: 'READ-ONLY growth/upsell outreach tool \u2014 "which members are ABOUT TO reach the next VIP tier, so I can nudge them over the line?" (e.g. "kh\xE1ch n\xE0o s\u1EAFp l\xEAn Gold?"). The mirror of joy_get_retention_risk_members\' tier_drop_risk signal: that one finds members about to FALL, this one finds members about to CLIMB. Upgrades in Joy apply immediately when tierPoint reaches the next tier\'s threshold, so distance-to-threshold is the exact signal \u2014 each row reports the member\'s current tier, tierPoint, the next tier and its threshold, and `pointsToNextTier` (how little they still need). Sorted closest-first. `margin_points` defines "close" (default: 10% of the next tier\'s own threshold, so it scales with the shop); note tierPoint is measured in the tier program\'s entry metric \u2014 points, money spent, or order count depending on the program setup \u2014 so quote distances in that unit, not always "points". Optionally scope to one CURRENT tier with `tier_id` (from joy_list_tiers); the highest tier is never a candidate (nothing to climb to), exclusive/VIP-deal tiers are excluded on both ends. Results are a HARD-CAPPED shortlist (limit max 50, default 20, split evenly across tiers when unscoped) plus has_more \u2014 never the full population. For ONE member\'s own distance to the next tier, prefer joy_find_member (field pointsRemain) instead.',
@@ -2087,8 +2655,290 @@ function registerTierUpgradeTools(server, ctx) {
2087
2655
  });
2088
2656
  }
2089
2657
 
2090
- // ../mcp-core/build/tools/programsWrite.js
2658
+ // ../mcp-core/build/tools/loyaltyPage.js
2091
2659
  import { z as z13 } from "zod";
2660
+ function summarizeDesign(data) {
2661
+ const sections = data.design?.sections ?? [];
2662
+ const on = sections.filter((section) => section.enabled !== false).map((section) => section.id);
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.";
2665
+ if (data.pageVersionActive && (data.renderPath === "v2-block" || data.renderPath === void 0)) {
2666
+ live = "This page IS the shop\u2019s live loyalty page \u2014 changes are visible to customers.";
2667
+ }
2668
+ if (data.pageVersionActive && data.renderPath === "legacy-blocks") {
2669
+ live = "The new loyalty page is active for this shop.";
2670
+ }
2671
+ if (data.pageVersionActive && data.renderPath === "none") {
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.";
2673
+ }
2674
+ const css = data.canEditCustomCss ? "" : " Custom CSS is not available on this shop\u2019s plan.";
2675
+ return `${legacyWarning}New loyalty page design. Sections on: ${on.join(", ") || "none"}. ${live}${css}`;
2676
+ }
2677
+ function summarizeAppliedChanges(applied2, published, warnings2 = []) {
2678
+ const headline = applied2.slice(0, 5).map((change) => `${change.field}: ${String(change.from ?? "\u2014")} \u2192 ${String(change.to)}`).join("; ");
2679
+ const more = applied2.length > 5 ? ` (+${applied2.length - 5} more)` : "";
2680
+ const where = published ? "Published: live on the storefront now." : "Saved to the draft \u2014 visible in the merchant\u2019s editor now, and to customers once they publish.";
2681
+ const caveat = warnings2.length ? `\u26A0 ${warnings2.join(" \u26A0 ")} ` : "";
2682
+ return `${caveat}${applied2.length} design field${applied2.length === 1 ? "" : "s"} changed \u2014 ${headline}${more}. ${where} Undo with joy_revert_loyalty_page_design.`;
2683
+ }
2684
+ function registerLoyaltyPageTools(server, ctx) {
2685
+ registerReadTool(server, ctx, {
2686
+ name: "joy_get_loyalty_page_design",
2687
+ title: "Get loyalty page design",
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.",
2689
+ inputSchema: {},
2690
+ handler: async () => {
2691
+ const { data } = await ctx.api.getData("/loyalty-page/design");
2692
+ return summaryWithJson(summarizeDesign(data), data);
2693
+ }
2694
+ });
2695
+ }
2696
+ function registerLoyaltyPageWriteTools(server, ctx) {
2697
+ registerWriteTool(server, ctx, {
2698
+ name: "joy_update_loyalty_page_design",
2699
+ title: "Update loyalty page design",
2700
+ // Not idempotent in the annotation sense: repeating the call is harmless for
2701
+ // the page, but each call overwrites the single undo snapshot, so a second
2702
+ // identical call is not a no-op for what revert would restore.
2703
+ destructiveHint: false,
2704
+ idempotentHint: false,
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.",
2706
+ inputSchema: {
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"}}}'),
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.")
2709
+ },
2710
+ handler: async (args) => {
2711
+ const result = await ctx.api.mutateData("/loyalty-page/design", {
2712
+ method: "PATCH",
2713
+ timeoutMs: 2e4,
2714
+ body: { design: args.design, ...args.publish === void 0 ? {} : { publish: args.publish } }
2715
+ });
2716
+ const applied2 = result?.applied ?? [];
2717
+ if (!applied2.length) {
2718
+ return "No change \u2014 every field you sent already had that value.";
2719
+ }
2720
+ const published = result?.published ?? false;
2721
+ const warnings2 = result?.warnings ?? [];
2722
+ return summaryWithJson(summarizeAppliedChanges(applied2, published, warnings2), {
2723
+ applied: applied2,
2724
+ published,
2725
+ ...warnings2.length ? { warnings: warnings2 } : {}
2726
+ });
2727
+ }
2728
+ });
2729
+ registerWriteTool(server, ctx, {
2730
+ name: "joy_revert_loyalty_page_design",
2731
+ title: "Revert the last loyalty page design change",
2732
+ // It removes work the merchant may have wanted, and calling it twice
2733
+ // restores the same snapshot rather than stepping further back.
2734
+ destructiveHint: true,
2735
+ idempotentHint: true,
2736
+ description: "Undo the last design change made through joy_update_loyalty_page_design, restoring the page exactly as it was before that call \u2014 including any program or tier artwork it changed, which goes back LIVE immediately because artwork has no draft \u2014 and republish. ONE step only \u2014 it is not a history, and a second call restores the same snapshot again rather than going further back. WARNING: it restores a whole saved design, so any edit the merchant made in the admin editor AFTER that update is discarded too \u2014 if they may have been editing, ask before calling this. Use it promptly when a change is judged wrong; the next update overwrites the snapshot.",
2737
+ inputSchema: {
2738
+ publish: z13.boolean().optional().describe("Default false \u2014 the restored design lands on the draft. Pass true to put it back on the storefront immediately.")
2739
+ },
2740
+ handler: async (args) => {
2741
+ const result = await ctx.api.mutateData("/loyalty-page/design/revert", {
2742
+ method: "POST",
2743
+ timeoutMs: 2e4,
2744
+ body: args.publish === void 0 ? {} : { publish: args.publish }
2745
+ });
2746
+ const programIcons = result?.programIconsRestored ?? 0;
2747
+ const tierIcons = result?.tierIconsRestored ?? 0;
2748
+ const icons = programIcons || tierIcons ? ` ${[
2749
+ programIcons ? `${programIcons} program icon${programIcons === 1 ? "" : "s"}` : "",
2750
+ tierIcons ? `${tierIcons} tier icon${tierIcons === 1 ? "" : "s"}` : ""
2751
+ ].filter(Boolean).join(" and ")} were put back too, and artwork has no draft \u2014 those are LIVE now.` : "";
2752
+ return summaryWithJson(`Reverted to the design from before the last update. ${result?.published ? "Live on the storefront again." : "The page is restored on the draft \u2014 publish to put it back for customers."}${icons}`, { design: result?.design, programIconsRestored: programIcons, tierIconsRestored: tierIcons });
2753
+ }
2754
+ });
2755
+ }
2756
+
2757
+ // ../mcp-core/build/tools/loyaltyPageText.js
2758
+ import { z as z14 } from "zod";
2759
+ var localeSchema = z14.string().min(2).optional().describe("Active locale code. Omit to use the shop's primary language.");
2760
+ function registerLoyaltyPageTextTools(server, ctx) {
2761
+ registerReadTool(server, ctx, {
2762
+ name: "joy_get_loyalty_page_text",
2763
+ title: "Get loyalty page text",
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.",
2765
+ inputSchema: { locale: localeSchema },
2766
+ handler: async (args) => {
2767
+ const { data } = await ctx.api.getData("/loyalty-page/text", {
2768
+ query: { locale: args.locale }
2769
+ });
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);
2771
+ }
2772
+ });
2773
+ }
2774
+ function registerLoyaltyPageTextWriteTools(server, ctx) {
2775
+ registerWriteTool(server, ctx, {
2776
+ name: "joy_update_loyalty_page_text",
2777
+ title: "Update loyalty page text",
2778
+ destructiveHint: true,
2779
+ idempotentHint: false,
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.",
2781
+ inputSchema: {
2782
+ locale: localeSchema,
2783
+ changes: z14.array(z14.object({
2784
+ key: z14.string().min(1),
2785
+ value: z14.string(),
2786
+ manual: z14.boolean().optional().describe("Set true only when the merchant authored this exact replacement.")
2787
+ })).min(1).describe("Exact key/value edits from joy_get_loyalty_page_text.")
2788
+ },
2789
+ handler: async (args) => {
2790
+ const result = await ctx.api.mutateData("/loyalty-page/text", {
2791
+ method: "PATCH",
2792
+ timeoutMs: 12e4,
2793
+ body: { changes: args.changes, ...args.locale ? { locale: args.locale } : {} }
2794
+ });
2795
+ const propagation = result.translatedLocales.length ? ` Retranslated in ${result.translatedLocales.join(", ")}.` : "";
2796
+ const skipped = result.skipped.length ? ` ${result.skipped.length} change(s) skipped.` : "";
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);
2798
+ }
2799
+ });
2800
+ }
2801
+
2802
+ // ../mcp-core/build/tools/loyaltyHub.js
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";
2092
2942
  var PROGRAM_TYPE_BY_KIND = {
2093
2943
  earning: "earning",
2094
2944
  redemption: "spending"
@@ -2116,7 +2966,7 @@ var REVIEW_APPS = [
2116
2966
  "okendoApp",
2117
2967
  "ReviewsIoApp",
2118
2968
  "growaveApp",
2119
- "typdalApp"
2969
+ "segunoReviewApp"
2120
2970
  ];
2121
2971
  var REVIEW_FLOW_APPS = /* @__PURE__ */ new Set([
2122
2972
  "stampedApp",
@@ -2124,6 +2974,7 @@ var REVIEW_FLOW_APPS = /* @__PURE__ */ new Set([
2124
2974
  "okendoApp",
2125
2975
  "ReviewsIoApp",
2126
2976
  "growaveApp",
2977
+ "segunoReviewApp",
2127
2978
  "typdalApp"
2128
2979
  ]);
2129
2980
  var REVIEW_PHOTO_VIDEO_APPS = /* @__PURE__ */ new Set(["reviewRocketApp", "looxApp"]);
@@ -2167,7 +3018,8 @@ var EARNING_FIELDS = [
2167
3018
  "extraPointsForVideos",
2168
3019
  "isAppliedVipTier",
2169
3020
  "earnPointsTiers",
2170
- "appliedPlaceOrderTo"
3021
+ "appliedPlaceOrderTo",
3022
+ "skipEarnPointGuest"
2171
3023
  ];
2172
3024
  var REDEMPTION_FIELDS = [
2173
3025
  "event",
@@ -2190,8 +3042,8 @@ var REDEMPTION_FIELDS = [
2190
3042
  function withoutUnsetFields(body) {
2191
3043
  return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== void 0));
2192
3044
  }
2193
- function missingFields(event, fields) {
2194
- return { ok: false, error: `Creating ${event} requires: ${fields.join(", ")}.` };
3045
+ function missingFields(event, fields2) {
3046
+ return { ok: false, error: `Creating ${event} requires: ${fields2.join(", ")}.` };
2195
3047
  }
2196
3048
  function unsupportedCreate(event) {
2197
3049
  return {
@@ -2484,45 +3336,45 @@ function isProgramChangeNoop(current, prepared) {
2484
3336
  return diffFields(current, prepared.body).length === 0;
2485
3337
  }
2486
3338
  var programConfigInputSchema = {
2487
- program_id: z13.string().min(1).optional().describe("Existing Joy program ID. Omit to create a new program."),
2488
- event: z13.string().optional().describe("Program event. Required for create; optional for update."),
2489
- title: z13.string().min(1).optional().describe("Customer-facing program title."),
2490
- status: z13.boolean().optional().describe("Set false to disable this rule instead of deleting it."),
2491
- priority: z13.number().int().optional().describe("Optional program priority."),
2492
- start_date: z13.string().optional().describe("Optional ISO start date."),
2493
- end_date: z13.string().optional().describe("Optional ISO end date."),
2494
- earn_by: z13.enum(["price", "fixed_order", "quantity", "order"]).optional(),
2495
- rate_money: z13.number().optional(),
2496
- rate_item: z13.number().optional(),
2497
- earn_point: z13.number().optional(),
2498
- date_format: z13.enum(["MM/DD", "DD/MM", "MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"]).optional(),
2499
- type_reward_timing: z13.enum(["exact_birthday", "begin_of_month", "around_birthday"]).optional(),
2500
- limit_unit: z13.string().optional(),
2501
- limit_interval: z13.number().int().positive().optional(),
2502
- url_account: z13.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."),
2503
- review_app: z13.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."),
2504
- description_review: z13.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."),
2505
- extra_points_for_images: z13.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."),
2506
- extra_points_for_videos: z13.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."),
2507
- spend_point: z13.number().int().positive().optional(),
2508
- earn_amount: z13.number().optional(),
2509
- shipping_discount_type: z13.enum(["percentage", "fixedAmount"]).optional(),
2510
- shipping_discount_amount: z13.number().optional(),
2511
- applied_to: z13.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."),
2512
- specific_products: z13.array(z13.unknown()).optional().describe("free_gift with applied_to sf_product only \u2014 the merchant-provided product records/IDs. Do not invent product IDs."),
2513
- specific_collections: z13.array(z13.unknown()).optional(),
2514
- applied_collection_ids: z13.array(z13.string()).optional().describe("free_gift with applied_to specific or exclude_collection only \u2014 merchant-provided collection IDs."),
2515
- collection_quantity: z13.number().int().positive().optional().describe("Required for collection-based free_gift rules (applied_to specific or exclude_collection)."),
2516
- product_url: z13.array(z13.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."),
2517
- applied_place_order_to: z13.enum(["all", "vip-tier", "member_and_guest"]).optional().describe(`place_order only \u2014 the admin's "Who to reward" setting. Leave unset: enabling per-tier earning sets 'vip-tier' automatically, which legacy place_order programs REQUIRE (they predate isEventPlaceOrderV2 and ignore is_applied_vip_tier).`),
2518
- is_applied_vip_tier: z13.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."),
2519
- earn_points_tiers: z13.record(z13.object({
2520
- earn_point: z13.number().optional().describe("Points this tier earns, replacing the flat earn_point."),
2521
- rate_money: z13.number().optional().describe("place_order only: money amount that earns earn_point for this tier."),
2522
- type_earn: z13.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()
2523
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."),
2524
- reason: z13.string().min(3).describe("Why the merchant wants this configuration change."),
2525
- apply: z13.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.")
2526
3378
  };
2527
3379
  var PLACE_ORDER_EVENTS = /* @__PURE__ */ new Set(["place_order", "place_order_subscription"]);
2528
3380
  function placeOrderVipTierPatch(event, args) {
@@ -2531,9 +3383,7 @@ function placeOrderVipTierPatch(event, args) {
2531
3383
  return {};
2532
3384
  if (!PLACE_ORDER_EVENTS.has(String(event)))
2533
3385
  return {};
2534
- if (args.applied_place_order_to)
2535
- return {};
2536
- return { appliedPlaceOrderTo: "vip-tier" };
3386
+ return { appliedPlaceOrderTo: "vip-tier", skipEarnPointGuest: true };
2537
3387
  }
2538
3388
  function toIntegerRatio(earnPoint, rateMoney) {
2539
3389
  const isWhole = (value) => Math.abs(value - Math.round(value)) < 1e-9;
@@ -2626,11 +3476,14 @@ async function executeProgramChange(ctx, kind, args, toChange) {
2626
3476
  if (stop2)
2627
3477
  return stop2;
2628
3478
  await ctx.api.mutateData(`/programs/${prepared.programId}`, { method: "PUT", body: prepared.body });
2629
- if (resolvedArgs.earn_points_tiers) {
3479
+ if (resolvedArgs.earn_points_tiers || prepared.body.appliedPlaceOrderTo === "vip-tier") {
2630
3480
  const { data: after } = await ctx.api.getData(`/programs/${prepared.programId}`);
2631
3481
  if (earnPointsTiersDropped(resolvedArgs.earn_points_tiers, after?.earnPointsTiers)) {
2632
3482
  return `Every other field saved, but earnPointsTiers did NOT: the backend stored an empty map. REST v2 declares earnPointsTiers as a shapeless Yup.object() and validates with stripUnknown, which strips every tier key. Per-VIP-tier earning cannot currently be set through this API by any caller \u2014 configure it in Joy admin, and report this so the schema gets a real shape. Do not retry; the result will be the same.`;
2633
3483
  }
3484
+ if (prepared.body.appliedPlaceOrderTo === "vip-tier" && after?.appliedPlaceOrderTo !== "vip-tier") {
3485
+ return `Per-tier rates may have saved, but Who to reward did not: appliedPlaceOrderTo is still "${String(after?.appliedPlaceOrderTo ?? "unset")}" (All loyalty members), not vip-tier. The admin radio and the earning engine both read this field. Do not report VIP Tier only as applied; verify the place_order program in Joy admin.`;
3486
+ }
2634
3487
  }
2635
3488
  return `Updated ${kind} program ${prepared.programId}. This affects every customer immediately.`;
2636
3489
  }
@@ -2649,7 +3502,7 @@ function registerProgramConfigTools(server, ctx) {
2649
3502
  registerConfigWriteTool(server, ctx, {
2650
3503
  name: "joy_configure_earning_program",
2651
3504
  title: "Create or update an earning program",
2652
- description: "WRITE \u2014 changes earning behaviour for every customer immediately. Creates place_order, sign_up, birthday, interact_website, write_review, and the nine social-follow rules (Instagram, YouTube, TikTok, Facebook, X/Twitter, Pinterest, Discord, Telegram, Reddit); social rules need url_account and write_review needs review_app. Use program_id to update ANY existing earning rule, including types this tool cannot create. Previews by default. Joy has no safe delete here: set status: false to reversibly disable a rule instead.",
3505
+ description: "WRITE \u2014 changes earning behaviour for every customer immediately. Creates place_order, sign_up, birthday, interact_website, write_review, and the nine social-follow rules (Instagram, YouTube, TikTok, Facebook, X/Twitter, Pinterest, Discord, Telegram, Reddit); social rules need url_account and write_review needs review_app. For place_order, is_applied_vip_tier / earn_points_tiers also sets Who to reward (appliedPlaceOrderTo) to vip-tier \u2014 the admin radio is that field, not the per-tier map. Use program_id to update ANY existing earning rule, including types this tool cannot create. Previews by default. Joy has no safe delete here: set status: false to reversibly disable a rule instead.",
2653
3506
  inputSchema: programConfigInputSchema,
2654
3507
  destructiveHint: true,
2655
3508
  handler: (args) => executeProgramChange(ctx, "earning", args, toEarningProgramChange)
@@ -2665,8 +3518,10 @@ function registerProgramConfigTools(server, ctx) {
2665
3518
  }
2666
3519
 
2667
3520
  // ../mcp-core/build/tools/tiersWrite.js
2668
- import { z as z14 } from "zod";
3521
+ import { z as z18 } from "zod";
2669
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.";
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.";
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.";
2670
3525
  var RESET_SCHEDULE_FIELDS = [
2671
3526
  "useResetTier",
2672
3527
  "resetFrequency",
@@ -2728,6 +3583,12 @@ var TIER_REWARD_FIELDS = [
2728
3583
  ];
2729
3584
  var TIER_PERK_FIELDS = ["contentPerk"];
2730
3585
  var TIER_SETTINGS_FIELDS = [
3586
+ "entryMethod",
3587
+ "startDate",
3588
+ "prefix",
3589
+ "isUsePrefixDiscountCode",
3590
+ "showTierPointSettings",
3591
+ "allowGiveEntryRewardAgain",
2731
3592
  "useDemotionTier",
2732
3593
  "typeDemotion",
2733
3594
  "downgradeAfter",
@@ -2746,7 +3607,7 @@ var TIER_SETTINGS_FIELDS = [
2746
3607
  "notificationDowngradeBefore",
2747
3608
  "notificationDowngradeBeforeUnit"
2748
3609
  ];
2749
- var rewardEventSchema = z14.enum([
3610
+ var rewardEventSchema = z18.enum([
2750
3611
  "amount_discount",
2751
3612
  "percentage_discount",
2752
3613
  "free_shipping",
@@ -2754,95 +3615,101 @@ var rewardEventSchema = z14.enum([
2754
3615
  "bonus_point",
2755
3616
  "buy_x_get_y"
2756
3617
  ]);
2757
- var finiteNumber = () => z14.number().finite();
2758
- var tierRewardInputSchema = z14.object({
3618
+ var finiteNumber = () => z18.number().finite();
3619
+ var tierRewardInputSchema = z18.object({
2759
3620
  event: rewardEventSchema.describe("Required reward kind."),
2760
- title: z14.string().min(1).optional(),
2761
- type: z14.enum(["tier_spending", "tier_privileges", "pre_tier_reward"]).optional(),
2762
- status: z14.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(),
2763
3624
  earn_amount: finiteNumber().optional(),
2764
3625
  spend_point: finiteNumber().optional(),
2765
3626
  bonus_points: finiteNumber().optional(),
2766
- code_name: z14.string().optional(),
2767
- redeem_type: z14.string().optional(),
2768
- applied_to: z14.enum(["all", "specific_product", "specific"]).optional().describe("Tier-reward scope only: all, specific_product, or specific. These literals differ from program-level reward tools."),
2769
- order_req: z14.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(),
2770
3631
  order_req_amount: finiteNumber().optional(),
2771
3632
  min_spend_point: finiteNumber().optional(),
2772
3633
  max_spend_point: finiteNumber().optional(),
2773
- specific_products: z14.array(z14.unknown()).optional(),
2774
- specific_collections: z14.array(z14.unknown()).optional(),
2775
- applied_collection_ids: z14.array(z14.unknown()).optional(),
2776
- combined_with: z14.array(z14.enum(["orderDiscounts", "shippingDiscounts", "productDiscounts"])).optional(),
2777
- has_limit_shipping: z14.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(),
2778
3639
  limit_shipping: finiteNumber().optional(),
2779
3640
  expired_after: finiteNumber().optional(),
2780
- expired_unit: z14.enum(["day", "month"]).optional(),
2781
- is_refund_coupon: z14.boolean().optional(),
3641
+ expired_unit: z18.enum(["day", "month"]).optional(),
3642
+ is_refund_coupon: z18.boolean().optional(),
2782
3643
  priority: finiteNumber().optional(),
2783
- show_loyalty_page: z14.boolean().optional(),
2784
- perk_title_language: z14.record(z14.unknown()).optional(),
2785
- reward_product_selection_mode: z14.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(),
2786
3647
  reward_product_quantity: finiteNumber().optional()
2787
3648
  });
2788
3649
  var tierConfigInputSchema = {
2789
- tier_id: z14.string().min(1).optional().describe("Existing tier ID. Omit to create a tier."),
2790
- name: z14.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(),
2791
3652
  target_point: finiteNumber().nonnegative().optional().describe("Tier threshold. On an existing tier this is staged; it does not immediately re-evaluate members."),
2792
- icon: z14.string().optional(),
2793
- icon_custom: z14.string().optional(),
2794
- bg_image: z14.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."),
2795
- default_avatar: z14.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."),
2796
- image_block: z14.string().optional(),
2797
- is_exclusive_tier: z14.boolean().optional(),
2798
- hide_default_tier: z14.boolean().optional(),
2799
- text_color: z14.string().optional(),
2800
- progress_bar_color: z14.string().optional(),
2801
- box_bg_color: z14.string().optional(),
2802
- box_text_color: z14.string().optional(),
2803
- bg_card_color: z14.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(),
2804
3665
  card_opacity: finiteNumber().optional(),
2805
- icon_color: z14.string().optional(),
2806
- bg_type: z14.string().optional(),
2807
- tier_names_language: z14.record(z14.unknown()).optional(),
2808
- rewards: z14.array(tierRewardInputSchema).optional().describe("Optional rewards to create with a NEW tier in this one configuration write."),
2809
- reason: z14.string().min(3).describe("Why the merchant wants this tier configuration."),
2810
- apply: z14.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.")
2811
3672
  };
2812
3673
  var tierRewardToolInputSchema = {
2813
- tier_id: z14.string().min(1).describe("Tier ID from joy_list_tiers."),
2814
- reward_id: z14.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."),
2815
3676
  ...tierRewardInputSchema.shape,
2816
- product_url: z14.array(z14.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."),
2817
- reason: z14.string().min(3).describe("Why the merchant wants this reward configuration."),
2818
- apply: z14.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.")
2819
3680
  };
2820
3681
  var tierSettingsInputSchema = {
2821
- use_demotion_tier: z14.boolean().optional(),
2822
- type_demotion: z14.enum(["downgrade_tier", "reset_tier"]).optional(),
2823
- downgrade_after: z14.number().int().positive().optional(),
2824
- downgrade_after_unit: z14.enum(["day", "month", "year"]).optional(),
2825
- use_reset_tier: z14.boolean().optional(),
2826
- reset_after: z14.number().int().positive().optional(),
2827
- reset_after_unit: z14.enum(["day", "month", "year"]).optional(),
2828
- reset_frequency: z14.enum(["monthly", "quarterly", "half_yearly", "yearly", "custom"]).optional(),
2829
- reset_interval_value: z14.number().int().positive().optional(),
2830
- reset_interval_unit: z14.enum(["day", "month", "year"]).optional(),
2831
- reset_start_date: z14.string().min(1).optional(),
2832
- month_reset_tier: z14.number().int().min(1).max(12).optional(),
2833
- day_reset_tier: z14.number().int().min(1).max(31).optional(),
2834
- is_sync_tags_customer_tier: z14.boolean().optional(),
2835
- customer_tier_tag_prefix: z14.string().optional(),
2836
- notification_downgrade_before: z14.number().int().positive().optional(),
2837
- notification_downgrade_before_unit: z14.enum(["day", "month"]).optional(),
2838
- reason: z14.string().min(3).describe("Why the merchant wants these tier settings."),
2839
- apply: z14.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.")
2840
3707
  };
2841
3708
  var tierPerksInputSchema = {
2842
- tier_id: z14.string().min(1).describe("Tier ID from joy_list_tiers."),
2843
- perks: z14.array(z14.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."),
2844
- reason: z14.string().min(3).describe("Why the merchant wants to replace this tier perk list."),
2845
- apply: z14.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.")
2846
3713
  };
2847
3714
  function tierFreeGiftSelectionError(args, requireAppliedTo = false) {
2848
3715
  if (args.event !== "free_gift")
@@ -2941,7 +3808,7 @@ function buildTierConfigPreview(params) {
2941
3808
  }
2942
3809
  function rethrowTierCreateConflict(error) {
2943
3810
  if (error instanceof JoyApiError && error.status === 409) {
2944
- 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);
2945
3812
  }
2946
3813
  throw error;
2947
3814
  }
@@ -3138,6 +4005,12 @@ async function configureTierPerks(ctx, args) {
3138
4005
  }
3139
4006
  function toTierSettingsBody(args) {
3140
4007
  return omitUndefined({
4008
+ entryMethod: args.entry_method,
4009
+ startDate: args.start_date,
4010
+ prefix: args.prefix,
4011
+ isUsePrefixDiscountCode: args.use_prefix_discount_code,
4012
+ showTierPointSettings: args.show_tier_point_settings,
4013
+ allowGiveEntryRewardAgain: args.allow_give_entry_reward_again,
3141
4014
  useDemotionTier: args.use_demotion_tier,
3142
4015
  typeDemotion: args.type_demotion,
3143
4016
  downgradeAfter: args.downgrade_after === void 0 ? void 0 : String(args.downgrade_after),
@@ -3163,6 +4036,11 @@ function touchesResetSchedule(body) {
3163
4036
  function buildTierSettingsPreview(current, args, now = /* @__PURE__ */ new Date()) {
3164
4037
  const body = toTierSettingsBody(args);
3165
4038
  const changes = diffFields(current, body);
4039
+ const calculationWarnings = [
4040
+ ...changes.some((change) => change.field === "entryMethod") ? [ENTRY_METHOD_WARNING] : [],
4041
+ ...changes.some((change) => change.field === "startDate") ? [START_DATE_WARNING] : []
4042
+ ];
4043
+ const entryMethodWarning = calculationWarnings.length > 0 ? calculationWarnings : void 0;
3166
4044
  if (!touchesResetSchedule(body)) {
3167
4045
  return {
3168
4046
  kind: "preview",
@@ -3172,7 +4050,8 @@ function buildTierSettingsPreview(current, args, now = /* @__PURE__ */ new Date(
3172
4050
  target: "tier settings",
3173
4051
  reason: args.reason,
3174
4052
  changes,
3175
- dropped: droppedFields(body, TIER_SETTINGS_FIELDS)
4053
+ dropped: droppedFields(body, TIER_SETTINGS_FIELDS),
4054
+ warnings: entryMethodWarning
3176
4055
  })
3177
4056
  };
3178
4057
  }
@@ -3202,7 +4081,7 @@ function buildTierSettingsPreview(current, args, now = /* @__PURE__ */ new Date(
3202
4081
  reason: args.reason,
3203
4082
  changes,
3204
4083
  dropped: droppedFields(body, TIER_SETTINGS_FIELDS),
3205
- warnings: [scheduleWarning]
4084
+ warnings: entryMethodWarning ? [scheduleWarning, ...entryMethodWarning] : [scheduleWarning]
3206
4085
  })
3207
4086
  };
3208
4087
  }
@@ -3220,8 +4099,19 @@ async function configureTierSettings(ctx, args) {
3220
4099
  timeoutMs: 15e3,
3221
4100
  body: decision.body
3222
4101
  });
4102
+ const unconfirmed = unconfirmedSettingsFields(decision.body, updated ?? {});
4103
+ if (unconfirmed.length > 0) {
4104
+ throw new JoyApiError("server", `The tier-settings write was not confirmed for: ${unconfirmed.join(", ")}. Do not report those fields as changed; verify them in Joy admin.`);
4105
+ }
3223
4106
  const schedule = touchesResetSchedule(decision.body) ? ` Projected scheduleResetTierAt: ${formatSchedule(decision.projectedScheduleResetTierAt)}.` : "";
3224
- return summaryWithJson(`Updated tier settings.${schedule}`, compact(updated, [...TIER_SETTINGS_FIELDS, "id"]));
4107
+ const calculationChanged = decision.body.entryMethod !== void 0 && !sameSettingValue("entryMethod", current.entryMethod, decision.body.entryMethod) || decision.body.startDate !== void 0 && !sameSettingValue("startDate", current.startDate, decision.body.startDate);
4108
+ if (calculationChanged)
4109
+ ctx.cache?.delete("tiers");
4110
+ const calculationNote = [
4111
+ decision.body.entryMethod !== void 0 && !sameSettingValue("entryMethod", current.entryMethod, decision.body.entryMethod) ? ENTRY_METHOD_WARNING : "",
4112
+ decision.body.startDate !== void 0 && !sameSettingValue("startDate", current.startDate, decision.body.startDate) ? START_DATE_WARNING : ""
4113
+ ].filter(Boolean).map((warning) => ` ${warning}`).join("");
4114
+ return summaryWithJson(`Updated tier settings.${schedule}${calculationNote}`, compact(updated, [...TIER_SETTINGS_FIELDS, "id"]));
3225
4115
  } catch (error) {
3226
4116
  rethrowTierSettingsNotLaunched(error);
3227
4117
  }
@@ -3238,7 +4128,7 @@ async function readTierSettings(ctx) {
3238
4128
  }
3239
4129
  function rethrowTierSettingsNotLaunched(error) {
3240
4130
  if (error instanceof JoyApiError && error.kind === "not_found") {
3241
- 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);
3242
4132
  }
3243
4133
  throw error;
3244
4134
  }
@@ -3308,8 +4198,8 @@ function registerTierConfigTools(server, ctx) {
3308
4198
  });
3309
4199
  registerConfigWriteTool(server, ctx, {
3310
4200
  name: "joy_configure_tier_settings",
3311
- title: "Configure VIP tier demotion and reset settings",
3312
- description: "CONFIGURATION WRITE \u2014 changes tier demotion, reset, customer-tag, and downgrade-notification settings. Any reset-schedule change previews the current and projected scheduleResetTierAt before it may apply, because that daily-cron anchor affects every tier member.",
4201
+ title: "Configure VIP tier calculation, demotion and reset settings",
4202
+ description: 'CONFIGURATION WRITE \u2014 the admin "Tier calculation" card plus demotion/reset. Covers entry_method (pointEarned / moneySpent / numberOfOrders), start_date (program calculation window \u2014 not reset_start_date), prefix / use_prefix_discount_code, show_tier_point_settings, allow_give_entry_reward_again, demotion, reset, customer-tag, and downgrade-notification settings. Changing entry_method or start_date saves immediately and NEW activity uses the new rule/window; existing members keep their current tierPoint until the merchant relaunches in Joy admin \u2014 this tool cannot trigger that. Any reset-schedule change previews the current and projected scheduleResetTierAt before it may apply, because that daily-cron anchor affects every tier member.',
3313
4203
  inputSchema: tierSettingsInputSchema,
3314
4204
  destructiveHint: true,
3315
4205
  handler: (args) => configureTierSettings(ctx, args)
@@ -3324,6 +4214,15 @@ function isData(value) {
3324
4214
  function sameValue(a, b) {
3325
4215
  return a === b || String(a) === String(b);
3326
4216
  }
4217
+ function sameSettingValue(field, a, b) {
4218
+ if (field === "startDate" || field === "resetStartDate") {
4219
+ return String(a ?? "").slice(0, 10) === String(b ?? "").slice(0, 10);
4220
+ }
4221
+ return sameValue(a, b);
4222
+ }
4223
+ function unconfirmedSettingsFields(body, updated) {
4224
+ return Object.keys(body).filter((field) => !sameSettingValue(field, body[field], updated[field]));
4225
+ }
3327
4226
  function formatSchedule(value) {
3328
4227
  if (value === void 0)
3329
4228
  return "(not exposed by REST v2)";
@@ -3419,7 +4318,7 @@ function isAfterOrEqual(candidate, cursor, inclusive) {
3419
4318
  }
3420
4319
 
3421
4320
  // ../mcp-core/build/tools/shopConfig.js
3422
- import { z as z15 } from "zod";
4321
+ import { z as z19 } from "zod";
3423
4322
  var SHOP_SETTINGS_FIELDS = [
3424
4323
  "primaryLanguageInApp",
3425
4324
  "dateFormat",
@@ -3517,8 +4416,8 @@ function toShopSettingsBody(args) {
3517
4416
  function getDroppedShopSettingsFields(args) {
3518
4417
  return getUnknownArguments(args, SHOP_SETTINGS_VALID_ARGUMENTS);
3519
4418
  }
3520
- function selectedFields(body, fields) {
3521
- const selected = fields.filter((field) => body[field] !== void 0);
4419
+ function selectedFields(body, fields2) {
4420
+ const selected = fields2.filter((field) => body[field] !== void 0);
3522
4421
  return selected.length > 0 ? selected.join(", ") : "none selected";
3523
4422
  }
3524
4423
  function buildShopSettingsPreview(current, args) {
@@ -3562,20 +4461,20 @@ function registerShopConfigTools(server, ctx) {
3562
4461
  title: "Configure safe shop-level loyalty settings",
3563
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.",
3564
4463
  inputSchema: {
3565
- primary_language_in_app: z15.enum(["en", "en-US", "fr", "es", "de", "it", "ja", "id", "zh-CN"]).optional(),
3566
- date_format: z15.enum(["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD", "YYYY-MM-DD"]).optional(),
3567
- date_format_for_activity: z15.enum(["HH:mm", "hh:mm A"]).optional(),
3568
- point_position: z15.enum(["before", "after"]).optional(),
3569
- customer_apply_discount_behavior: z15.enum(["stay_current", "go_to_cart", "open_cart_drawer"]).optional(),
3570
- customer_redeem_coupon_behavior: z15.enum(["auto_apply", "save_for_later"]).optional(),
3571
- customer_free_gift_behavior: z15.enum(["manual_add_to_cart", "auto_add_to_cart"]).optional(),
3572
- status_for_review: z15.enum(["pending", "approveAuto"]).optional(),
3573
- earn_by_order_status: z15.enum(["financial_status", "fulfillment_status"]).optional(),
3574
- enable_global_earning_limit: z15.boolean().optional(),
3575
- global_earning_limit_timeframe: z15.enum(["yearly", "monthly", "rolling"]).optional(),
3576
- global_earning_limit_rolling_days: z15.number().int().min(1).max(365).optional(),
3577
- reason: z15.string().min(3).describe("Why the merchant wants these shop-wide settings changed."),
3578
- apply: z15.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.")
3579
4478
  },
3580
4479
  destructiveHint: false,
3581
4480
  strictArguments: true,
@@ -3597,10 +4496,10 @@ function registerShopConfigTools(server, ctx) {
3597
4496
  title: "Add a storefront translation language",
3598
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.",
3599
4498
  inputSchema: {
3600
- primary_language: z15.enum(TRANSLATION_LOCALES),
3601
- additional_language: z15.enum(TRANSLATION_LOCALES),
3602
- reason: z15.string().min(3).describe("Why the merchant wants this storefront language added."),
3603
- apply: z15.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.")
3604
4503
  },
3605
4504
  destructiveHint: false,
3606
4505
  strictArguments: true,
@@ -3619,7 +4518,7 @@ function registerShopConfigTools(server, ctx) {
3619
4518
  }
3620
4519
 
3621
4520
  // ../mcp-core/build/tools/brandingWrite.js
3622
- import { z as z16 } from "zod";
4521
+ import { z as z20 } from "zod";
3623
4522
  var WIDGET_DRAFT_SAVED_MESSAGE = "Saved to the V4 draft. The storefront is unchanged until you publish it in Joy admin.";
3624
4523
  var HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
3625
4524
  var COLOR_ROLE_PATHS = {
@@ -3662,26 +4561,26 @@ var BUTTON_STYLE_VALUES = ["store", "preset", "custom"];
3662
4561
  var CONTENT_STYLE_VALUES = ["icons_and_text", "text_only"];
3663
4562
  var DISPLAY_TYPE_VALUES = ["widget", "drawer", "floating-drawer"];
3664
4563
  var widgetDraftInputSchema = {
3665
- version: z16.enum(["v3", "v4"]).describe("Widget version to change. A live target is accepted only when this is the shop\u2019s currently live version."),
3666
- target: z16.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."),
3667
- apply_scan: z16.boolean().optional().describe("Apply a storefront scan through Joy\u2019s shared backend design merge. Omit scan_url to scan this shop\u2019s own storefront."),
3668
- scan_url: z16.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL to scan when apply_scan is true."),
3669
- storefront_password: z16.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."),
3670
- apply_images: z16.boolean().optional().describe("When apply_scan is true, also replace detected logo and card imagery. Defaults to false."),
3671
- color_overrides: z16.record(z16.string()).optional().describe(`Optional hex overrides for supported V4 color roles: ${COLOR_ROLES.join(", ")}.`),
3672
- design_overrides: z16.object({
3673
- corner_radius: z16.enum(CORNER_RADIUS_VALUES).optional().describe("Corner style. radiusNone is square/not rounded; radiusExtra is the most rounded."),
3674
- button_style: z16.enum(BUTTON_STYLE_VALUES).optional().describe("store matches the storefront, custom uses the widget\u2019s own button colours."),
3675
- content_style: z16.enum(CONTENT_STYLE_VALUES).optional().describe("text_only removes the icons beside earn/redeem rows; icons_and_text shows them."),
3676
- display_type: z16.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.")
3677
4576
  }).optional().describe("Optional non-colour design settings. Values are enums, not numbers or CSS lengths."),
3678
- font_overrides: z16.object({ heading: z16.string().min(1).optional(), body: z16.string().min(1).optional() }).optional().describe("Optional heading and body font overrides."),
3679
- reason: z16.string().min(3).describe("Why the merchant wants this widget draft."),
3680
- apply: z16.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.")
3681
4580
  };
3682
4581
  var brandScanInputSchema = {
3683
- url: z16.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL. Omit it to scan this shop\u2019s own storefront."),
3684
- storefront_password: z16.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.")
3685
4584
  };
3686
4585
  function isHexColor(value) {
3687
4586
  return typeof value === "string" && HEX_COLOR.test(value);
@@ -3706,19 +4605,19 @@ function brandingErrorGuidance(error, operation, target = "draft") {
3706
4605
  const passwordIncorrect = errorCode === "PASSWORD_INCORRECT" || /PASSWORD_INCORRECT|storefront password (?:did not unlock|was not accepted)|incorrect storefront password/i.test(error.modelMessage);
3707
4606
  const passwordRequired = errorCode === "PASSWORD_REQUIRED" || error.status === 409 || /PASSWORD_REQUIRED|storefront is password-protected|storefront password is required/i.test(error.modelMessage);
3708
4607
  if (operation === "scan" && passwordIncorrect) {
3709
- 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);
3710
4609
  }
3711
4610
  if (operation === "scan" && passwordRequired) {
3712
- 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);
3713
4612
  }
3714
4613
  if (operation === "scan" && error.status === 503) {
3715
- 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);
3716
4615
  }
3717
4616
  if (operation === "draft" && target === "live" && error.status === 409) {
3718
- 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);
3719
4618
  }
3720
4619
  if (operation === "draft" && error.status === 409) {
3721
- 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);
3722
4621
  }
3723
4622
  return void 0;
3724
4623
  }
@@ -3781,7 +4680,7 @@ function errorWithoutCredential(error, credential) {
3781
4680
  if (!credential)
3782
4681
  return error;
3783
4682
  if (error instanceof JoyApiError) {
3784
- 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);
3785
4684
  }
3786
4685
  if (error instanceof Error)
3787
4686
  return new Error(redactCredential(error.message, credential));
@@ -3934,7 +4833,7 @@ async function putWidgetScan(ctx, args) {
3934
4833
  }
3935
4834
  }
3936
4835
  function registerBrandingReadTools(server, ctx) {
3937
- registerReadTool(server, {
4836
+ registerReadTool(server, ctx, {
3938
4837
  name: "joy_scan_brand_design",
3939
4838
  title: "Scan a storefront\u2019s design",
3940
4839
  description: "READ \u2014 scans an optional public HTTPS storefront URL, or this shop\u2019s storefront when omitted, and returns detected colors, heading/body fonts, button style, and whether the result was cached. For a gated storefront, ask the merchant for its password and pass storefront_password; never guess it. The result is a design input, not visual proof of how the widget will look.",
@@ -4000,13 +4899,13 @@ No write was sent because this would be a no-op.`, args.storefront_password);
4000
4899
  throw new JoyApiError("server", `${failureGuidance} These fields were unchanged after the required read-back: ${missing.join(", ")}.`);
4001
4900
  }
4002
4901
  }
4003
- const confirmed = [
4902
+ const confirmed2 = [
4004
4903
  ...scanApplied ? ["the backend storefront-design merge"] : [],
4005
4904
  ...explicit.changes.length > 0 ? [
4006
4905
  `${explicit.changes.length} explicit field${explicit.changes.length === 1 ? "" : "s"}`
4007
4906
  ] : []
4008
4907
  ].join(" and ");
4009
- const savedSummary = target === "live" ? `Updated the live ${args.version.toUpperCase()} widget configuration and confirmed ${confirmed} by reading it back. The storefront changed as soon as it saved; there is no publish step and no undo.` : `Updated ${args.version.toUpperCase()} widget draft and confirmed ${confirmed} by reading it back. The agent cannot see the rendered result; review it in Joy admin.`;
4908
+ const savedSummary = target === "live" ? `Updated the live ${args.version.toUpperCase()} widget configuration and confirmed ${confirmed2} by reading it back. The storefront changed as soon as it saved; there is no publish step and no undo.` : `Updated ${args.version.toUpperCase()} widget draft and confirmed ${confirmed2} by reading it back. The agent cannot see the rendered result; review it in Joy admin.`;
4010
4909
  const result = `${summaryWithJson(savedSummary, {
4011
4910
  ...scanApplied ? { appliedScan: { colors: true, images: args.apply_images === true } } : {},
4012
4911
  ...explicit.changes.length > 0 ? { applied: explicit.changes.map((change) => ({ path: change.path, value: change.to })) } : {}
@@ -4022,22 +4921,40 @@ ${WIDGET_DRAFT_SAVED_MESSAGE}` : ""}`;
4022
4921
 
4023
4922
  // ../mcp-core/build/tools/widgetFields.js
4024
4923
  import { isDeepStrictEqual } from "node:util";
4025
- import { z as z17 } from "zod";
4924
+ import { z as z21 } from "zod";
4026
4925
  var fieldsPath = "/shop/branding/widget/fields";
4027
- var versionSchema = z17.literal("v4").default("v4").describe("Widget version. Field discovery currently supports V4.");
4926
+ var HIDE_MASTER = "display.hideWidgetButton";
4927
+ var HIDE_ALL = "launcher.hideCompletely";
4928
+ var HIDE_MOBILE = "launcher.hideOnMobile";
4929
+ function expandLauncherHideFields(fields2, current) {
4930
+ const byPath = new Map(fields2.map(({ path, value }) => [path, value]));
4931
+ const currentOf = (path) => current.find((field) => field.path === path)?.current;
4932
+ const get = (path) => byPath.has(path) ? byPath.get(path) : currentOf(path);
4933
+ const hideMaster = get(HIDE_MASTER);
4934
+ const hideAll = get(HIDE_ALL);
4935
+ const hideMobile = get(HIDE_MOBILE);
4936
+ if ((hideAll === true || hideMobile === true) && hideMaster !== true) {
4937
+ byPath.set(HIDE_MASTER, true);
4938
+ }
4939
+ if (hideMaster === true && hideAll !== true && hideMobile !== true) {
4940
+ byPath.set(HIDE_ALL, true);
4941
+ }
4942
+ return [...byPath.entries()].map(([path, value]) => ({ path, value }));
4943
+ }
4944
+ var versionSchema = z21.literal("v4").default("v4").describe("Widget version. Field discovery currently supports V4.");
4028
4945
  var widgetFieldsInputSchema = {
4029
4946
  version: versionSchema,
4030
- target: z17.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."),
4031
- fields: z17.array(z17.object({
4032
- path: z17.string().min(1),
4033
- value: z17.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.")
4034
4951
  })).min(1).describe("Paths and values to set. Discover paths and allowed values with joy_get_widget_fields. The server validates every value."),
4035
- reason: z17.string().min(3).describe("Why the merchant wants these widget changes."),
4036
- apply: z17.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.")
4037
4954
  };
4038
4955
  var MAX_FIELD_RESPONSE_CHARS = 12e3;
4039
- function estimateSize(fields) {
4040
- 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);
4041
4958
  }
4042
4959
  async function getWidgetFields(ctx, version2) {
4043
4960
  const { data } = await ctx.api.getData(fieldsPath, { query: { version: version2 } });
@@ -4047,30 +4964,30 @@ async function getWidgetFields(ctx, version2) {
4047
4964
  return data.fields;
4048
4965
  }
4049
4966
  function registerWidgetFieldReadTools(server, ctx) {
4050
- registerReadTool(server, {
4967
+ registerReadTool(server, ctx, {
4051
4968
  name: "joy_get_widget_fields",
4052
4969
  title: "Get settable widget fields",
4053
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.",
4054
4971
  inputSchema: {
4055
4972
  version: versionSchema,
4056
- group: z17.string().optional().describe("Return only this group. Omit to get the group index first."),
4057
- search: z17.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".')
4058
4975
  },
4059
4976
  handler: async (args) => {
4060
- const fields = await getWidgetFields(ctx, args.version);
4977
+ const fields2 = await getWidgetFields(ctx, args.version);
4061
4978
  const term = args.search?.trim().toLocaleLowerCase();
4062
- 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)));
4063
4980
  const wouldNotFit = estimateSize(matched) > MAX_FIELD_RESPONSE_CHARS;
4064
4981
  if (args.group === void 0 && !term && wouldNotFit) {
4065
4982
  const index = {};
4066
- for (const field of fields) {
4983
+ for (const field of fields2) {
4067
4984
  const entry = index[field.group] ?? { fields: 0, examples: [] };
4068
4985
  entry.fields += 1;
4069
4986
  if (entry.examples.length < 2)
4070
4987
  entry.examples.push(field.path);
4071
4988
  index[field.group] = entry;
4072
4989
  }
4073
- 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 });
4074
4991
  }
4075
4992
  if (matched.length === 0) {
4076
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.`;
@@ -4081,7 +4998,7 @@ function registerWidgetFieldReadTools(server, ctx) {
4081
4998
  entries.push({ label, path, type, current, ...values === void 0 ? {} : { values } });
4082
4999
  groups.set(group, entries);
4083
5000
  }
4084
- 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) });
4085
5002
  }
4086
5003
  });
4087
5004
  }
@@ -4089,13 +5006,14 @@ function registerWidgetFieldWriteTools(server, ctx) {
4089
5006
  registerConfigWriteTool(server, ctx, {
4090
5007
  name: "joy_set_widget_fields",
4091
5008
  title: "Set widget fields",
4092
- description: "CONFIG WRITE \u2014 set paths discovered by joy_get_widget_fields; the server validates fields and values. Previews each before \u2192 after by default, surfaces every refusal, and reads back after writing. Draft by default; never switch to live unless the merchant explicitly requests target: 'live'. A live save changes the storefront immediately with no publish step and no undo. Set apply: true only after preview approval; do not claim visual verification.",
5009
+ description: "CONFIG WRITE \u2014 set paths discovered by joy_get_widget_fields; the server validates fields and values. Previews each before \u2192 after by default, surfaces every refusal, and reads back after writing. Hiding the floating button needs display.hideWidgetButton AND launcher.hideCompletely (or hideOnMobile); setting one expands the other to match the admin Display panel. Account icon opening the widget is links.replaceAccountLink, not links.openWidgetAfterLogin. Draft by default; never switch to live unless the merchant explicitly requests target: 'live'. A live save changes the storefront immediately with no publish step and no undo. Set apply: true only after preview approval; do not claim visual verification.",
4093
5010
  inputSchema: widgetFieldsInputSchema,
4094
5011
  strictArguments: true,
4095
5012
  destructiveHint: true,
4096
5013
  handler: async (args) => {
4097
5014
  const current = await getWidgetFields(ctx, args.version);
4098
- const changes = args.fields.map(({ path, value }) => ({
5015
+ const fields2 = expandLauncherHideFields(args.fields, current);
5016
+ const changes = fields2.map(({ path, value }) => ({
4099
5017
  path,
4100
5018
  from: current.find((field) => field.path === path)?.current,
4101
5019
  to: value
@@ -4110,7 +5028,7 @@ The server will validate every requested path and value when applied; this previ
4110
5028
  result = await ctx.api.mutateData(fieldsPath, {
4111
5029
  method: "PUT",
4112
5030
  query: { version: args.version, target: args.target },
4113
- body: { fields: args.fields }
5031
+ body: { fields: fields2 }
4114
5032
  });
4115
5033
  } catch (error) {
4116
5034
  throw brandingErrorGuidance(error, "draft", args.target) ?? error;
@@ -4123,23 +5041,23 @@ The server will validate every requested path and value when applied; this previ
4123
5041
  readBackFailure = error instanceof Error ? error.message : String(error);
4124
5042
  }
4125
5043
  const refusedPaths = new Set(result.refused.map((field) => field.path));
4126
- const applied = [];
5044
+ const applied2 = [];
4127
5045
  const unconfirmed = [];
4128
- for (const field of args.fields) {
5046
+ for (const field of fields2) {
4129
5047
  if (refusedPaths.has(field.path))
4130
5048
  continue;
4131
5049
  const accepted = result.applied.find((entry) => entry.path === field.path);
4132
5050
  const observed = readBack.find((entry) => entry.path === field.path);
4133
5051
  if (!readBackFailure && accepted && observed && isDeepStrictEqual(accepted.value, field.value) && isDeepStrictEqual(observed.current, field.value)) {
4134
- applied.push({ path: field.path, value: field.value });
5052
+ applied2.push({ path: field.path, value: field.value });
4135
5053
  continue;
4136
5054
  }
4137
5055
  unconfirmed.push({ path: field.path, requested: field.value, current: observed?.current });
4138
5056
  }
4139
- const summary = `Widget field write: ${applied.length} confirmed by read-back, ${result.refused.length} refused, ${unconfirmed.length} unconfirmed.`;
5057
+ const summary = `Widget field write: ${applied2.length} confirmed by read-back, ${result.refused.length} refused, ${unconfirmed.length} unconfirmed.`;
4140
5058
  const details = `${summary}
4141
- ${JSON.stringify({ refused: result.refused, unconfirmed, applied }, null, 1)}`;
4142
- const consequence = args.target === "live" ? "WARNING \u2014 saving this changes the storefront immediately. There is NO publish step and NO undo." : applied.length > 0 ? WIDGET_DRAFT_SAVED_MESSAGE : "Review the V4 draft in Joy admin.";
5059
+ ${JSON.stringify({ refused: result.refused, unconfirmed, applied: applied2 }, null, 1)}`;
5060
+ const consequence = args.target === "live" ? "WARNING \u2014 saving this changes the storefront immediately. There is NO publish step and NO undo." : applied2.length > 0 ? WIDGET_DRAFT_SAVED_MESSAGE : "Review the V4 draft in Joy admin.";
4143
5061
  const report = `${details}
4144
5062
  ${consequence}${readBackFailure ? `
4145
5063
  Read-back failed: ${readBackFailure}` : ""}`;
@@ -4153,7 +5071,7 @@ Do not report refused or unconfirmed fields as saved. Inspect the current widget
4153
5071
  }
4154
5072
 
4155
5073
  // ../mcp-core/build/tools/referralBrandingWrite.js
4156
- import { z as z18 } from "zod";
5074
+ import { z as z22 } from "zod";
4157
5075
  var POPUP_DISPLAY_TYPES = ["auto_show_popup", "show_bubble_teaser", "do_not_show"];
4158
5076
  var REFERRAL_WIDGET_ARGUMENTS = {
4159
5077
  brand_color: "brandColor",
@@ -4169,24 +5087,24 @@ var REFERRAL_WIDGET_ARGUMENTS = {
4169
5087
  frequency_show_popup: "frequencyShowPopup",
4170
5088
  is_show_subscribe_newsletter: "isShowSubscribeNewsletter"
4171
5089
  };
4172
- var hexColor = (description) => z18.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);
4173
5091
  var referralWidgetInputShape = {
4174
5092
  brand_color: hexColor("Primary accent color for the referral widget."),
4175
5093
  text_color: hexColor("Main referral-widget text color."),
4176
5094
  action_bg_color: hexColor("Referral-widget action button background color."),
4177
5095
  action_text_color: hexColor("Referral-widget action button text color."),
4178
- color_type: z18.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."),
4179
5097
  custom_solid_color: hexColor("Custom solid background color used when color_type is custom-solid."),
4180
- theme_text_font: z18.string().trim().min(1).optional().describe("Referral-widget font family, for example Open+Sans."),
4181
- type_show_popup: z18.enum(POPUP_DISPLAY_TYPES).optional().describe("How the referral popup appears: automatically, as a bubble teaser, or not at all."),
4182
- is_auto_close_pop_up: z18.boolean().optional().describe("Whether an automatically shown referral popup closes itself."),
4183
- closing_pop_up_time: z18.number().int().min(1).optional().describe("Auto-close delay in seconds; must be a positive integer."),
4184
- frequency_show_popup: z18.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."),
4185
- is_show_subscribe_newsletter: z18.boolean().optional().describe("Show the newsletter subscription option in the referral popup."),
4186
- reason: z18.string().min(3).describe("Why the merchant wants the live referral widget changed."),
4187
- apply: z18.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.")
4188
5106
  };
4189
- var referralWidgetArgsSchema = z18.object(referralWidgetInputShape).strict();
5107
+ var referralWidgetArgsSchema = z22.object(referralWidgetInputShape).strict();
4190
5108
  function parseReferralWidgetArgs(input) {
4191
5109
  const parsed = referralWidgetArgsSchema.safeParse(input);
4192
5110
  if (parsed.success)
@@ -4252,6 +5170,369 @@ function registerReferralBrandingWriteTools(server, ctx) {
4252
5170
  });
4253
5171
  }
4254
5172
 
5173
+ // ../mcp-core/build/tools/redeemInCart.js
5174
+ import { z as z23 } from "zod";
5175
+ var PATH = "/shop/branding/redeem-inline";
5176
+ var styles = [
5177
+ "minimal",
5178
+ "editorial",
5179
+ "luxury",
5180
+ "bold",
5181
+ "brutalism",
5182
+ "playful",
5183
+ "tech",
5184
+ "botanic"
5185
+ ];
5186
+ var fills = ["solid", "gradient-br", "gradient-r", "gradient-b", "radial"];
5187
+ var gradients = ["gradient-br", "gradient-r", "gradient-b", "radial"];
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 === "" || (() => {
5190
+ try {
5191
+ return new URL(value).protocol === "https:";
5192
+ } catch {
5193
+ return false;
5194
+ }
5195
+ })(), "Use an HTTPS URL or an empty string to clear.");
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()
5200
+ }).strict();
5201
+ var colorFields = [
5202
+ "text",
5203
+ "dropdown",
5204
+ "primary",
5205
+ "buttonText",
5206
+ "buttonHover",
5207
+ "buttonHoverText",
5208
+ "savings",
5209
+ "sliderPrimary",
5210
+ "sliderTrack",
5211
+ "icon",
5212
+ "dropdownAccent"
5213
+ ];
5214
+ var colorsSchema = z23.object({
5215
+ background: fill.optional(),
5216
+ button: fill.optional(),
5217
+ ...Object.fromEntries(colorFields.map((key) => [key, hex.optional()])),
5218
+ buttonBorder: z23.string().optional(),
5219
+ buttonHoverBorder: z23.string().optional()
5220
+ }).strict();
5221
+ var cssShapeFields = [
5222
+ "buttonHoverOpacity",
5223
+ "buttonHoverTransform",
5224
+ "buttonHoverShadow",
5225
+ "buttonShadow",
5226
+ "buttonBorderWidth",
5227
+ "buttonBorderStyle",
5228
+ "buttonFontWeight",
5229
+ "buttonLetterSpacing",
5230
+ "buttonTextCase",
5231
+ "buttonTransition",
5232
+ "buttonHeight",
5233
+ "buttonFontSize",
5234
+ "buttonPaddingInline",
5235
+ "buttonSweepColor",
5236
+ "buttonSweepFrom",
5237
+ "buttonSweepTo",
5238
+ "buttonSweepDuration",
5239
+ "buttonSweepEasing"
5240
+ ];
5241
+ var fonts = ["default", "inter", "space-grotesk", "playfair", "pt-serif", "nunito"];
5242
+ var shapeSchema = z23.object({
5243
+ ...Object.fromEntries(cssShapeFields.map((key) => [key, z23.string().optional()])),
5244
+ borderColor: hex.optional(),
5245
+ patternColor: hex.optional(),
5246
+ dividerColor: hex.optional(),
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([
5256
+ "gift",
5257
+ "crown",
5258
+ "shopping-bag",
5259
+ "star",
5260
+ "heart",
5261
+ "trophy",
5262
+ "sparkle",
5263
+ "ticket",
5264
+ "none"
5265
+ ]).optional(),
5266
+ iconImage: https.optional(),
5267
+ backgroundImage: https.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()
5271
+ }).strict();
5272
+ var behaviorFields = {
5273
+ hide_for_guest: "hideForGuest",
5274
+ hide_not_enough_point: "hideNotEnoughPoint",
5275
+ detail_view_layout: "detailViewLayout",
5276
+ has_enable_cancel: "hasEnableCancel",
5277
+ enable_revoke_discount_apply_failed: "enableRevokeDiscountApplyFailed",
5278
+ show_points_input: "showPointsInput",
5279
+ prefer_higher_value_reward: "preferHigherValueReward",
5280
+ show_earn_back: "showEarnBack",
5281
+ has_reload_page: "hasReloadPage"
5282
+ };
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({
5286
+ ...behaviorInput,
5287
+ position_mode: z23.enum(["above_cart_summary", "custom"]).optional(),
5288
+ custom_type: z23.enum(["snippet", "selector"]).optional(),
5289
+ css_selector: z23.string().optional(),
5290
+ ...common
5291
+ }).strict();
5292
+ var designArgs = z23.object({
5293
+ style: z23.enum(styles).optional(),
5294
+ set_active: z23.boolean().optional(),
5295
+ colors: colorsSchema.optional(),
5296
+ shape: shapeSchema.optional(),
5297
+ ...common
5298
+ }).strict();
5299
+ function parse(schema, input) {
5300
+ const result = schema.safeParse(input);
5301
+ if (result.success)
5302
+ return result.data;
5303
+ const issue = result.error.issues[0];
5304
+ throw new JoyApiError("validation", `${issue.path.join(".")}: ${issue.message}`);
5305
+ }
5306
+ function valueAt(data, path) {
5307
+ return path.split(".").reduce((value, key) => value?.[key], data);
5308
+ }
5309
+ function changesFor(data, body) {
5310
+ const changes = [];
5311
+ function visit(value, prefix) {
5312
+ for (const [key, to] of Object.entries(value)) {
5313
+ const path = prefix ? `${prefix}.${key}` : key;
5314
+ if (to && typeof to === "object" && !Array.isArray(to))
5315
+ visit(to, path);
5316
+ else
5317
+ changes.push(...diffFields({ [path]: valueAt(data, path) }, { [path]: to }));
5318
+ }
5319
+ }
5320
+ visit(body, "");
5321
+ return changes;
5322
+ }
5323
+ function warnings(data) {
5324
+ return [
5325
+ ...data.redeemCartV4Ready === false ? [
5326
+ "V4 is disabled for this shop; settings are stored but do not show until enabled and published."
5327
+ ] : [],
5328
+ "Storefront updates after background metafield publish and requires the app embed."
5329
+ ];
5330
+ }
5331
+ function confirmed(data, body) {
5332
+ const missing = changesFor(data, body).map((change) => change.field);
5333
+ if (missing.length)
5334
+ throw new JoyApiError("server", `Redeem-in-cart write was not confirmed by read-back: ${missing.join(", ")}. Verify in Joy admin; do not retry blindly.`);
5335
+ }
5336
+ function applied(changes, data) {
5337
+ return summaryWithJson("Confirmed the Redeem-in-Cart settings in Firestore. Storefront updates after background metafield publish; the app embed is required. Prior values are included for manual restoration.", {
5338
+ changes: changes.map(({ field, from, to }) => ({ field, from: from ?? "(unset)", to })),
5339
+ current: Object.fromEntries(changes.map(({ field }) => [field, valueAt(data, field)]))
5340
+ });
5341
+ }
5342
+ async function getRedeemInCart(ctx, input) {
5343
+ const args = parse(z23.object({ style: z23.enum(styles).optional() }).strict(), input);
5344
+ const { data: raw } = await ctx.api.getData(PATH);
5345
+ const data = raw ?? {};
5346
+ const active = data.design?.activeStyle ?? "minimal";
5347
+ const legacy = data.design?.styles?.[active]?.earnBack?.show;
5348
+ const behavior = { ...data.behavior };
5349
+ for (const [field, fallback] of Object.entries({
5350
+ hideForGuest: false,
5351
+ hideNotEnoughPoint: false,
5352
+ detailViewLayout: false,
5353
+ hasEnableCancel: true,
5354
+ enableRevokeDiscountApplyFailed: true,
5355
+ showPointsInput: true,
5356
+ preferHigherValueReward: false
5357
+ }))
5358
+ behavior[field] ??= fallback;
5359
+ behavior.showEarnBackEffective = behavior.showEarnBack === void 0 ? legacy !== false : behavior.showEarnBack;
5360
+ if (behavior.showEarnBack === void 0) {
5361
+ behavior.showEarnBack = legacy === false ? "(unset \u2192 hidden by legacy preset)" : "(unset \u2192 shown)";
5362
+ }
5363
+ behavior.hasReloadPage = data.behavior?.hasReloadPage ?? "(unset)";
5364
+ behavior.hasReloadPageNote = "V3 theme setting, no effect on V4";
5365
+ const position = { ...data.position };
5366
+ for (const [field, fallback] of Object.entries({
5367
+ mode: "above_cart_summary",
5368
+ customType: "snippet",
5369
+ cssSelector: ""
5370
+ }))
5371
+ position[field] ??= fallback;
5372
+ const status = data.redeemCartV4Ready === void 0 ? "Redeem in cart V2 (internal V4, redeemCartV4Ready) status unknown (older backend)." : data.redeemCartV4Ready ? "Redeem in cart V2 (internal V4, redeemCartV4Ready) enabled for this shop (storefront follows after metafield publish; requires the app embed)." : "Redeem in cart V2 (internal V4, redeemCartV4Ready) disabled for this shop; stored settings do not currently apply.";
5373
+ const resolved = data.activeStyleResolved === void 0 ? "unknown (older backend)" : data.activeStyleResolved;
5374
+ return summaryWithJson(status, {
5375
+ behavior,
5376
+ position,
5377
+ activeStyle: active,
5378
+ activeStyleResolved: resolved,
5379
+ customisedStyles: Object.keys(data.design?.styles ?? {}),
5380
+ ...args.style ? {
5381
+ style: args.style,
5382
+ styleResolved: data.stylesResolved?.[args.style] ?? (data.activeStyleResolved?.id === args.style ? data.activeStyleResolved : "unknown (older backend)"),
5383
+ storedOverrides: data.design?.styles?.[args.style] ?? {},
5384
+ overriddenKeys: {
5385
+ colors: Object.keys(data.design?.styles?.[args.style]?.colors ?? {}),
5386
+ shape: Object.keys(data.design?.styles?.[args.style]?.shape ?? {})
5387
+ },
5388
+ legacyEarnBackShow: data.design?.styles?.[args.style]?.earnBack?.show ?? "(unset)"
5389
+ } : {}
5390
+ });
5391
+ }
5392
+ async function configureRedeemInCart(ctx, input) {
5393
+ const args = parse(behaviorArgs, input);
5394
+ if (args.position_mode === "custom" && args.custom_type === "selector" && !args.css_selector?.trim()) {
5395
+ throw new JoyApiError("validation", "custom selector position requires a non-empty css_selector.");
5396
+ }
5397
+ const body = {};
5398
+ for (const [argument, field] of Object.entries(behaviorFields)) {
5399
+ const value = args[argument];
5400
+ if (value !== void 0)
5401
+ (body.behavior ??= {})[field] = value;
5402
+ }
5403
+ for (const [argument, field] of Object.entries({
5404
+ position_mode: "mode",
5405
+ custom_type: "customType",
5406
+ css_selector: "cssSelector"
5407
+ })) {
5408
+ const value = args[argument];
5409
+ if (value !== void 0)
5410
+ (body.position ??= {})[field] = value;
5411
+ }
5412
+ if (!Object.keys(body).length)
5413
+ throw new JoyApiError("validation", "At least one behavior or position field is required.");
5414
+ const { data: raw } = await ctx.api.getData(PATH);
5415
+ const current = raw ?? {};
5416
+ const position = { ...current.position, ...body.position };
5417
+ if (position.mode === "custom" && position.customType === "selector" && !position.cssSelector?.trim())
5418
+ throw new JoyApiError("validation", "custom selector position requires a non-empty css_selector.");
5419
+ const changes = changesFor(current, body);
5420
+ const preview = buildChangePreview({
5421
+ action: "UPDATE",
5422
+ target: "Redeem-in-Cart behavior and position",
5423
+ reason: args.reason,
5424
+ changes,
5425
+ warnings: [
5426
+ ...warnings(current),
5427
+ ...position.mode === "custom" && position.customType === "snippet" ? ["Custom snippet position requires the snippet in the theme."] : [],
5428
+ "A bad CSS selector silently falls back to the default position."
5429
+ ]
5430
+ });
5431
+ if (!changes.length)
5432
+ return preview;
5433
+ const blocked = requireApply(args.apply, preview);
5434
+ if (blocked)
5435
+ return blocked;
5436
+ await ctx.api.mutateData(PATH, { method: "PUT", body });
5437
+ const { data } = await ctx.api.getData(PATH);
5438
+ confirmed(data ?? {}, body);
5439
+ return applied(changes, data ?? {});
5440
+ }
5441
+ async function configureRedeemInCartDesign(ctx, input) {
5442
+ const args = parse(designArgs, input);
5443
+ const { data: raw } = await ctx.api.getData(PATH);
5444
+ const current = raw ?? {};
5445
+ const style = args.style ?? current.design?.activeStyle ?? "minimal";
5446
+ const body = { design: {} };
5447
+ if (args.set_active === true)
5448
+ body.design.activeStyle = style;
5449
+ if (args.colors || args.shape) {
5450
+ const override = {};
5451
+ if (args.colors && Object.keys(args.colors).length) {
5452
+ override.colors = { ...args.colors };
5453
+ for (const key of ["background", "button"]) {
5454
+ const requested = override.colors[key];
5455
+ if (!requested)
5456
+ continue;
5457
+ const existing = current.design?.styles?.[style]?.colors?.[key] ?? current.stylesResolved?.[style]?.colors?.[key] ?? (current.activeStyleResolved?.id === style ? current.activeStyleResolved.colors?.[key] : void 0);
5458
+ if (existing) {
5459
+ if (requested.fillType === void 0 && existing.fillType)
5460
+ requested.fillType = existing.fillType;
5461
+ if (requested.colors?.length === 1 && existing.colors?.[1])
5462
+ requested.colors = [requested.colors[0], existing.colors[1]];
5463
+ if (requested.lastGradient === void 0 && existing.lastGradient)
5464
+ requested.lastGradient = existing.lastGradient;
5465
+ }
5466
+ }
5467
+ }
5468
+ if (args.shape && Object.keys(args.shape).length)
5469
+ override.shape = args.shape;
5470
+ if (Object.keys(override).length)
5471
+ body.design.styles = { [style]: override };
5472
+ }
5473
+ if (!Object.keys(body.design).length)
5474
+ throw new JoyApiError("validation", "Set set_active, colors, or shape to change a design.");
5475
+ const changes = changesFor(current, body);
5476
+ const previewChanges = changes.map((change) => {
5477
+ const prefix = `design.styles.${style}.`;
5478
+ if (!change.field.startsWith(prefix))
5479
+ return change;
5480
+ const resolved = current.stylesResolved?.[style] ?? (current.activeStyleResolved?.id === style ? current.activeStyleResolved : void 0);
5481
+ return {
5482
+ ...change,
5483
+ from: valueAt(resolved, change.field.slice(prefix.length)) ?? change.from ?? "(preset default)"
5484
+ };
5485
+ });
5486
+ const preview = buildChangePreview({
5487
+ action: "UPDATE",
5488
+ target: `Redeem-in-Cart preset ${style}`,
5489
+ reason: args.reason,
5490
+ changes: previewChanges,
5491
+ warnings: [
5492
+ ...warnings(current),
5493
+ "No visual preview exists; a wrong color can appear for every customer."
5494
+ ]
5495
+ });
5496
+ if (!changes.length)
5497
+ return preview;
5498
+ const blocked = requireApply(args.apply, preview);
5499
+ if (blocked)
5500
+ return blocked;
5501
+ await ctx.api.mutateData(PATH, { method: "PUT", body });
5502
+ const { data } = await ctx.api.getData(PATH);
5503
+ confirmed(data ?? {}, body);
5504
+ return applied(previewChanges, data ?? {});
5505
+ }
5506
+ function registerRedeemInCartReadTools(server, ctx) {
5507
+ registerReadTool(server, ctx, {
5508
+ name: "joy_get_redeem_in_cart",
5509
+ title: "Read Redeem-in-Cart settings",
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.',
5511
+ inputSchema: { style: z23.enum(styles).optional() },
5512
+ handler: (args) => getRedeemInCart(ctx, args)
5513
+ });
5514
+ }
5515
+ function registerRedeemInCartWriteTools(server, ctx) {
5516
+ registerConfigWriteTool(server, ctx, {
5517
+ name: "joy_configure_redeem_in_cart",
5518
+ title: "Configure Redeem-in-Cart behavior and position",
5519
+ description: "CONFIGURATION WRITE. Preview first, then apply explicitly. Includes every behavior flag, including hidden show_earn_back and V3-only has_reload_page. A bad CSS selector silently falls back to default position. Confirms by read-back.",
5520
+ inputSchema: behaviorArgs.shape,
5521
+ destructiveHint: true,
5522
+ strictArguments: true,
5523
+ handler: (args) => configureRedeemInCart(ctx, args)
5524
+ });
5525
+ registerConfigWriteTool(server, ctx, {
5526
+ name: "joy_configure_redeem_in_cart_design",
5527
+ title: "Configure Redeem-in-Cart design",
5528
+ description: "CONFIGURATION WRITE. Set the active preset or update its colors and shape. Preview first, then apply explicitly. No visual preview exists; storefront updates after metafield publish. Confirms every requested field by read-back.",
5529
+ inputSchema: designArgs.shape,
5530
+ destructiveHint: true,
5531
+ strictArguments: true,
5532
+ handler: (args) => configureRedeemInCartDesign(ctx, args)
5533
+ });
5534
+ }
5535
+
4255
5536
  // ../mcp-core/build/tools/themeSetup.js
4256
5537
  function loyaltyWidget(embeds) {
4257
5538
  return embeds.find((embed) => embed.name === "Joy Loyalty Widget" || embed.handle === "app-embed");
@@ -4279,6 +5560,8 @@ function formatThemeSetup(themeSetup) {
4279
5560
  });
4280
5561
  }
4281
5562
  for (const block of blocks) {
5563
+ if (block.added === true)
5564
+ continue;
4282
5565
  actions.push({
4283
5566
  item: block.name,
4284
5567
  instruction: "The merchant can add this missing block from the theme editor and save.",
@@ -4296,7 +5579,7 @@ function formatThemeSetup(themeSetup) {
4296
5579
  return summaryWithJson(`Loyalty widget is ${widgetStatus}.${passwordWarning} This read-only tool cannot switch anything on; only the merchant can use the server-provided links and save the change.`, { next_steps: actions });
4297
5580
  }
4298
5581
  function registerThemeSetupTools(server, ctx) {
4299
- registerReadTool(server, {
5582
+ registerReadTool(server, ctx, {
4300
5583
  name: "joy_check_theme_setup",
4301
5584
  title: "Check storefront theme setup",
4302
5585
  description: "Read-only diagnosis for the Joy loyalty widget, related theme embeds, and missing blocks. It cannot switch anything on or add anything: only the merchant can open the server-provided Shopify editor link, make the change, and save. Use it to answer why the widget is not showing and to get the exact setup links; never claim this tool enabled anything.",
@@ -4309,26 +5592,26 @@ function registerThemeSetupTools(server, ctx) {
4309
5592
  }
4310
5593
 
4311
5594
  // ../mcp-core/build/tools/imageUpload.js
4312
- import { z as z19 } from "zod";
5595
+ import { z as z24 } from "zod";
4313
5596
  var imageUploadInputSchema = {
4314
- source_url: z19.string().url().optional().describe("Public URL of an already hosted image. Provide exactly one of source_url or data_url."),
4315
- data_url: z19.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."),
4316
- filename: z19.string().min(1).max(200).optional().describe("Filename to use for the uploaded image."),
4317
- alt: z19.string().max(512).optional().describe("Merchant-provided alternative text for the image."),
4318
- reason: z19.string().min(3).describe("Why the merchant wants this image uploaded."),
4319
- apply: z19.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.")
4320
5603
  };
4321
5604
  function imageUploadErrorGuidance(error) {
4322
5605
  if (!(error instanceof JoyApiError))
4323
5606
  return void 0;
4324
5607
  if (error.status === 400) {
4325
- 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);
4326
5609
  }
4327
5610
  if (error.status === 413) {
4328
- 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);
4329
5612
  }
4330
5613
  if (error.status === 502) {
4331
- 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);
4332
5615
  }
4333
5616
  return void 0;
4334
5617
  }
@@ -4336,7 +5619,7 @@ function registerImageUploadTools(server, ctx) {
4336
5619
  registerConfigWriteTool(server, ctx, {
4337
5620
  name: "joy_upload_image",
4338
5621
  title: "Upload image to Shopify Files",
4339
- description: "CONFIG WRITE \u2014 uploads an image to the merchant's Shopify Files CDN and returns a permanent URL. Two-step flow: first call joy_upload_image, then pass its returned url to joy_set_widget_fields for any image field (sections.<ID>.bannerImage, membershipCard.*.bgImage, header.bannerImage, program.logo). Use data_url for image bytes the agent generated, or source_url for an image already hosted somewhere public; provide exactly one. The upload cap is 20 MB; the server validates the image type and source. This tool cannot see the image: do not describe, judge, or vouch for what was uploaded. The merchant reviews it in Joy admin. This writes into the merchant's store and is not undone by discarding a widget draft: the file stays in Shopify Files until the merchant deletes it. Dry run by default; set apply: true only after the merchant approves the preview.",
5622
+ description: "CONFIG WRITE \u2014 uploads an image to the merchant's Shopify Files CDN and returns a permanent URL. Two-step flow: first call joy_upload_image, then pass its returned url to joy_set_widget_fields for any image field (sections.<ID>.bannerImage, membershipCard.*.bgImage, header.bannerImage, program.logo), or to joy_update_loyalty_page_design for `hero.backgroundImage`, `hero.mobileBackgroundImage`, `inlineBanner.backgroundImage`, `inlineBanner.mobileBackgroundImage`, `earn.bannerImage`, `referral.image`, or `icons`; that loyalty-page tool also accepts separate video URLs at `hero.backgroundVideo` and `hero.mobileBackgroundVideo`. Use data_url for image bytes the agent generated, or source_url for an image already hosted somewhere public; provide exactly one. The upload cap is 20 MB; the server validates the image type and source. This tool cannot see the image: do not describe, judge, or vouch for what was uploaded. The merchant reviews it in Joy admin. This writes into the merchant's store and is not undone by discarding a widget draft: the file stays in Shopify Files until the merchant deletes it. Dry run by default; set apply: true only after the merchant approves the preview.",
4340
5623
  inputSchema: imageUploadInputSchema,
4341
5624
  strictArguments: true,
4342
5625
  destructiveHint: false,
@@ -4361,14 +5644,14 @@ function registerImageUploadTools(server, ctx) {
4361
5644
  }
4362
5645
 
4363
5646
  // ../mcp-core/build/prompts.js
4364
- import { z as z20 } from "zod";
5647
+ import { z as z25 } from "zod";
4365
5648
  function registerAllPrompts(server) {
4366
5649
  server.registerPrompt("support_missing_points", {
4367
5650
  title: "Investigate missing points for an order",
4368
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.",
4369
5652
  argsSchema: {
4370
- email: z20.string().describe("Customer's email address"),
4371
- order_number: z20.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")
4372
5655
  }
4373
5656
  }, ({ email, order_number }) => ({
4374
5657
  messages: [
@@ -4395,7 +5678,7 @@ function registerAllPrompts(server) {
4395
5678
  title: "Loyalty program review (health + revenue + tiers + referral)",
4396
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.",
4397
5680
  argsSchema: {
4398
- period: z20.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.")
4399
5682
  }
4400
5683
  }, ({ period }) => ({
4401
5684
  messages: [
@@ -4426,7 +5709,7 @@ function registerAllPrompts(server) {
4426
5709
  title: "Investigate a member for abuse risk",
4427
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.",
4428
5711
  argsSchema: {
4429
- email: z20.string().describe("Email of the member to investigate")
5712
+ email: z25.string().describe("Email of the member to investigate")
4430
5713
  }
4431
5714
  }, ({ email }) => ({
4432
5715
  messages: [
@@ -4453,8 +5736,8 @@ function registerAllPrompts(server) {
4453
5736
  title: "Set up a loyalty program safely",
4454
5737
  description: "Guided loyalty-program setup: inspect the current configuration, propose a minimal program, and make each approved configuration change one at a time.",
4455
5738
  argsSchema: {
4456
- business_type: z20.string().optional().describe("Business type, e.g. 'fashion' or 'coffee shop'."),
4457
- goal: z20.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'.")
4458
5741
  }
4459
5742
  }, ({ business_type, goal }) => ({
4460
5743
  messages: [
@@ -4479,7 +5762,7 @@ function registerAllPrompts(server) {
4479
5762
  title: "Apply storefront branding to a widget draft",
4480
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.",
4481
5764
  argsSchema: {
4482
- preferences: z20.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'.")
4483
5766
  }
4484
5767
  }, ({ preferences }) => ({
4485
5768
  messages: [
@@ -4506,7 +5789,7 @@ function registerAllPrompts(server) {
4506
5789
  title: "Set up the storefront loyalty widget",
4507
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.",
4508
5791
  argsSchema: {
4509
- focus: z20.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'.")
4510
5793
  }
4511
5794
  }, ({ focus }) => ({
4512
5795
  messages: [
@@ -4544,16 +5827,25 @@ function registerAllTools(server, ctx) {
4544
5827
  registerOrderExplainTools(server, ctx);
4545
5828
  registerActivityAuditTools(server, ctx);
4546
5829
  registerTierUpgradeTools(server, ctx);
5830
+ registerLoyaltyPageTools(server, ctx);
5831
+ registerLoyaltyPageTextTools(server, ctx);
5832
+ registerLoyaltyHubTools(server, ctx);
5833
+ registerLoyaltyHubTextTools(server, ctx);
4547
5834
  registerThemeSetupTools(server, ctx);
4548
5835
  registerBrandingReadTools(server, ctx);
4549
5836
  registerWidgetFieldReadTools(server, ctx);
5837
+ registerRedeemInCartReadTools(server, ctx);
4550
5838
  if (ctx.config.writesEnabled) {
4551
5839
  registerPointWriteTools(server, ctx);
4552
5840
  registerTierWriteTools(server, ctx);
4553
5841
  registerRewardWriteTools(server, ctx);
4554
5842
  registerReferralWriteTools(server, ctx);
4555
5843
  registerAnalyticsWriteTools(server, ctx);
4556
- 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.");
5844
+ registerLoyaltyPageWriteTools(server, ctx);
5845
+ registerLoyaltyPageTextWriteTools(server, ctx);
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.");
4557
5849
  }
4558
5850
  if (ctx.config.configWritesEnabled) {
4559
5851
  registerProgramConfigTools(server, ctx);
@@ -4563,24 +5855,492 @@ function registerAllTools(server, ctx) {
4563
5855
  registerBrandingWriteTools(server, ctx);
4564
5856
  registerWidgetFieldWriteTools(server, ctx);
4565
5857
  registerReferralBrandingWriteTools(server, ctx);
5858
+ registerRedeemInCartWriteTools(server, ctx);
4566
5859
  registerImageUploadTools(server, ctx);
4567
5860
  log.info("CONFIG write tools ENABLED (configure earning/redemption/referral program, tier, tier reward, tier settings, shop settings, referral widget, add translation language). These change behaviour for every customer at once and every one of them previews by default \u2014 an apply requires a second call. No backend audit trail records who changed configuration or why, and the endpoints accept no Idempotency-Key, so a repeated create makes a second live record.");
4568
5861
  }
4569
5862
  }
4570
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
+
4571
6293
  // src/index.ts
4572
6294
  var require2 = createRequire(import.meta.url);
4573
6295
  var { version } = require2("../package.json");
4574
6296
  async function main() {
4575
6297
  const config = loadConfig();
4576
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
+ }
4577
6336
  const server = new McpServer({ name: "joy-loyalty", version });
4578
- registerAllTools(server, { api, config, cache: new TtlCache() });
6337
+ registerAllTools(server, { api, config, cache: new TtlCache(), telemetry });
4579
6338
  registerAllPrompts(server);
4580
6339
  const transport = new StdioServerTransport();
4581
6340
  await server.connect(transport);
6341
+ telemetry?.instrumentTransport(transport);
4582
6342
  log.info(
4583
- `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}`
4584
6344
  );
4585
6345
  }
4586
6346
  main().catch((error) => {