@joy-loyalty/mcp 0.3.7

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 ADDED
@@ -0,0 +1,1955 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { createRequire } from "node:module";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+
8
+ // src/config.ts
9
+ var ConfigError = class extends Error {
10
+ };
11
+ var DEFAULT_API_BASE_URL = "https://api.joy.so";
12
+ var JOY_API_HOSTS = /* @__PURE__ */ new Set(["api.joy.so", "dev-api.joy.so"]);
13
+ var FIREBASE_STAGING_HOST = /^avada-joy(-staging(-\d+)?)?\.(firebaseapp\.com|web\.app)$/;
14
+ function assertAllowedBaseUrl(baseUrl) {
15
+ let parsed;
16
+ try {
17
+ parsed = new URL(baseUrl);
18
+ } catch {
19
+ throw new ConfigError(`JOY_API_BASE_URL is not a valid URL: "${baseUrl}".`);
20
+ }
21
+ const host = parsed.hostname;
22
+ const isLocal = host === "localhost" || host === "127.0.0.1";
23
+ if (!isLocal && parsed.protocol !== "https:") {
24
+ throw new ConfigError(
25
+ `JOY_API_BASE_URL must use https (got "${parsed.protocol}//") \u2014 the shop's secret key travels in request headers.`
26
+ );
27
+ }
28
+ if (isLocal || JOY_API_HOSTS.has(host) || FIREBASE_STAGING_HOST.test(host)) return;
29
+ throw new ConfigError(
30
+ `JOY_API_BASE_URL host "${host}" is not a Joy API host \u2014 refusing to send the shop's secret key there. Allowed: api.joy.so, dev-api.joy.so, avada-joy-staging-N.firebaseapp.com, localhost.`
31
+ );
32
+ }
33
+ function loadConfig(env = process.env) {
34
+ const appKey = env.JOY_APP_KEY?.trim();
35
+ const secretKey = env.JOY_SECRET_KEY?.trim() || void 0;
36
+ const privateBotKey = env.JOY_PRIVATE_BOT_KEY?.trim() || void 0;
37
+ const apiBaseUrl = (env.JOY_API_BASE_URL?.trim() || DEFAULT_API_BASE_URL).replace(/\/+$/, "");
38
+ assertAllowedBaseUrl(apiBaseUrl);
39
+ if (!appKey) {
40
+ throw new ConfigError(
41
+ 'Missing JOY_APP_KEY (your shop ID). Set it in the "env" block of your MCP client config.'
42
+ );
43
+ }
44
+ if (!secretKey && !privateBotKey) {
45
+ throw new ConfigError(
46
+ "Missing credential: set JOY_SECRET_KEY (merchant API secret key) or JOY_PRIVATE_BOT_KEY (Joy-internal, read-only)."
47
+ );
48
+ }
49
+ if (secretKey && privateBotKey) {
50
+ throw new ConfigError(
51
+ "Set only ONE of JOY_SECRET_KEY or JOY_PRIVATE_BOT_KEY \u2014 they are mutually exclusive auth modes."
52
+ );
53
+ }
54
+ const mode = privateBotKey ? "internal-bot" : "merchant";
55
+ const writesEnabled = mode === "merchant" && env.JOY_MCP_ENABLE_WRITES === "true";
56
+ const rawThreshold = Number(env.JOY_MCP_DEDUCT_DRYRUN_THRESHOLD ?? "1000");
57
+ const deductDryRunThreshold = Number.isFinite(rawThreshold) && rawThreshold >= 0 ? rawThreshold : 1e3;
58
+ return { apiBaseUrl, appKey, secretKey, privateBotKey, mode, writesEnabled, deductDryRunThreshold };
59
+ }
60
+
61
+ // ../mcp-core/build/client/joyApi.js
62
+ var JoyApiError = class extends Error {
63
+ kind;
64
+ modelMessage;
65
+ status;
66
+ constructor(kind, modelMessage, status) {
67
+ super(modelMessage);
68
+ this.kind = kind;
69
+ this.modelMessage = modelMessage;
70
+ this.status = status;
71
+ this.name = "JoyApiError";
72
+ }
73
+ };
74
+ var DEFAULT_TIMEOUT_MS = 1e4;
75
+ function extractServerMessage(body) {
76
+ if (!body || typeof body !== "object")
77
+ return void 0;
78
+ const record = body;
79
+ if (typeof record.error === "string")
80
+ return record.error;
81
+ if (record.error && typeof record.error === "object") {
82
+ const message = record.error.message;
83
+ if (typeof message === "string")
84
+ return message;
85
+ }
86
+ if (typeof record.message === "string")
87
+ return record.message;
88
+ return void 0;
89
+ }
90
+ function extractServerCode(body) {
91
+ if (!body || typeof body !== "object")
92
+ return void 0;
93
+ const record = body;
94
+ if (typeof record.code === "string")
95
+ return record.code;
96
+ if (record.error && typeof record.error === "object") {
97
+ const code = record.error.code;
98
+ if (typeof code === "string")
99
+ return code;
100
+ }
101
+ return void 0;
102
+ }
103
+ function classifyError(status, serverMessage, retryAfter, credentialHint) {
104
+ const detail = serverMessage ? ` Server said: "${serverMessage}".` : "";
105
+ if (status === 401) {
106
+ 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);
107
+ }
108
+ if (status === 403) {
109
+ const text = (serverMessage ?? "").toLowerCase();
110
+ if (text.includes("plan")) {
111
+ 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);
112
+ }
113
+ 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);
114
+ }
115
+ if (status === 429) {
116
+ const wait = retryAfter ? `${retryAfter} seconds` : "~10 seconds";
117
+ return new JoyApiError("rate_limit", `Rate limited: the Joy API allows 120 requests per 10 seconds per shop. 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.`, status);
118
+ }
119
+ if (status === 404) {
120
+ 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);
121
+ }
122
+ if (status === 400 || status === 422) {
123
+ return new JoyApiError("validation", `Invalid request.${detail} Fix the named parameter and call again.`, status);
124
+ }
125
+ if (status === 409) {
126
+ 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);
127
+ }
128
+ 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);
129
+ }
130
+ function errorFromOkBody(body) {
131
+ if (!body || typeof body !== "object")
132
+ return void 0;
133
+ const record = body;
134
+ const hasError = record.success === false || typeof record.error === "string" || record.error !== null && record.error !== void 0 && typeof record.error === "object";
135
+ if (!hasError)
136
+ return void 0;
137
+ const message = extractServerMessage(record);
138
+ const code = extractServerCode(record);
139
+ if (code === "SHOP_UNINSTALLED" || /uninstall/i.test(message ?? "")) {
140
+ 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.");
141
+ }
142
+ if (code === "VALIDATION_ERROR") {
143
+ return new JoyApiError("validation", `Invalid request${message ? `: "${message}"` : ""}. Fix the named parameter and call again.`);
144
+ }
145
+ 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.`);
146
+ }
147
+ var JoyApi = class {
148
+ config;
149
+ constructor(config) {
150
+ this.config = config;
151
+ }
152
+ credentialHint() {
153
+ return this.config.mode === "internal-bot" ? "JOY_APP_KEY and JOY_PRIVATE_BOT_KEY" : "JOY_APP_KEY and JOY_SECRET_KEY";
154
+ }
155
+ headers() {
156
+ const headers = {
157
+ "Content-Type": "application/json",
158
+ "X-Joy-Loyalty-App-Key": this.config.appKey
159
+ };
160
+ if (this.config.privateBotKey) {
161
+ headers["X-Joy-Private-Bot-Key"] = this.config.privateBotKey;
162
+ } else if (this.config.secretKey) {
163
+ headers["X-Joy-Loyalty-Secret-Key"] = this.config.secretKey;
164
+ }
165
+ return headers;
166
+ }
167
+ async request(path, options = {}) {
168
+ const { method = "GET", query, body, timeoutMs = DEFAULT_TIMEOUT_MS, headers } = options;
169
+ const url = new URL(`${this.config.apiBaseUrl}/rest_api/v2${path}`);
170
+ for (const [key, value] of Object.entries(query ?? {})) {
171
+ if (value !== void 0)
172
+ url.searchParams.set(key, String(value));
173
+ }
174
+ let response;
175
+ try {
176
+ response = await fetch(url, {
177
+ method,
178
+ headers: { ...headers, ...this.headers() },
179
+ body: body === void 0 ? void 0 : JSON.stringify(body),
180
+ // fetch strips authorization on cross-origin redirects but NOT custom
181
+ // headers — an open redirect would forward the shop key elsewhere
182
+ redirect: "error",
183
+ signal: AbortSignal.timeout(timeoutMs)
184
+ });
185
+ } catch (error) {
186
+ if (error instanceof Error && error.name === "TimeoutError") {
187
+ throw new JoyApiError("timeout", `The Joy API did not respond within ${Math.round(timeoutMs / 1e3)}s. Safe to retry ONCE; if it times out again, report the outage instead of retrying further.`);
188
+ }
189
+ 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.`);
190
+ }
191
+ let parsed;
192
+ try {
193
+ parsed = await response.json();
194
+ } catch {
195
+ parsed = void 0;
196
+ }
197
+ if (!response.ok) {
198
+ throw classifyError(response.status, extractServerMessage(parsed), response.headers.get("Retry-After"), this.credentialHint());
199
+ }
200
+ const okBodyError = errorFromOkBody(parsed);
201
+ if (okBodyError)
202
+ throw okBodyError;
203
+ return parsed ?? {};
204
+ }
205
+ /** GET that unwraps the {success, data, meta} envelope — agent context never sees the wrapper. */
206
+ async getData(path, options = {}) {
207
+ const envelope = await this.request(path, options);
208
+ return {
209
+ data: envelope.data,
210
+ hasNext: envelope.meta?.pagination?.hasNext === true
211
+ };
212
+ }
213
+ /** POST/PUT that unwraps the envelope — for write tools. */
214
+ async mutateData(path, options = {}) {
215
+ const envelope = await this.request(path, options);
216
+ return envelope.data;
217
+ }
218
+ };
219
+
220
+ // ../mcp-core/build/cache.js
221
+ var TtlCache = class {
222
+ store = /* @__PURE__ */ new Map();
223
+ get(key, ttlMs, loader) {
224
+ const now = Date.now();
225
+ const hit = this.store.get(key);
226
+ if (hit && hit.expiresAt > now)
227
+ return hit.promise;
228
+ const promise = loader().catch((error) => {
229
+ this.store.delete(key);
230
+ throw error;
231
+ });
232
+ this.store.set(key, { expiresAt: now + ttlMs, promise });
233
+ return promise;
234
+ }
235
+ };
236
+
237
+ // ../mcp-core/build/tools/members.js
238
+ import { z } from "zod";
239
+
240
+ // ../mcp-core/build/logger.js
241
+ var log = {
242
+ info(message) {
243
+ console.error(`[joy-mcp] ${message}`);
244
+ },
245
+ error(message) {
246
+ console.error(`[joy-mcp:error] ${message}`);
247
+ }
248
+ };
249
+
250
+ // ../mcp-core/build/toolkit.js
251
+ async function runHandler(name, run) {
252
+ try {
253
+ const text = await run();
254
+ return { content: [{ type: "text", text }] };
255
+ } catch (error) {
256
+ if (error instanceof JoyApiError) {
257
+ return { isError: true, content: [{ type: "text", text: error.modelMessage }] };
258
+ }
259
+ const message = error instanceof Error ? error.message : String(error);
260
+ log.error(`${name} failed unexpectedly: ${message}`);
261
+ return {
262
+ isError: true,
263
+ content: [
264
+ {
265
+ type: "text",
266
+ 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.`
267
+ }
268
+ ]
269
+ };
270
+ }
271
+ }
272
+ function registerReadTool(server, spec) {
273
+ const callback = (async (args) => runHandler(spec.name, () => spec.handler(args ?? {})));
274
+ server.registerTool(spec.name, {
275
+ title: spec.title,
276
+ description: spec.description,
277
+ inputSchema: spec.inputSchema,
278
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
279
+ }, callback);
280
+ }
281
+ function registerWriteTool(server, ctx, spec) {
282
+ const callback = (async (args) => {
283
+ if (!ctx.config.writesEnabled) {
284
+ return {
285
+ isError: true,
286
+ content: [
287
+ {
288
+ type: "text",
289
+ 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.`
290
+ }
291
+ ]
292
+ };
293
+ }
294
+ return runHandler(spec.name, () => spec.handler(args ?? {}));
295
+ });
296
+ server.registerTool(spec.name, {
297
+ title: spec.title,
298
+ description: spec.description,
299
+ inputSchema: spec.inputSchema,
300
+ annotations: {
301
+ readOnlyHint: false,
302
+ destructiveHint: spec.destructiveHint,
303
+ idempotentHint: spec.idempotentHint,
304
+ openWorldHint: false
305
+ }
306
+ }, callback);
307
+ }
308
+
309
+ // ../mcp-core/build/shape.js
310
+ var CHARACTER_LIMIT = 24e3;
311
+ var MAX_STRING_FIELD = 400;
312
+ function truncateString(value) {
313
+ return value.length > MAX_STRING_FIELD ? `${value.slice(0, MAX_STRING_FIELD)}\u2026` : value;
314
+ }
315
+ function scalarOnly(source) {
316
+ const result = {};
317
+ const omitted = [];
318
+ for (const [key, value] of Object.entries(source)) {
319
+ if (value === null || value === void 0 || value === "")
320
+ continue;
321
+ if (typeof value === "string") {
322
+ result[key] = truncateString(value);
323
+ } else if (typeof value === "number" || typeof value === "boolean") {
324
+ result[key] = value;
325
+ } else {
326
+ omitted.push(key);
327
+ }
328
+ }
329
+ if (omitted.length > 0)
330
+ result._omitted = omitted;
331
+ return result;
332
+ }
333
+ function compact(source, preferredKeys) {
334
+ if (!preferredKeys)
335
+ return scalarOnly(source);
336
+ const result = {};
337
+ for (const key of preferredKeys) {
338
+ const value = source[key];
339
+ if (value === null || value === void 0 || value === "")
340
+ continue;
341
+ result[key] = typeof value === "string" ? truncateString(value) : value;
342
+ }
343
+ if (Object.keys(result).length < 2)
344
+ return scalarOnly(source);
345
+ return result;
346
+ }
347
+ function clampText(text) {
348
+ if (text.length <= CHARACTER_LIMIT)
349
+ return text;
350
+ return `${text.slice(0, CHARACTER_LIMIT)}
351
+ \u2026 [truncated: response exceeded ${CHARACTER_LIMIT} chars \u2014 narrow the query or use limit/after paging]`;
352
+ }
353
+ function formatPoints(value) {
354
+ return typeof value === "number" ? value.toLocaleString("en-US") : String(value ?? "?");
355
+ }
356
+ function summaryWithJson(summary, payload) {
357
+ return clampText(`${summary}
358
+ ${JSON.stringify(payload, null, 1)}`);
359
+ }
360
+
361
+ // ../mcp-core/build/tools/members.js
362
+ var MEMBER_DETAIL_KEYS = [
363
+ "id",
364
+ "shopifyCustomerId",
365
+ "email",
366
+ "name",
367
+ "phone",
368
+ "type",
369
+ "point",
370
+ "pointsRemain",
371
+ "tierPoint",
372
+ "pendingPoint",
373
+ "totalSpent",
374
+ "totalEarnedPoints",
375
+ "tierId",
376
+ "tierName",
377
+ "referralCode",
378
+ "state",
379
+ "birthday",
380
+ "createdAt"
381
+ ];
382
+ var MEMBER_LIST_KEYS = ["id", "shopifyCustomerId", "email", "name", "point", "tierName", "type"];
383
+ var MEMBER_SORTS = {
384
+ points: { field: "point", label: "points balance" },
385
+ tier_points: { field: "tierPoint", label: "VIP tier points" },
386
+ created_at: { field: "createdAt", label: "signup date" },
387
+ recent_activity: { field: "updatedAt", label: "recent activity" }
388
+ };
389
+ var ACTIVITY_KEYS = [
390
+ "id",
391
+ "type",
392
+ "event",
393
+ "content",
394
+ "oldPoint",
395
+ "newPoint",
396
+ "earnPoint",
397
+ "spendPoint",
398
+ "programTitle",
399
+ "couponCode",
400
+ "source",
401
+ "orderName",
402
+ "adminNote",
403
+ "userNote",
404
+ "reason",
405
+ "createdAt"
406
+ ];
407
+ var REWARD_KEYS = [
408
+ "id",
409
+ "couponCode",
410
+ "programTitle",
411
+ "discountStatus",
412
+ "orderReqAmount",
413
+ "expiredAt",
414
+ "usedAt",
415
+ "createdAt"
416
+ ];
417
+ var memberIdentifierSchema = {
418
+ email: z.string().optional().describe("Member email. Matched exactly after lowercasing/trimming \u2014 typos return no result."),
419
+ shopify_customer_id: z.string().optional().describe('Shopify customer ID (numeric string), e.g. "7348912345".')
420
+ };
421
+ function normalizeEmail(email) {
422
+ return email.trim().toLowerCase();
423
+ }
424
+ async function resolveMember(api, args) {
425
+ if (args.shopify_customer_id) {
426
+ const { data } = await api.getData(`/customers/external/${encodeURIComponent(args.shopify_customer_id.trim())}`);
427
+ if (data && typeof data === "object") {
428
+ if (args.email && typeof data.email === "string") {
429
+ const givenEmail = normalizeEmail(args.email);
430
+ if (normalizeEmail(data.email) !== givenEmail) {
431
+ throw new JoyApiError("validation", `Identifier mismatch: shopify_customer_id "${args.shopify_customer_id}" belongs to ${data.email}, not "${givenEmail}". These point at DIFFERENT customers \u2014 re-check which one you mean and pass only ONE identifier.`);
432
+ }
433
+ }
434
+ return data;
435
+ }
436
+ throw new JoyApiError("not_found", `No loyalty member found for shopify_customer_id "${args.shopify_customer_id}". Verify the ID, or look the member up by email instead.`);
437
+ }
438
+ if (args.email) {
439
+ const email = normalizeEmail(args.email);
440
+ const { data } = await api.getData("/customers", { query: { email, limit: 1 } });
441
+ const member = Array.isArray(data) ? data[0] : void 0;
442
+ if (member)
443
+ return member;
444
+ throw new JoyApiError("not_found", `No loyalty member found with email "${email}" (exact match). Double-check the spelling, or use shopify_customer_id if you have it. Customers who never joined the program will not appear.`);
445
+ }
446
+ throw new JoyApiError("validation", "Provide either email or shopify_customer_id to identify the member.");
447
+ }
448
+ function memberDisplayName(member) {
449
+ const name = typeof member.name === "string" ? member.name.trim() : "";
450
+ return name || String(member.email ?? member.id ?? "member");
451
+ }
452
+ function reclassifyCursorError(error, after) {
453
+ if (error instanceof JoyApiError && error.kind === "server" && after) {
454
+ throw new JoyApiError("validation", `The 'after' cursor "${after}" appears to be invalid. Retry WITHOUT 'after', or use only a next_after value returned by a previous call.`);
455
+ }
456
+ throw error;
457
+ }
458
+ function registerMemberTools(server, ctx) {
459
+ registerReadTool(server, {
460
+ name: "joy_find_member",
461
+ title: "Find a loyalty member",
462
+ 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.',
463
+ inputSchema: memberIdentifierSchema,
464
+ handler: async (args) => {
465
+ const member = await resolveMember(ctx.api, args);
466
+ const shaped = compact(member, MEMBER_DETAIL_KEYS);
467
+ const tier = member.tierName ? `, tier ${String(member.tierName)}` : "";
468
+ return summaryWithJson(`Member ${memberDisplayName(member)}: ${formatPoints(member.point)} points${tier}.`, shaped);
469
+ }
470
+ });
471
+ registerReadTool(server, {
472
+ name: "joy_get_member_store_credit",
473
+ title: "Get a member's store credit balance",
474
+ 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.",
475
+ inputSchema: memberIdentifierSchema,
476
+ handler: async (args) => {
477
+ let path;
478
+ let who;
479
+ if (args.shopify_customer_id) {
480
+ const externalId = args.shopify_customer_id.trim();
481
+ path = `/customers/external/${encodeURIComponent(externalId)}/store-credit`;
482
+ who = `Shopify customer ${externalId}`;
483
+ } else {
484
+ const member = await resolveMember(ctx.api, args);
485
+ path = `/customers/${encodeURIComponent(String(member.id))}/store-credit`;
486
+ who = memberDisplayName(member);
487
+ }
488
+ const { data } = await ctx.api.getData(path);
489
+ const accounts = Array.isArray(data?.accounts) ? data.accounts : [];
490
+ if (accounts.length === 0) {
491
+ return `${who} has NO store credit \u2014 no store credit account exists on Shopify (balance 0).`;
492
+ }
493
+ const balances = accounts.map((account) => `${String(account.amount)} ${String(account.currencyCode)}`).join(", ");
494
+ return summaryWithJson(`${who} has store credit: ${balances}.`, {
495
+ accounts: accounts.map((account) => compact(account, ["amount", "currencyCode"]))
496
+ });
497
+ }
498
+ });
499
+ registerReadTool(server, {
500
+ name: "joy_list_members",
501
+ title: "List loyalty members",
502
+ 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.',
503
+ inputSchema: {
504
+ limit: z.number().int().min(1).max(100).optional().describe("Members per page (default 20, max 100)."),
505
+ sort_by: z.enum(["points", "tier_points", "created_at", "recent_activity"]).optional().describe('Sort field (default recent_activity). "points" = current spendable balance (use for top/bottom members by points); "tier_points" = VIP tier progress points; "created_at" = signup date; "recent_activity" = last update.'),
506
+ direction: z.enum(["desc", "asc"]).optional().describe('"desc" (default) = highest/newest first; "asc" = lowest/oldest first.'),
507
+ tier_id: z.string().optional().describe("Only members currently in this VIP tier. Must be a tier `id` from joy_list_tiers, NOT the tier name."),
508
+ include_total_count: z.boolean().optional().describe("Set true when the merchant asks HOW MANY members/customers there are \u2014 returns total_members (exact count of the whole list, respecting tier_id). Combine with limit 1 when only the number is needed. Costs extra database reads; leave unset otherwise."),
509
+ after: z.string().optional().describe("Cursor: a next_after value returned by a previous call. Never invent one, and only reuse it with the SAME sort_by/direction/tier_id that produced it.")
510
+ },
511
+ handler: async (args) => {
512
+ const limit = args.limit ?? 20;
513
+ const sortBy = args.sort_by ?? "recent_activity";
514
+ const direction = args.direction ?? "desc";
515
+ const withTotal = args.include_total_count === true;
516
+ const sort = MEMBER_SORTS[sortBy];
517
+ let envelope;
518
+ try {
519
+ envelope = await ctx.api.request("/customers", {
520
+ query: {
521
+ limit,
522
+ after: args.after,
523
+ order: `${sort.field}_${direction}`,
524
+ tierId: args.tier_id?.trim() || void 0,
525
+ hasCount: withTotal ? true : void 0
526
+ }
527
+ });
528
+ } catch (error) {
529
+ reclassifyCursorError(error, args.after);
530
+ }
531
+ const result = {
532
+ data: envelope.data,
533
+ hasNext: envelope.meta?.pagination?.hasNext === true
534
+ };
535
+ const totalMembers = withTotal ? envelope.meta?.pagination?.total : void 0;
536
+ const members = Array.isArray(result.data) ? result.data : [];
537
+ if (members.length === 0) {
538
+ if (args.after)
539
+ return "No more members after this cursor \u2014 you have reached the end of the list.";
540
+ if (args.tier_id) {
541
+ return `No members are currently in tier "${args.tier_id}". Verify the tier id via joy_list_tiers (pass the id, not the name).`;
542
+ }
543
+ return "This shop has no loyalty members yet.";
544
+ }
545
+ const listKeys = sortBy === "tier_points" || sortBy === "created_at" ? [...MEMBER_LIST_KEYS, sort.field] : MEMBER_LIST_KEYS;
546
+ const shaped = members.map((member) => compact(member, listKeys));
547
+ const lastId = members[members.length - 1]?.id;
548
+ const isDateSort = sortBy === "created_at" || sortBy === "recent_activity";
549
+ const dirWord = direction === "desc" ? isDateSort ? "newest" : "highest" : isDateSort ? "oldest" : "lowest";
550
+ const orderNote = ` sorted by ${sort.label} (${dirWord} first)`;
551
+ const totalNote = typeof totalMembers === "number" ? ` This shop has ${totalMembers} member(s)${args.tier_id ? " in this tier" : ""} in TOTAL.` : "";
552
+ return summaryWithJson(`${members.length} member(s) on this page,${orderNote}${result.hasNext ? " (more available)" : ""}.${totalNote}`, {
553
+ members: shaped,
554
+ has_more: result.hasNext,
555
+ ...typeof totalMembers === "number" ? { total_members: totalMembers } : {},
556
+ ...result.hasNext && lastId ? { next_after: lastId } : {}
557
+ });
558
+ }
559
+ });
560
+ registerReadTool(server, {
561
+ name: "joy_get_member_activity",
562
+ title: "Get a member's point activity",
563
+ 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.',
564
+ inputSchema: {
565
+ ...memberIdentifierSchema,
566
+ limit: z.number().int().min(1).max(50).optional().describe("Transactions per page (default 10, max 50)."),
567
+ after: z.string().optional().describe("Cursor: a next_after value returned by a previous call. Never invent one.")
568
+ },
569
+ handler: async (args) => {
570
+ const limit = args.limit ?? 10;
571
+ const query = { limit, after: args.after };
572
+ if (args.shopify_customer_id) {
573
+ query.shopifyCustomerId = args.shopify_customer_id.trim();
574
+ } else {
575
+ const member = await resolveMember(ctx.api, args);
576
+ query.customerId = String(member.id);
577
+ }
578
+ let transactionsResult;
579
+ try {
580
+ transactionsResult = await ctx.api.getData("/transactions", { query });
581
+ } catch (error) {
582
+ reclassifyCursorError(error, args.after);
583
+ }
584
+ const { data, hasNext } = transactionsResult;
585
+ const activities = Array.isArray(data) ? data : [];
586
+ if (activities.length === 0) {
587
+ return args.after ? "No more activity after this cursor \u2014 you have reached the end of the history." : "This member has no point activity yet.";
588
+ }
589
+ const lastId = activities[activities.length - 1]?.id;
590
+ return summaryWithJson(`${activities.length} point transaction(s)${hasNext ? " (more available)" : ""}.`, {
591
+ activities: activities.map((activity) => compact(activity, ACTIVITY_KEYS)),
592
+ has_more: hasNext,
593
+ ...hasNext && lastId ? { next_after: lastId } : {}
594
+ });
595
+ }
596
+ });
597
+ registerReadTool(server, {
598
+ name: "joy_get_member_rewards",
599
+ title: "Get a member's reward/coupon history",
600
+ 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?".`,
601
+ inputSchema: {
602
+ ...memberIdentifierSchema,
603
+ limit: z.number().int().min(1).max(50).optional().describe("Rewards per page (default 10, max 50)."),
604
+ after: z.string().optional().describe("Cursor: a next_after value returned by a previous call. Never invent one.")
605
+ },
606
+ handler: async (args) => {
607
+ const limit = args.limit ?? 10;
608
+ const query = { limit, after: args.after };
609
+ if (args.shopify_customer_id) {
610
+ query.shopifyCustomerId = args.shopify_customer_id.trim();
611
+ } else {
612
+ const member = await resolveMember(ctx.api, args);
613
+ query.customerId = String(member.id);
614
+ }
615
+ let rewardsResult;
616
+ try {
617
+ rewardsResult = await ctx.api.getData("/rewards", { query });
618
+ } catch (error) {
619
+ reclassifyCursorError(error, args.after);
620
+ }
621
+ const { data, hasNext } = rewardsResult;
622
+ const rewards = Array.isArray(data) ? data : [];
623
+ if (rewards.length === 0) {
624
+ return args.after ? "No more rewards after this cursor \u2014 you have reached the end of the history." : "This member has no redeemed rewards yet.";
625
+ }
626
+ const lastId = rewards[rewards.length - 1]?.id;
627
+ return summaryWithJson(`${rewards.length} reward(s)${hasNext ? " (more available)" : ""}.`, {
628
+ rewards: rewards.map((reward) => compact(reward, REWARD_KEYS)),
629
+ has_more: hasNext,
630
+ ...hasNext && lastId ? { next_after: lastId } : {}
631
+ });
632
+ }
633
+ });
634
+ }
635
+
636
+ // ../mcp-core/build/tools/tiers.js
637
+ import { z as z2 } from "zod";
638
+ var TIER_KEYS = [
639
+ "id",
640
+ "name",
641
+ "targetPoint",
642
+ "targetAmount",
643
+ "entryMethod",
644
+ "members",
645
+ "rateMoney",
646
+ "earnPoint",
647
+ "tierRewards",
648
+ "inactive",
649
+ "isExclusiveTier",
650
+ "systemType"
651
+ ];
652
+ var TIERS_TTL_MS = 15 * 60 * 1e3;
653
+ function getTiersCached(ctx) {
654
+ return ctx.cache.get("tiers", TIERS_TTL_MS, async () => {
655
+ const { data } = await ctx.api.getData("/tiers");
656
+ return Array.isArray(data) ? data : [];
657
+ });
658
+ }
659
+ function registerTierTools(server, ctx) {
660
+ registerReadTool(server, {
661
+ name: "joy_list_tiers",
662
+ title: "List VIP tiers",
663
+ 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.',
664
+ inputSchema: {
665
+ include_member_counts: z2.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.")
666
+ },
667
+ handler: async (args) => {
668
+ if (args.include_member_counts === true) {
669
+ const { data } = await ctx.api.getData("/tiers", { query: { hasCount: true } });
670
+ const tiers2 = Array.isArray(data) ? data : [];
671
+ if (tiers2.length === 0) {
672
+ return "This shop has no VIP tiers configured. Tier-related questions do not apply.";
673
+ }
674
+ const keys = [...TIER_KEYS.filter((key) => key !== "members"), "memberCount"];
675
+ const total = tiers2.reduce((sum, tier) => sum + (Number(tier.memberCount) || 0), 0);
676
+ const top = [...tiers2].sort((a, b) => (Number(b.memberCount) || 0) - (Number(a.memberCount) || 0))[0];
677
+ 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)));
678
+ }
679
+ const tiers = await getTiersCached(ctx);
680
+ if (tiers.length === 0) {
681
+ return "This shop has no VIP tiers configured. Tier-related questions do not apply.";
682
+ }
683
+ return summaryWithJson(`${tiers.length} VIP tier(s) configured.`, tiers.map((tier) => compact(tier, TIER_KEYS)));
684
+ }
685
+ });
686
+ }
687
+ function registerTierWriteTools(server, ctx) {
688
+ registerWriteTool(server, ctx, {
689
+ name: "joy_set_member_tier",
690
+ title: "Set a member's VIP tier",
691
+ description: "WRITE \u2014 manually moves ONE member to a VIP tier (may trigger tier rewards for the member). Find valid tier IDs via joy_list_tiers first. Setting the tier the member already has is a safe no-op. admin_note is recorded on the tier-change activity.",
692
+ inputSchema: {
693
+ ...memberIdentifierSchema,
694
+ tier_id: z2.string().describe("Target tier ID (from joy_list_tiers)."),
695
+ reason: z2.string().min(3).describe("Why the tier is being changed \u2014 REQUIRED, recorded on the tier-change activity."),
696
+ admin_note: z2.string().optional().describe("Optional extra note for the merchant team.")
697
+ },
698
+ destructiveHint: true,
699
+ idempotentHint: true,
700
+ handler: async (args) => {
701
+ const member = await resolveMember(ctx.api, args);
702
+ if (member.tierId === args.tier_id) {
703
+ return `${memberDisplayName(member)} is already in tier ${String(member.tierName ?? args.tier_id)} \u2014 no change made.`;
704
+ }
705
+ const result = await ctx.api.mutateData(`/customers/external/${encodeURIComponent(String(member.shopifyCustomerId))}/tier`, {
706
+ method: "PUT",
707
+ timeoutMs: 15e3,
708
+ body: {
709
+ tierId: args.tier_id,
710
+ source: "mcp",
711
+ // Tier endpoint has no dedicated reason field — the audited note carries it
712
+ adminNote: args.admin_note ? `${args.reason} \u2014 ${args.admin_note}` : args.reason
713
+ }
714
+ });
715
+ const tierName = result?.tierName ? String(result.tierName) : args.tier_id;
716
+ return summaryWithJson(`Moved ${memberDisplayName(member)} to tier "${tierName}".`, compact(result ?? {}, ["id", "shopifyCustomerId", "tierId", "tierName", "tierPoint", "tierUpdatedAt"]));
717
+ }
718
+ });
719
+ }
720
+
721
+ // ../mcp-core/build/tools/programs.js
722
+ import { z as z3 } from "zod";
723
+ var EARNING_KEYS = [
724
+ "id",
725
+ "event",
726
+ "title",
727
+ "status",
728
+ "type",
729
+ "earnBy",
730
+ "rateMoney",
731
+ "earnPoint",
732
+ "earnPointsTiers",
733
+ "conditions",
734
+ "typeMilestone",
735
+ "milestones",
736
+ "startDate",
737
+ "endDate",
738
+ "priority",
739
+ "isDraft",
740
+ "expired"
741
+ ];
742
+ var SPENDING_KEYS = [
743
+ "id",
744
+ "title",
745
+ "type",
746
+ "event",
747
+ "status",
748
+ "spendPoint",
749
+ "earnAmount",
750
+ "redeemType",
751
+ "minSpendPoint",
752
+ "maxSpendPoint",
753
+ "hasMinSpend",
754
+ "hasMaxSpend",
755
+ "expiredAfter",
756
+ "expiredTime",
757
+ "limitRedeem",
758
+ "conditions",
759
+ "priority",
760
+ // Free shipping fields — not covered by earnAmount/redeemType above
761
+ "shippingDiscountType",
762
+ "shippingDiscountAmount",
763
+ "hasLimitShipping",
764
+ "limitShipping",
765
+ "enableMaxCapShipping",
766
+ "maxCapShipping"
767
+ ];
768
+ function registerProgramTools(server, ctx) {
769
+ registerReadTool(server, {
770
+ name: "joy_list_earning_rules",
771
+ title: "List point-earning rules",
772
+ 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.',
773
+ inputSchema: {},
774
+ handler: async () => {
775
+ const { data } = await ctx.api.getData("/programs/earning");
776
+ const programs = Array.isArray(data) ? data : [];
777
+ if (programs.length === 0) {
778
+ return "No earning rules configured \u2014 customers currently have no way to earn points.";
779
+ }
780
+ return summaryWithJson(`${programs.length} earning rule(s) configured.`, programs.map((program) => compact(program, EARNING_KEYS)));
781
+ }
782
+ });
783
+ registerReadTool(server, {
784
+ name: "joy_list_redemption_rules",
785
+ title: "List point-redemption rules",
786
+ 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.',
787
+ inputSchema: {
788
+ include_redeem_counts: z3.boolean().optional().describe("Set true ONLY when the merchant asks about usage/popularity \u2014 adds redeemCount (all-time number of redemptions) to each rule. Leave unset for pure configuration questions; counting costs extra database reads.")
789
+ },
790
+ handler: async (args) => {
791
+ const withCounts = args.include_redeem_counts === true;
792
+ const { data } = await ctx.api.getData("/programs/redemption", {
793
+ query: withCounts ? { hasCount: true } : {}
794
+ });
795
+ const programs = Array.isArray(data) ? data : [];
796
+ if (programs.length === 0) {
797
+ return "No redemption rules configured \u2014 customers currently cannot spend their points.";
798
+ }
799
+ const keys = withCounts ? [...SPENDING_KEYS, "redeemCount"] : SPENDING_KEYS;
800
+ let summary = `${programs.length} redemption rule(s) configured.`;
801
+ if (withCounts) {
802
+ const total = programs.reduce((sum, program) => sum + (Number(program.redeemCount) || 0), 0);
803
+ const top = [...programs].sort((a, b) => (Number(b.redeemCount) || 0) - (Number(a.redeemCount) || 0))[0];
804
+ summary = `${programs.length} redemption rule(s), ${total} redemption(s) all-time \u2014 most redeemed: "${String(top.title)}" (${Number(top.redeemCount) || 0}).`;
805
+ }
806
+ return summaryWithJson(summary, programs.map((program) => compact(program, keys)));
807
+ }
808
+ });
809
+ }
810
+
811
+ // ../mcp-core/build/tools/referrals.js
812
+ import { z as z4 } from "zod";
813
+ var REFERRAL_ACTIVITY_KEYS = [
814
+ "id",
815
+ "email",
816
+ "referralCustomerEmail",
817
+ "statusReferral",
818
+ "couponCode",
819
+ "createdAt"
820
+ ];
821
+ var REFERRAL_PROGRAM_KEYS = [
822
+ "status",
823
+ "title",
824
+ "customerRewardEarnAmount",
825
+ "customerRewardDiscountType",
826
+ "customerRewardExpiredAfter",
827
+ "customerRewardOrderReq",
828
+ "customerRewardOrderReqAmount",
829
+ "isAppliedVipTier",
830
+ "earnPointsTiers",
831
+ "friendRewardEarnAmount",
832
+ "friendRewardDiscountType",
833
+ "friendRewardExpiredAfter",
834
+ "friendRewardOrderReq",
835
+ "friendRewardOrderReqAmount",
836
+ "maxReferralsPerCustomer",
837
+ "referralLimitType",
838
+ "referralEligibilityType",
839
+ "restrictReferralAccessByTags",
840
+ "enableAntiCheat",
841
+ "referralSharingMode",
842
+ "autoRevokeReferrerReward"
843
+ ];
844
+ function formatReferralReward(amount, type) {
845
+ if (amount === void 0 || amount === null)
846
+ return "not configured";
847
+ if (type === "percentage")
848
+ return `${String(amount)}% off`;
849
+ if (type === "point")
850
+ return `${String(amount)} points`;
851
+ if (type === "amount")
852
+ return `${String(amount)} off (fixed amount)`;
853
+ return `${String(amount)} (${String(type ?? "unknown type")})`;
854
+ }
855
+ function registerReferralTools(server, ctx) {
856
+ registerReadTool(server, {
857
+ name: "joy_get_referral_program",
858
+ title: "Get referral program status & configuration",
859
+ 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.',
860
+ inputSchema: {},
861
+ handler: async () => {
862
+ let program;
863
+ try {
864
+ const { data } = await ctx.api.getData("/referrals/program");
865
+ program = data ?? {};
866
+ } catch (error) {
867
+ if (error instanceof JoyApiError && error.kind === "not_found") {
868
+ return "The referral program has never been set up on this shop \u2014 it is OFF; customers cannot refer friends.";
869
+ }
870
+ throw error;
871
+ }
872
+ const enabled = program.status === true;
873
+ const referrerReward = formatReferralReward(program.customerRewardEarnAmount, program.customerRewardDiscountType);
874
+ const friendReward = formatReferralReward(program.friendRewardEarnAmount, program.friendRewardDiscountType);
875
+ return summaryWithJson(`Referral program is ${enabled ? "ENABLED (ON)" : "DISABLED (OFF)"} \u2014 referrer gets ${referrerReward}, referred friend gets ${friendReward}.`, compact(program, REFERRAL_PROGRAM_KEYS));
876
+ }
877
+ });
878
+ registerReadTool(server, {
879
+ name: "joy_get_referral_performance",
880
+ title: "Get referral program performance",
881
+ 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.",
882
+ inputSchema: {
883
+ start_date: z4.string().optional().describe("ISO date (YYYY-MM-DD), inclusive start of the range."),
884
+ end_date: z4.string().optional().describe("ISO date (YYYY-MM-DD), inclusive end of the range.")
885
+ },
886
+ handler: async (args) => {
887
+ const { data } = await ctx.api.getData("/referrals/analytics", {
888
+ query: { startDate: args.start_date, endDate: args.end_date }
889
+ });
890
+ const range = args.start_date || args.end_date ? ` between ${args.start_date ?? "the beginning"} and ${args.end_date ?? "now"}` : "";
891
+ const count = data?.countCouponCodeUsage ?? 0;
892
+ return summaryWithJson(`${count} referral coupon(s) were used${range} (completed referrals).`, compact(data ?? {}));
893
+ }
894
+ });
895
+ registerReadTool(server, {
896
+ name: "joy_get_member_referrals",
897
+ title: "Get one member's referral results",
898
+ 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.",
899
+ inputSchema: memberIdentifierSchema,
900
+ handler: async (args) => {
901
+ const member = await resolveMember(ctx.api, args);
902
+ const memberId = encodeURIComponent(String(member.id));
903
+ const [summary, referrers] = await Promise.all([
904
+ ctx.api.getData(`/referrals/customers/${memberId}/summary`),
905
+ ctx.api.getData(`/referrals/customers/${memberId}/referrers`, {
906
+ query: { limit: 20 }
907
+ })
908
+ ]);
909
+ const activities = Array.isArray(referrers.data) ? referrers.data : [];
910
+ return summaryWithJson(`Referral results for ${String(member.email ?? member.id)} \u2014 ${activities.length} recent referral record(s).`, {
911
+ totals: compact(summary.data ?? {}),
912
+ recent_referrals: activities.map((activity) => compact(activity, REFERRAL_ACTIVITY_KEYS))
913
+ });
914
+ }
915
+ });
916
+ }
917
+ function registerReferralWriteTools(server, ctx) {
918
+ registerWriteTool(server, ctx, {
919
+ name: "joy_create_referral_invite",
920
+ title: "Generate a referral invite link",
921
+ description: "WRITE \u2014 generates a personal referral invite link for an email address. For an EXISTING member this just returns their own link (safe, cheap). For an email that is NOT yet a member, this CREATES A REAL SHOPIFY CUSTOMER RECORD \u2014 irreversible from this tool \u2014 and may trigger sign-up point rewards for them. Double-check the email spelling with the merchant before calling for a non-member; a typo creates a junk customer, not an error. Identify by email only (this can create a new record, so it never accepts shopify_customer_id).",
922
+ inputSchema: {
923
+ email: z4.string().email().describe("Email address to generate the invite link for. Must be a real, verified address \u2014 a typo creates a real customer record, not an error."),
924
+ use_marketing: z4.boolean().optional().describe("Marketing consent for a NEW customer record. Defaults to false (no consent). Only pass true if the person actually opted in to marketing \u2014 this can set real email-marketing-consent on their Shopify customer record.")
925
+ },
926
+ destructiveHint: false,
927
+ idempotentHint: false,
928
+ handler: async (args) => {
929
+ const result = await ctx.api.mutateData("/referrals/invitations", {
930
+ method: "POST",
931
+ timeoutMs: 15e3,
932
+ body: { email: args.email, useMarketing: args.use_marketing }
933
+ });
934
+ const isExisting = result?.isExistingCustomer ? "existing member" : "new prospect";
935
+ return summaryWithJson(`Generated referral invite link for ${args.email} (${isExisting}).`, compact(result ?? {}, ["urlReferral", "referralCode", "isExistingCustomer"]));
936
+ }
937
+ });
938
+ }
939
+
940
+ // ../mcp-core/build/tools/overview.js
941
+ var OVERVIEW_TTL_MS = 15 * 60 * 1e3;
942
+ function registerOverviewTools(server, ctx) {
943
+ registerReadTool(server, {
944
+ name: "joy_get_loyalty_overview",
945
+ title: "Get loyalty program overview",
946
+ 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.',
947
+ inputSchema: {},
948
+ handler: async () => {
949
+ const { shop, settings } = await ctx.cache.get("overview", OVERVIEW_TTL_MS, async () => {
950
+ const [shopResult, settingsResult] = await Promise.all([
951
+ ctx.api.getData("/whoami"),
952
+ ctx.api.getData("/shop/settings")
953
+ ]);
954
+ return {
955
+ shop: shopResult.data ?? {},
956
+ settings: settingsResult.data ?? {}
957
+ };
958
+ });
959
+ const shopName = String(shop.name ?? shop.domain ?? "shop");
960
+ const plan = shop.plan ? ` (plan: ${String(shop.plan)})` : "";
961
+ return summaryWithJson(`Operating on ${shopName}${plan}.`, {
962
+ shop: compact(shop, ["id", "name", "domain", "email", "plan", "currency", "timezone", "isInstalled"]),
963
+ settings: compact(settings)
964
+ });
965
+ }
966
+ });
967
+ }
968
+
969
+ // ../mcp-core/build/tools/analytics.js
970
+ import { z as z5 } from "zod";
971
+ function toDateRangeQuery(args) {
972
+ const query = {};
973
+ if (args.start_date !== void 0)
974
+ query.startDate = args.start_date;
975
+ if (args.end_date !== void 0)
976
+ query.endDate = args.end_date;
977
+ return query;
978
+ }
979
+ function toLoyaltyRevenueQuery(args) {
980
+ const query = { metric: args.metric, ...toDateRangeQuery(args) };
981
+ if (args.period !== void 0)
982
+ query.period = args.period;
983
+ if (args.compare_to_last_year !== void 0)
984
+ query.compareToLastYear = args.compare_to_last_year;
985
+ return query;
986
+ }
987
+ function toTierPerformanceQuery(args) {
988
+ return { metric: args.metric, ...toDateRangeQuery(args) };
989
+ }
990
+ function toV2ReportQuery(args) {
991
+ const query = {
992
+ reportType: args.report_type,
993
+ ...toDateRangeQuery(args)
994
+ };
995
+ if (args.unit !== void 0)
996
+ query.unit = args.unit;
997
+ return query;
998
+ }
999
+ var V2_REPORT_TYPES = [
1000
+ "overview_kpis",
1001
+ "members_overview",
1002
+ "cohort_retention",
1003
+ "participation_rate",
1004
+ "customer_base",
1005
+ "tier_performance",
1006
+ "tier_trend",
1007
+ "tier_transitions",
1008
+ "wallet_adoption",
1009
+ "reward_roi",
1010
+ "points_economy",
1011
+ "points_movement",
1012
+ "top_programs_earned",
1013
+ "top_programs_redeemed",
1014
+ "coupon_usage",
1015
+ "coupon_funnel",
1016
+ "coupon_programs_earned",
1017
+ "coupon_programs_redeemed",
1018
+ "referral_summary",
1019
+ "referral_funnel",
1020
+ "referral_traffic",
1021
+ "referral_conversion",
1022
+ "referred_customer",
1023
+ "order_referred",
1024
+ "sale_referred",
1025
+ "loyalty_impact",
1026
+ "member_comparison",
1027
+ "redeemer_comparison",
1028
+ "revenue_by_channel",
1029
+ "assisted_revenue",
1030
+ "assisted_revenue_orders"
1031
+ ];
1032
+ var V2_REPORT_MENU = `Report menu (pick the closest reportType):
1033
+ - overview_kpis: top-line KPIs \u2014 members, points earned/redeemed, redemption rate, redeem & referral revenue, enrollment & repeat rates, with period-over-period trends. Best FIRST call for broad questions.
1034
+ - members_overview: membership composition, lifecycle funnel, activation/enrollment/retention rates, new members.
1035
+ - cohort_retention: monthly signup-cohort retention matrix (M0..M11).
1036
+ - participation_rate: daily enrollment-rate & redeemer-rate trend.
1037
+ - customer_base: cumulative customer-base growth (members vs guests).
1038
+ - tier_performance: per-VIP-tier member count, AOV, repeat rate, revenue.
1039
+ - tier_trend: member count over time per tier.
1040
+ - tier_transitions: tier upgrades/downgrades/new enrollments/net movement.
1041
+ - wallet_adoption: Apple/Google Wallet card adoption (all-time snapshot).
1042
+ - reward_roi: reward cost vs revenue and ROI KPIs.
1043
+ - points_economy: points earned vs redeemed (and losses) over time.
1044
+ - points_movement: totals & series for earned/redeemed/expired/refunded/adjusted points, balance, liability, redemption rate \u2014 use for "points issued/redeemed in <month>".
1045
+ - top_programs_earned / top_programs_redeemed: program rankings by points earned / redeemed.
1046
+ - coupon_usage: coupons generated vs used, usage rate.
1047
+ - coupon_funnel: generated \u2192 used \u2192 drove an order.
1048
+ - coupon_programs_earned / coupon_programs_redeemed: coupon programs ranked.
1049
+ - referral_summary: referral KPIs and top referrers.
1050
+ - referral_funnel: traffic \u2192 signed up \u2192 purchased.
1051
+ - referral_traffic / referral_conversion / referred_customer: referral clicks, conversion metrics, referred customers over time.
1052
+ - order_referred / sale_referred: orders / revenue driven by referrals over time.
1053
+ - loyalty_impact / member_comparison / redeemer_comparison: members-vs-non-members and redeemers-vs-non comparisons (AOV, repeat rate, revenue, CLV).
1054
+ - revenue_by_channel: loyalty revenue split by sales channel.
1055
+ - assisted_revenue / assisted_revenue_orders: Joy-assisted revenue vs total store revenue / assisted orders detail.`;
1056
+ var LOYALTY_REVENUE_KEYS = [
1057
+ "metric",
1058
+ "period",
1059
+ "value",
1060
+ "previousValue",
1061
+ "deltaPercent",
1062
+ "rows"
1063
+ ];
1064
+ var TIER_PERFORMANCE_KEYS = ["metric", "period", "rows", "topGroup"];
1065
+ var HEALTH_CHECK_KEYS = ["healthScore", "flagCount", "flags", "snapshot"];
1066
+ var reportDateRangeSchema = {
1067
+ start_date: z5.string().optional().describe("ISO date (YYYY-MM-DD), inclusive start of the report window. Defaults to 30 days ago. Effective range max 366 days."),
1068
+ end_date: z5.string().optional().describe("ISO date (YYYY-MM-DD), inclusive end of the report window. Defaults to today.")
1069
+ };
1070
+ 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.";
1071
+ function withScopeGuidance(error) {
1072
+ if (error instanceof JoyApiError && error.status === 403 && /read_reports/i.test(error.message)) {
1073
+ 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);
1074
+ }
1075
+ return error;
1076
+ }
1077
+ function withV2EnablementGuidance(error) {
1078
+ if (error instanceof JoyApiError && error.status === 409 && /Analytics V2 is not enabled/i.test(error.message)) {
1079
+ 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);
1080
+ }
1081
+ return error;
1082
+ }
1083
+ function formatEnableStatus(data) {
1084
+ const status = String(data.status ?? "unknown");
1085
+ if (status === "started") {
1086
+ return "Analytics V2 is now ENABLED and the historical data backfill has started in the background. It can take from a few minutes to a few tens of minutes depending on data volume. Tell the merchant it is underway; call this tool again later to check progress \u2014 analytics answers may be incomplete until it finishes.";
1087
+ }
1088
+ if (status === "running") {
1089
+ const steps = Array.isArray(data.steps) ? data.steps : [];
1090
+ const done = steps.filter((step) => step.status === "completed").length;
1091
+ const progress = steps.length ? ` (${done}/${steps.length} steps completed)` : "";
1092
+ return `The Analytics V2 backfill is STILL RUNNING${progress} \u2014 current step: ${String(data.currentStep ?? "?")}. Do not re-trigger it; tell the merchant it is in progress and analytics numbers may be incomplete until it finishes. Check again in a few minutes.`;
1093
+ }
1094
+ if (status === "failed") {
1095
+ return `The Analytics V2 backfill STOPPED ON AN ERROR at step "${String(data.currentStep ?? "?")}". Historical numbers may be incomplete. Tell the merchant to contact Joy support to resume it \u2014 do not re-trigger it yourself.`;
1096
+ }
1097
+ if (status === "already_enabled") {
1098
+ return "Analytics V2 is already enabled for this shop" + (data.completedAt ? ` (backfill completed ${String(data.completedAt)})` : "") + " \u2014 analytics reports are available via joy_get_analytics_report.";
1099
+ }
1100
+ return `Enable request returned an unrecognized status "${status}" \u2014 report this to Joy support.`;
1101
+ }
1102
+ async function getScopedReport(ctx, path, options = {}) {
1103
+ try {
1104
+ const { data } = await ctx.api.getData(path, options);
1105
+ return data ?? {};
1106
+ } catch (error) {
1107
+ throw withScopeGuidance(error);
1108
+ }
1109
+ }
1110
+ function registerAnalyticsTools(server, ctx) {
1111
+ registerReadTool(server, {
1112
+ name: "joy_get_analytics_report",
1113
+ title: "Loyalty analytics report (Analytics V2)",
1114
+ 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,
1115
+ inputSchema: {
1116
+ report_type: z5.enum(V2_REPORT_TYPES).describe("Which Analytics V2 loyalty report to retrieve (see the menu in the tool description)."),
1117
+ ...reportDateRangeSchema,
1118
+ unit: z5.enum(["day", "month"]).optional().describe("Time grain for trend reports. Defaults by range size (\u2264100 days \u2192 day, else month).")
1119
+ },
1120
+ handler: async (args) => {
1121
+ let report;
1122
+ try {
1123
+ const { data } = await ctx.api.getData("/analytics/report", {
1124
+ query: toV2ReportQuery(args),
1125
+ timeoutMs: 3e4
1126
+ });
1127
+ report = data ?? {};
1128
+ } catch (error) {
1129
+ throw withV2EnablementGuidance(error);
1130
+ }
1131
+ const period = report.period;
1132
+ const header = `Analytics V2 \u2014 ${args.report_type} (${String(period?.fromDate ?? "?")} to ${String(period?.toDate ?? "?")}, unit ${String(report.unit ?? "?")}):`;
1133
+ const summary = typeof report.summary === "string" && report.summary.length > 0 ? report.summary : JSON.stringify(compact(report, ["report"]));
1134
+ return clampText(`${header}
1135
+
1136
+ ${summary}`);
1137
+ }
1138
+ });
1139
+ registerReadTool(server, {
1140
+ name: "joy_get_loyalty_revenue_report",
1141
+ title: "Loyalty revenue impact (Shopify sales data)",
1142
+ 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.",
1143
+ inputSchema: {
1144
+ metric: z5.enum(["total_sales", "aov", "returning_rate", "orders"]).describe("Which revenue metric to compute (see description for definitions)."),
1145
+ period: z5.enum(["last_7d", "last_30d", "last_90d", "last_1y", "this_month"]).optional().describe("Relative reporting period. Default last_30d. Ignored when start_date/end_date are given."),
1146
+ compare_to_last_year: z5.boolean().optional().describe("Add previous-year comparison (previousValue + deltaPercent). total_sales only; ignored with a custom date range."),
1147
+ start_date: z5.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range start. Requires end_date too; max 366 days."),
1148
+ end_date: z5.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range end. Requires start_date too; not in the future.")
1149
+ },
1150
+ handler: async (args) => {
1151
+ const data = await getScopedReport(ctx, "/analytics/loyalty-revenue", {
1152
+ query: toLoyaltyRevenueQuery(args),
1153
+ timeoutMs: 3e4
1154
+ });
1155
+ const summary = typeof data.summary === "string" ? data.summary : `${args.metric} for ${String(data.period ?? "the requested period")}: ${String(data.value ?? "no data")}.`;
1156
+ return summaryWithJson(summary, compact(data, LOYALTY_REVENUE_KEYS));
1157
+ }
1158
+ });
1159
+ registerReadTool(server, {
1160
+ name: "joy_get_tier_performance_breakdown",
1161
+ title: "Sales performance by VIP tier",
1162
+ 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,
1163
+ inputSchema: {
1164
+ metric: z5.enum(["total_sales", "aov", "orders_per_customer", "spend_per_customer"]).describe("Which per-tier metric to compute."),
1165
+ start_date: z5.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range start. Requires end_date too; max 366 days. Omit both for all-time."),
1166
+ end_date: z5.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom range end. Requires start_date too.")
1167
+ },
1168
+ handler: async (args) => {
1169
+ let data;
1170
+ try {
1171
+ data = await getScopedReport(ctx, "/analytics/tier-performance", {
1172
+ query: toTierPerformanceQuery(args),
1173
+ timeoutMs: 3e4
1174
+ });
1175
+ } catch (error) {
1176
+ if (error instanceof JoyApiError && error.status === 422) {
1177
+ 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);
1178
+ }
1179
+ throw error;
1180
+ }
1181
+ const top = data.topGroup;
1182
+ const summary = typeof data.summary === "string" ? data.summary : `${args.metric} by tier${top ? ` \u2014 top: ${String(top.group)} (${String(top.value)})` : ""}.`;
1183
+ return summaryWithJson(summary, compact(data, TIER_PERFORMANCE_KEYS));
1184
+ }
1185
+ });
1186
+ registerReadTool(server, {
1187
+ name: "joy_get_loyalty_health_check",
1188
+ title: "Loyalty program health check",
1189
+ 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.`,
1190
+ inputSchema: {
1191
+ start_date: z5.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom current-window start. Requires end_date too."),
1192
+ end_date: z5.string().optional().describe("ISO date (YYYY-MM-DD) \u2014 custom current-window end. Requires start_date too.")
1193
+ },
1194
+ handler: async (args) => {
1195
+ const { data } = await ctx.api.getData("/analytics/health-check", {
1196
+ query: toDateRangeQuery(args),
1197
+ timeoutMs: 2e4
1198
+ });
1199
+ const report = data ?? {};
1200
+ const summary = typeof report.summary === "string" ? report.summary : `Loyalty health score: ${String(report.healthScore ?? "?")}/100.`;
1201
+ return summaryWithJson(summary, compact(report, HEALTH_CHECK_KEYS));
1202
+ }
1203
+ });
1204
+ }
1205
+ function registerAnalyticsWriteTools(server, ctx) {
1206
+ registerWriteTool(server, ctx, {
1207
+ name: "joy_enable_analytics_v2",
1208
+ title: "Switch the shop to Analytics V2 (starts data backfill)",
1209
+ description: 'WRITE \u2014 enables the new Analytics V2 for this shop and starts the historical data backfill in the background. Call this when joy_get_analytics_report returned the "shop is still on Analytics V1" error AND the merchant has EXPLICITLY agreed to switch \u2014 never call it unprompted: it changes the default Analytics experience in their Joy admin and runs a data migration that can take from a few minutes to a few tens of minutes. IDEMPOTENT and doubles as a STATUS CHECK: calling it again never restarts a running backfill \u2014 it returns progress (running/failed/already enabled) instead, so use it to answer "is the migration done yet?".',
1210
+ inputSchema: {
1211
+ merchant_confirmed: z5.literal(true).describe("Must be true \u2014 pass it ONLY after the merchant explicitly agreed to switch to the new Analytics. If they have not been asked yet, ask first instead of calling this tool.")
1212
+ },
1213
+ destructiveHint: false,
1214
+ idempotentHint: true,
1215
+ handler: async () => {
1216
+ const { data } = await ctx.api.mutateData("/analytics/enable-v2", {
1217
+ method: "POST",
1218
+ timeoutMs: 2e4,
1219
+ body: {}
1220
+ });
1221
+ return formatEnableStatus(data ?? {});
1222
+ }
1223
+ });
1224
+ }
1225
+
1226
+ // ../mcp-core/build/tools/points.js
1227
+ import { z as z6 } from "zod";
1228
+
1229
+ // ../mcp-core/build/tools/writeSupport.js
1230
+ import { randomUUID } from "node:crypto";
1231
+ function resolveIdempotencyKey(provided) {
1232
+ const trimmed = provided?.trim();
1233
+ return trimmed || randomUUID();
1234
+ }
1235
+ function rethrowWithRetryKey(error, idempotencyKey) {
1236
+ if (error instanceof JoyApiError && (error.kind === "timeout" || error.kind === "network" || error.kind === "server")) {
1237
+ 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);
1238
+ }
1239
+ throw error;
1240
+ }
1241
+ function rethrowRedeemUncertain(error) {
1242
+ if (error instanceof JoyApiError && (error.kind === "timeout" || error.kind === "network" || error.kind === "server")) {
1243
+ 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);
1244
+ }
1245
+ throw error;
1246
+ }
1247
+ function rethrowRefundUncertain(error) {
1248
+ if (error instanceof JoyApiError && (error.kind === "timeout" || error.kind === "network" || error.kind === "server")) {
1249
+ 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);
1250
+ }
1251
+ throw error;
1252
+ }
1253
+ function buildBalancePreview(params) {
1254
+ const { memberLabel, currentBalance, delta, toolName } = params;
1255
+ const resulting = currentBalance + delta;
1256
+ const negativeWarning = resulting < 0 ? ` WARNING: this would drive the balance NEGATIVE (${resulting.toLocaleString("en-US")}) \u2014 confirm with the merchant that this is intended.` : "";
1257
+ return {
1258
+ summary: `DRY RUN \u2014 no points were changed. ${memberLabel}: ${currentBalance.toLocaleString("en-US")} \u2192 ${resulting.toLocaleString("en-US")} (${delta >= 0 ? "+" : ""}${delta.toLocaleString("en-US")}).${negativeWarning}`,
1259
+ payload: {
1260
+ dry_run: true,
1261
+ member: memberLabel,
1262
+ current_balance: currentBalance,
1263
+ change: delta,
1264
+ resulting_balance: resulting,
1265
+ ...resulting < 0 ? { warning: "resulting balance would be NEGATIVE" } : {},
1266
+ next_step: `If this is correct, call ${toolName} again with the same arguments plus apply: true.`
1267
+ }
1268
+ };
1269
+ }
1270
+ function needsDeductPreview(params) {
1271
+ const { points, threshold, currentBalance, apply } = params;
1272
+ if (apply === true)
1273
+ return false;
1274
+ return points > threshold || currentBalance - points < 0;
1275
+ }
1276
+
1277
+ // ../mcp-core/build/tools/points.js
1278
+ var MUTATION_RESULT_KEYS = [
1279
+ "id",
1280
+ "type",
1281
+ "event",
1282
+ "oldPoint",
1283
+ "newPoint",
1284
+ "pointChange",
1285
+ "earnPoint",
1286
+ "spendPoint",
1287
+ "reason",
1288
+ "source",
1289
+ "createdAt"
1290
+ ];
1291
+ var writeArgsSchema = {
1292
+ ...memberIdentifierSchema,
1293
+ reason: z6.string().min(3).describe("Why this change is being made \u2014 REQUIRED, recorded permanently in the audit trail."),
1294
+ admin_note: z6.string().optional().describe("Optional internal note for the merchant team."),
1295
+ idempotency_key: z6.string().max(255).optional().describe("Only pass this when RETRYING an uncertain outcome: reuse the exact key from the previous error message so the backend replays instead of applying twice. Omit for a new operation.")
1296
+ };
1297
+ async function executePointMutation(params) {
1298
+ const { ctx, path, member, point, reason, adminNote, idempotencyKey } = params;
1299
+ try {
1300
+ return await ctx.api.mutateData(path, {
1301
+ method: "POST",
1302
+ timeoutMs: 15e3,
1303
+ headers: { "Idempotency-Key": idempotencyKey },
1304
+ body: {
1305
+ shopifyCustomerId: String(member.shopifyCustomerId),
1306
+ point,
1307
+ reason,
1308
+ source: "mcp",
1309
+ ...adminNote ? { adminNote } : {}
1310
+ }
1311
+ });
1312
+ } catch (error) {
1313
+ rethrowWithRetryKey(error, idempotencyKey);
1314
+ }
1315
+ }
1316
+ function registerPointWriteTools(server, ctx) {
1317
+ registerWriteTool(server, ctx, {
1318
+ name: "joy_award_points",
1319
+ title: "Award points to a member",
1320
+ description: "WRITE \u2014 gives points to ONE loyalty member (points are merchant money; the host will ask the user to approve). Identify the member by email or Shopify customer ID. reason is required and goes to the permanent audit trail. The operation is protected by an idempotency key, so a confirmed retry with the returned key can never double-award.",
1321
+ inputSchema: {
1322
+ ...writeArgsSchema,
1323
+ points: z6.number().int().positive().describe("Points to add (positive integer).")
1324
+ },
1325
+ destructiveHint: false,
1326
+ idempotentHint: false,
1327
+ handler: async (args) => {
1328
+ const member = await resolveMember(ctx.api, args);
1329
+ const idempotencyKey = resolveIdempotencyKey(args.idempotency_key);
1330
+ const result = await executePointMutation({
1331
+ ctx,
1332
+ path: "/transactions/points/award",
1333
+ member,
1334
+ point: args.points,
1335
+ reason: args.reason,
1336
+ adminNote: args.admin_note,
1337
+ idempotencyKey
1338
+ });
1339
+ return summaryWithJson(`Awarded ${formatPoints(args.points)} points to ${memberDisplayName(member)} \u2014 balance ${formatPoints(result.oldPoint)} \u2192 ${formatPoints(result.newPoint)}.`, compact(result, MUTATION_RESULT_KEYS));
1340
+ }
1341
+ });
1342
+ registerWriteTool(server, ctx, {
1343
+ name: "joy_deduct_points",
1344
+ title: "Deduct points from a member",
1345
+ description: `WRITE, destructive \u2014 removes points from ONE member (points are merchant money; cannot be undone except by a compensating award). reason is required and audited. Deductions larger than ${ctx.config.deductDryRunThreshold} points return a DRY-RUN preview first \u2014 call again with apply: true to execute. Idempotency-protected.`,
1346
+ inputSchema: {
1347
+ ...writeArgsSchema,
1348
+ points: z6.number().int().positive().describe("Points to remove (positive integer)."),
1349
+ apply: z6.boolean().optional().describe(`Required (true) to EXECUTE a deduction above ${ctx.config.deductDryRunThreshold} points after reviewing the dry-run preview.`)
1350
+ },
1351
+ destructiveHint: true,
1352
+ idempotentHint: false,
1353
+ handler: async (args) => {
1354
+ const member = await resolveMember(ctx.api, args);
1355
+ const currentBalance = Number(member.point ?? 0);
1356
+ const mustPreview = needsDeductPreview({
1357
+ points: args.points,
1358
+ threshold: ctx.config.deductDryRunThreshold,
1359
+ currentBalance,
1360
+ apply: args.apply
1361
+ });
1362
+ if (mustPreview) {
1363
+ const preview = buildBalancePreview({
1364
+ memberLabel: memberDisplayName(member),
1365
+ currentBalance,
1366
+ delta: -args.points,
1367
+ toolName: "joy_deduct_points"
1368
+ });
1369
+ return summaryWithJson(preview.summary, preview.payload);
1370
+ }
1371
+ const idempotencyKey = resolveIdempotencyKey(args.idempotency_key);
1372
+ const result = await executePointMutation({
1373
+ ctx,
1374
+ path: "/transactions/points/deduct",
1375
+ member,
1376
+ point: args.points,
1377
+ reason: args.reason,
1378
+ adminNote: args.admin_note,
1379
+ idempotencyKey
1380
+ });
1381
+ return summaryWithJson(`Deducted ${formatPoints(args.points)} points from ${memberDisplayName(member)} \u2014 balance ${formatPoints(result.oldPoint)} \u2192 ${formatPoints(result.newPoint)}.`, compact(result, MUTATION_RESULT_KEYS));
1382
+ }
1383
+ });
1384
+ registerWriteTool(server, ctx, {
1385
+ name: "joy_adjust_points",
1386
+ title: "Adjust a member's points balance",
1387
+ description: "WRITE, destructive \u2014 corrects a member's balance by a signed delta (points are merchant money). ALWAYS dry-runs first: the initial call returns a preview of current \u2192 resulting balance WITHOUT changing anything; call again with apply: true to execute. reason is required and audited. Idempotency-protected.",
1388
+ inputSchema: {
1389
+ ...writeArgsSchema,
1390
+ points_delta: z6.number().int().refine((value) => value !== 0, { message: "points_delta must not be 0" }).describe("Signed change: positive adds, negative removes."),
1391
+ apply: z6.boolean().optional().describe("Must be true to EXECUTE. Omit (or false) to get the dry-run preview.")
1392
+ },
1393
+ destructiveHint: true,
1394
+ idempotentHint: false,
1395
+ handler: async (args) => {
1396
+ const member = await resolveMember(ctx.api, args);
1397
+ const currentBalance = Number(member.point ?? 0);
1398
+ if (args.apply !== true) {
1399
+ const preview = buildBalancePreview({
1400
+ memberLabel: memberDisplayName(member),
1401
+ currentBalance,
1402
+ delta: args.points_delta,
1403
+ toolName: "joy_adjust_points"
1404
+ });
1405
+ return summaryWithJson(preview.summary, preview.payload);
1406
+ }
1407
+ const idempotencyKey = resolveIdempotencyKey(args.idempotency_key);
1408
+ const result = await executePointMutation({
1409
+ ctx,
1410
+ path: "/transactions/points/adjust",
1411
+ member,
1412
+ point: args.points_delta,
1413
+ reason: args.reason,
1414
+ adminNote: args.admin_note,
1415
+ idempotencyKey
1416
+ });
1417
+ return summaryWithJson(`Adjusted ${memberDisplayName(member)}'s balance by ${args.points_delta >= 0 ? "+" : "-"}${formatPoints(Math.abs(args.points_delta))} \u2014 ${formatPoints(result.oldPoint)} \u2192 ${formatPoints(result.newPoint)}.`, compact(result, MUTATION_RESULT_KEYS));
1418
+ }
1419
+ });
1420
+ registerWriteTool(server, ctx, {
1421
+ name: "joy_redeem_reward",
1422
+ title: "Redeem points for a reward",
1423
+ description: "WRITE, destructive \u2014 spends a member's points on a redemption program and returns the issued coupon code. Find the program_id via joy_list_redemption_rules first. Can take up to ~30s (a Shopify discount is minted inline). NO server-side retry protection yet: if the outcome is uncertain (timeout), verify via joy_get_member_activity BEFORE any retry \u2014 a blind retry can double-spend.",
1424
+ inputSchema: {
1425
+ ...memberIdentifierSchema,
1426
+ program_id: z6.string().describe("Redemption program ID (from joy_list_redemption_rules)."),
1427
+ redeem_points: z6.number().int().positive().describe("Points to spend on this redemption.")
1428
+ },
1429
+ destructiveHint: true,
1430
+ idempotentHint: false,
1431
+ handler: async (args) => {
1432
+ const member = await resolveMember(ctx.api, args);
1433
+ let result;
1434
+ try {
1435
+ result = await ctx.api.mutateData("/redeem", {
1436
+ method: "POST",
1437
+ timeoutMs: 3e4,
1438
+ body: {
1439
+ programId: args.program_id,
1440
+ customerId: String(member.id),
1441
+ redeemPoint: args.redeem_points
1442
+ }
1443
+ });
1444
+ } catch (error) {
1445
+ rethrowRedeemUncertain(error);
1446
+ }
1447
+ const discount = result?.discount ?? {};
1448
+ const customer = result?.customer ?? {};
1449
+ if (!discount.code) {
1450
+ 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.");
1451
+ }
1452
+ return summaryWithJson(`Redeemed ${formatPoints(args.redeem_points)} points from ${memberDisplayName(member)} for coupon "${String(discount.code)}" \u2014 balance now ${formatPoints(customer.point)}.`, {
1453
+ coupon: compact(discount, ["code", "title", "expiredAt"]),
1454
+ member_balance: customer.point
1455
+ });
1456
+ }
1457
+ });
1458
+ }
1459
+
1460
+ // ../mcp-core/build/tools/rewards.js
1461
+ import { z as z7 } from "zod";
1462
+ function registerRewardWriteTools(server, ctx) {
1463
+ registerWriteTool(server, ctx, {
1464
+ name: "joy_refund_coupon",
1465
+ title: "Refund / revoke / cancel a redeemed coupon",
1466
+ description: 'WRITE \u2014 refunds, revokes, cancels, voids, or undoes a coupon a member redeemed with points (use this for ANY request to "revoke", "disable", "cancel", "void", or "undo" a point-redeemed coupon/discount code). IMPORTANT: this AUTOMATICALLY credits the spent points back to the member as part of the refund (a refund_point_redeem activity is recorded with your reason) \u2014 do NOT also call joy_award_points afterward, that would double-credit them. Only coupons that came from a POINT REDEMPTION can be refunded this way (not coupons from other sources, e.g. manual discounts). Identify the member by email or Shopify customer ID \u2014 do not ask the caller for a raw customer ID.',
1467
+ inputSchema: {
1468
+ ...memberIdentifierSchema,
1469
+ coupon_code: z7.string().describe("The discount code to refund (from joy_get_member_rewards or joy_get_member_activity)."),
1470
+ reason: z7.string().min(3).describe("Why this coupon is being refunded \u2014 REQUIRED, recorded in the audit trail.")
1471
+ },
1472
+ destructiveHint: true,
1473
+ idempotentHint: false,
1474
+ handler: async (args) => {
1475
+ const member = await resolveMember(ctx.api, args);
1476
+ try {
1477
+ await ctx.api.mutateData("/rewards/coupons/refund", {
1478
+ method: "POST",
1479
+ timeoutMs: 15e3,
1480
+ body: {
1481
+ discountCode: args.coupon_code,
1482
+ shopifyCustomerId: String(member.shopifyCustomerId),
1483
+ reason: args.reason
1484
+ }
1485
+ });
1486
+ } catch (error) {
1487
+ rethrowRefundUncertain(error);
1488
+ }
1489
+ const updated = await resolveMember(ctx.api, args).catch(() => member);
1490
+ return summaryWithJson(`Refunded coupon "${args.coupon_code}" for ${memberDisplayName(member)} \u2014 points were automatically credited back, balance now ${formatPoints(updated.point)}. Do not award points separately for this.`, compact(updated, ["id", "shopifyCustomerId", "point"]));
1491
+ }
1492
+ });
1493
+ }
1494
+
1495
+ // ../mcp-core/build/tools/retention.js
1496
+ import { z as z8 } from "zod";
1497
+ var POINTS_EXPIRING_KEYS = [
1498
+ "customerId",
1499
+ "name",
1500
+ "email",
1501
+ "shopifyCustomerId",
1502
+ "point",
1503
+ "expiredAt",
1504
+ "daysUntilExpiry"
1505
+ ];
1506
+ var TIER_DROP_RISK_KEYS = [
1507
+ "customerId",
1508
+ "name",
1509
+ "email",
1510
+ "shopifyCustomerId",
1511
+ "tierId",
1512
+ "tierName",
1513
+ "lastOrderAt",
1514
+ "daysUntilDemotion",
1515
+ "demotion"
1516
+ ];
1517
+ function toRetentionRiskQuery(args) {
1518
+ const query = {};
1519
+ if (args.signal !== void 0)
1520
+ query.signal = args.signal;
1521
+ if (args.window_days !== void 0)
1522
+ query.windowDays = args.window_days;
1523
+ if (args.tier_id !== void 0)
1524
+ query.tierId = args.tier_id;
1525
+ if (args.limit !== void 0)
1526
+ query.limit = args.limit;
1527
+ return query;
1528
+ }
1529
+ function registerRetentionTools(server, ctx) {
1530
+ registerReadTool(server, {
1531
+ name: "joy_get_retention_risk_members",
1532
+ title: "Find members at risk of losing points or dropping a VIP tier",
1533
+ 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.',
1534
+ inputSchema: {
1535
+ signal: z8.enum(["points_expiring", "tier_drop_risk", "both"]).optional().describe('Which retention-risk signal to check. "points_expiring" for expiring-points questions, "tier_drop_risk" for tier-downgrade-risk questions, "both" (default) when the merchant asks broadly (e.g. "who is at risk of leaving?").'),
1536
+ window_days: z8.number().int().min(1).max(30).optional().describe("Lookahead for BOTH signals (default 7, hard-capped at 30 \u2014 larger values are clamped, not rejected): points_expiring = points expiring within this many days; tier_drop_risk = members who are already demotable or become demotable within this many days."),
1537
+ tier_id: z8.string().optional().describe("For tier_drop_risk: scope to one VIP tier ID (from joy_list_tiers). Omit to check every non-exclusive tier above the lowest \u2014 the result limit is split evenly across tiers."),
1538
+ limit: z8.number().int().min(1).max(50).optional().describe("Max members per signal (default 20, hard-capped at 50).")
1539
+ },
1540
+ handler: async (args) => {
1541
+ const { data } = await ctx.api.getData("/customers/retention-risk", {
1542
+ query: toRetentionRiskQuery(args)
1543
+ });
1544
+ const result = data ?? {};
1545
+ const pointsExpiring = Array.isArray(result.pointsExpiring) ? result.pointsExpiring : [];
1546
+ const tierDropRisk = Array.isArray(result.tierDropRisk) ? result.tierDropRisk : [];
1547
+ const signal = typeof result.signal === "string" ? result.signal : args.signal ?? "both";
1548
+ const tierDropRiskNote = typeof result.tierDropRiskNote === "string" ? result.tierDropRiskNote : void 0;
1549
+ const scheduledReset = result.tierScheduledReset && typeof result.tierScheduledReset === "object" ? result.tierScheduledReset : void 0;
1550
+ const scheduledResetLine = scheduledReset ? `store-wide scheduled tier reset: ALL members reset to the lowest tier on ${scheduledReset.resetAt} (in ${scheduledReset.daysUntilReset} day(s))` : void 0;
1551
+ if (pointsExpiring.length === 0 && tierDropRisk.length === 0) {
1552
+ if (scheduledResetLine) {
1553
+ return `No per-member retention-risk list for the requested signal(s), but note the ${scheduledResetLine}.`;
1554
+ }
1555
+ return tierDropRiskNote ? `No members currently at retention risk for the requested signal(s). Note on the tier signal: ${tierDropRiskNote}` : "No members currently at retention risk for the requested signal(s).";
1556
+ }
1557
+ const summaryParts = [];
1558
+ if (signal !== "tier_drop_risk") {
1559
+ summaryParts.push(`${pointsExpiring.length} member(s) with points expiring soon`);
1560
+ }
1561
+ if (signal !== "points_expiring") {
1562
+ summaryParts.push(tierDropRiskNote ? `tier signal not applicable (${tierDropRiskNote})` : `${tierDropRisk.length} member(s) at risk of dropping a VIP tier`);
1563
+ if (scheduledResetLine)
1564
+ summaryParts.push(scheduledResetLine);
1565
+ }
1566
+ return summaryWithJson(`${summaryParts.join(", ")}.`, {
1567
+ signal,
1568
+ pointsExpiring: pointsExpiring.map((item) => compact(item, POINTS_EXPIRING_KEYS)),
1569
+ tierDropRisk: tierDropRisk.map((item) => compact(item, TIER_DROP_RISK_KEYS)),
1570
+ ...tierDropRiskNote ? { tier_drop_risk_note: tierDropRiskNote } : {},
1571
+ ...scheduledReset ? { tier_scheduled_reset: scheduledReset } : {},
1572
+ has_more: result.hasMore === true
1573
+ });
1574
+ }
1575
+ });
1576
+ }
1577
+
1578
+ // ../mcp-core/build/tools/risk.js
1579
+ var REDEMPTION_KEYS = ["id", "couponCode", "programTitle", "createdAt"];
1580
+ var FLAG_LABELS = {
1581
+ referral_email_similarity: "referred someone with a highly similar email address",
1582
+ referral_device_match: "shares a last-known IP/device value with their referrer",
1583
+ referee_had_prior_order: "already had an order before being referred (not actually a new customer)",
1584
+ high_daily_redemptions: "redeemed an unusually high number of rewards today"
1585
+ };
1586
+ function describeFlags(flags) {
1587
+ return flags.map((flag) => FLAG_LABELS[flag] ?? flag).join("; ");
1588
+ }
1589
+ function shapeSupportingData(supportingData) {
1590
+ const shaped = {};
1591
+ if (Array.isArray(supportingData.similarEmailReferrals)) {
1592
+ shaped.similarEmailReferrals = supportingData.similarEmailReferrals.map((item) => ({ email: item.email }));
1593
+ }
1594
+ if (supportingData.referralDeviceMatch) {
1595
+ const source = supportingData.referralDeviceMatch;
1596
+ shaped.referralDeviceMatch = { matchedReferrerDevice: source.matchedReferrerDevice === true };
1597
+ }
1598
+ if (supportingData.priorOrderBeforeReferral) {
1599
+ const source = supportingData.priorOrderBeforeReferral;
1600
+ shaped.priorOrderBeforeReferral = { latestOrderActivity: source.latestOrderActivity };
1601
+ }
1602
+ if (Array.isArray(supportingData.todaysRedemptions)) {
1603
+ shaped.todaysRedemptions = supportingData.todaysRedemptions.map((item) => compact(item, REDEMPTION_KEYS));
1604
+ }
1605
+ return shaped;
1606
+ }
1607
+ function registerRiskTools(server, ctx) {
1608
+ registerReadTool(server, {
1609
+ name: "joy_investigate_member_risk",
1610
+ title: "Investigate a member for referral/redemption abuse risk",
1611
+ 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.`,
1612
+ inputSchema: memberIdentifierSchema,
1613
+ handler: async (args) => {
1614
+ const member = await resolveMember(ctx.api, args);
1615
+ const who = memberDisplayName(member);
1616
+ const { data } = await ctx.api.getData(`/customers/${encodeURIComponent(String(member.id))}/risk-assessment`);
1617
+ const result = data ?? {};
1618
+ const flags = Array.isArray(result.flags) ? result.flags : [];
1619
+ const supportingData = result.supportingData ?? {};
1620
+ const limitations = Array.isArray(result.limitations) ? result.limitations : [];
1621
+ if (flags.length === 0) {
1622
+ return summaryWithJson(`No risk flags for ${who} \u2014 none of the checked signals fired.`, {
1623
+ flags: [],
1624
+ limitations
1625
+ });
1626
+ }
1627
+ return summaryWithJson(`${who}: ${flags.length} risk flag(s) \u2014 ${describeFlags(flags)}.`, {
1628
+ flags,
1629
+ supportingData: shapeSupportingData(supportingData),
1630
+ limitations
1631
+ });
1632
+ }
1633
+ });
1634
+ }
1635
+
1636
+ // ../mcp-core/build/tools/orderExplain.js
1637
+ import { z as z9 } from "zod";
1638
+ var RULE_KEYS = ["programId", "title", "priority", "matched", "earnedPoints", "reasons"];
1639
+ function describeRule(rule) {
1640
+ const title = typeof rule.title === "string" ? rule.title : String(rule.programId ?? "rule");
1641
+ if (rule.matched === null)
1642
+ return `${title}: not evaluated (a higher-priority rule stopped processing)`;
1643
+ if (rule.matched === true)
1644
+ return `${title}: matched, +${rule.earnedPoints ?? 0} pts`;
1645
+ const reasons = Array.isArray(rule.reasons) ? rule.reasons.join("; ") : "";
1646
+ return `${title}: did not match${reasons ? ` (${reasons})` : ""}`;
1647
+ }
1648
+ function registerOrderExplainTools(server, ctx) {
1649
+ registerReadTool(server, {
1650
+ name: "joy_explain_order_points",
1651
+ title: "Explain why an order did or didn't earn points",
1652
+ 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.",
1653
+ inputSchema: {
1654
+ order_id: z9.string().describe('The order to explain \u2014 either the internal Shopify order ID (numeric) or the order name as shown to the merchant, e.g. "#1234".')
1655
+ },
1656
+ handler: async (args) => {
1657
+ const { data } = await ctx.api.getData(`/orders/${encodeURIComponent(args.order_id)}/points-explanation`);
1658
+ const result = data ?? {};
1659
+ const explainSource = typeof result.explainSource === "string" ? result.explainSource : "replay";
1660
+ const recordedAt = typeof result.recordedAt === "string" ? result.recordedAt : null;
1661
+ const sourceNote = explainSource === "recorded" ? ` [from the recorded decision-time trace${recordedAt ? `, ${recordedAt}` : ""}]` : " [reconstructed against the current order state]";
1662
+ if (result.blockedByGate) {
1663
+ const blockedRules = Array.isArray(result.rules) ? result.rules : [];
1664
+ return summaryWithJson(`This order did not earn points: ${result.blockedByGate}${sourceNote}`, {
1665
+ blockedByGate: result.blockedByGate,
1666
+ rules: blockedRules.map((rule) => compact(rule, RULE_KEYS)),
1667
+ totalEarnedPoints: 0,
1668
+ explainSource,
1669
+ recordedAt
1670
+ });
1671
+ }
1672
+ const rules = Array.isArray(result.rules) ? result.rules : [];
1673
+ const totalEarnedPoints = typeof result.totalEarnedPoints === "number" ? result.totalEarnedPoints : 0;
1674
+ const maxTheoreticalPoints = typeof result.maxTheoreticalPoints === "number" ? result.maxTheoreticalPoints : 0;
1675
+ const cappedBy = result.cappedBy ?? null;
1676
+ const summaryLines = [
1677
+ `Order earned ${totalEarnedPoints} point(s)${cappedBy ? ` (capped down from ${maxTheoreticalPoints} by the global earning limit)` : ""}.${sourceNote}`,
1678
+ ...rules.map((rule) => `- ${describeRule(rule)}`)
1679
+ ];
1680
+ return summaryWithJson(summaryLines.join("\n"), {
1681
+ blockedByGate: null,
1682
+ rules: rules.map((rule) => compact(rule, RULE_KEYS)),
1683
+ totalEarnedPoints,
1684
+ maxTheoreticalPoints,
1685
+ cappedBy,
1686
+ explainSource,
1687
+ recordedAt
1688
+ });
1689
+ }
1690
+ });
1691
+ }
1692
+
1693
+ // ../mcp-core/build/tools/activityAudit.js
1694
+ import { z as z10 } from "zod";
1695
+ var AUDIT_GROUPS = [
1696
+ "tier_downgraded",
1697
+ "tier_upgraded",
1698
+ "points_earned",
1699
+ "points_redeemed",
1700
+ "points_refunded",
1701
+ "points_expired",
1702
+ "points_adjusted",
1703
+ "referral",
1704
+ "members_joined"
1705
+ ];
1706
+ var AUDIT_ITEM_KEYS = [
1707
+ "type",
1708
+ "customerId",
1709
+ "email",
1710
+ "shopifyCustomerId",
1711
+ "point",
1712
+ "orderId",
1713
+ "orderName",
1714
+ "programId",
1715
+ "programTitle",
1716
+ "couponCode",
1717
+ "oldTierName",
1718
+ "newTierName",
1719
+ "referralCustomerEmail",
1720
+ "source",
1721
+ "createdAt"
1722
+ ];
1723
+ function toActivityAuditQuery(args) {
1724
+ const query = { group: args.group };
1725
+ if (args.program_id !== void 0)
1726
+ query.programId = args.program_id;
1727
+ if (args.from_date !== void 0)
1728
+ query.fromDate = args.from_date;
1729
+ if (args.to_date !== void 0)
1730
+ query.toDate = args.to_date;
1731
+ if (args.limit !== void 0)
1732
+ query.limit = args.limit;
1733
+ return query;
1734
+ }
1735
+ function registerActivityAuditTools(server, ctx) {
1736
+ registerReadTool(server, {
1737
+ name: "joy_list_activities",
1738
+ title: "List loyalty activities across members (audit by activity group)",
1739
+ 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".',
1740
+ inputSchema: {
1741
+ group: z10.enum(AUDIT_GROUPS).describe('Which activity group to audit. Pick by merchant intent: "who dropped a tier" \u2192 tier_downgraded; "who redeemed (program A)" \u2192 points_redeemed (+ program_id); "who earned points from orders" \u2192 points_earned; "who referred people" \u2192 referral.'),
1742
+ program_id: z10.string().optional().describe("Scope to one program (from joy_list_earning_rules / joy_list_redemption_rules), e.g. redemptions of one specific reward. Applied as a post-filter \u2014 a sparse program in a busy shop may fill fewer rows than `limit`."),
1743
+ from_date: z10.string().optional().describe("Start of the period, ISO date (e.g. 2026-07-01). Default: 30 days before to_date."),
1744
+ to_date: z10.string().optional().describe("End of the period, ISO date. Default: now."),
1745
+ limit: z10.number().int().min(1).max(50).optional().describe("Max rows (default 20, hard-capped at 50).")
1746
+ },
1747
+ handler: async (args) => {
1748
+ const { data } = await ctx.api.getData("/activities/audit", {
1749
+ query: toActivityAuditQuery(args)
1750
+ });
1751
+ const result = data ?? {};
1752
+ const items = Array.isArray(result.items) ? result.items : [];
1753
+ if (items.length === 0) {
1754
+ return `No "${args.group}" activities found between ${result.fromDate} and ${result.toDate}${args.program_id ? ` for program ${args.program_id}` : ""}.`;
1755
+ }
1756
+ return summaryWithJson(`${items.length} "${args.group}" activit${items.length === 1 ? "y" : "ies"} between ${result.fromDate} and ${result.toDate}${result.hasMore === true ? " (more exist beyond the cap \u2014 narrow the range to see the rest)" : ""}.`, {
1757
+ group: result.group,
1758
+ from_date: result.fromDate,
1759
+ to_date: result.toDate,
1760
+ items: items.map((item) => compact(item, AUDIT_ITEM_KEYS)),
1761
+ has_more: result.hasMore === true
1762
+ });
1763
+ }
1764
+ });
1765
+ }
1766
+
1767
+ // ../mcp-core/build/tools/tierUpgrade.js
1768
+ import { z as z11 } from "zod";
1769
+ var UPGRADE_ITEM_KEYS = [
1770
+ "customerId",
1771
+ "name",
1772
+ "email",
1773
+ "shopifyCustomerId",
1774
+ "tierId",
1775
+ "tierName",
1776
+ "tierPoint",
1777
+ "nextTierId",
1778
+ "nextTierName",
1779
+ "nextTierTarget",
1780
+ "pointsToNextTier"
1781
+ ];
1782
+ function toTierUpgradeQuery(args) {
1783
+ const query = {};
1784
+ if (args.tier_id !== void 0)
1785
+ query.tierId = args.tier_id;
1786
+ if (args.margin_points !== void 0)
1787
+ query.marginPoints = args.margin_points;
1788
+ if (args.limit !== void 0)
1789
+ query.limit = args.limit;
1790
+ return query;
1791
+ }
1792
+ function registerTierUpgradeTools(server, ctx) {
1793
+ registerReadTool(server, {
1794
+ name: "joy_get_tier_upgrade_candidates",
1795
+ title: "Find members close to reaching the next VIP tier",
1796
+ 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.',
1797
+ inputSchema: {
1798
+ tier_id: z11.string().optional().describe("Scope to members currently in this VIP tier ID (from joy_list_tiers). Omit to check every climbable tier \u2014 the result limit is split evenly across tiers."),
1799
+ margin_points: z11.number().min(1).optional().describe(`How close (in the tier program's entry metric) counts as "near the next tier". Default: 10% of the next tier's threshold. Use an explicit value when the merchant names one ("within 100 points of Gold").`),
1800
+ limit: z11.number().int().min(1).max(50).optional().describe("Max members returned (default 20, hard-capped at 50).")
1801
+ },
1802
+ handler: async (args) => {
1803
+ const { data } = await ctx.api.getData("/customers/tier-upgrade-candidates", {
1804
+ query: toTierUpgradeQuery(args)
1805
+ });
1806
+ const result = data ?? {};
1807
+ const items = Array.isArray(result.items) ? result.items : [];
1808
+ if (items.length === 0) {
1809
+ return "No members are currently within the margin of their next VIP tier. Consider widening margin_points, or check the tier setup with joy_list_tiers.";
1810
+ }
1811
+ return summaryWithJson(`${items.length} member(s) close to reaching their next VIP tier (closest first)${result.hasMore === true ? " \u2014 more exist beyond the cap" : ""}.`, {
1812
+ items: items.map((item) => compact(item, UPGRADE_ITEM_KEYS)),
1813
+ has_more: result.hasMore === true
1814
+ });
1815
+ }
1816
+ });
1817
+ }
1818
+
1819
+ // ../mcp-core/build/prompts.js
1820
+ import { z as z12 } from "zod";
1821
+ function registerAllPrompts(server) {
1822
+ server.registerPrompt("support_missing_points", {
1823
+ title: "Investigate missing points for an order",
1824
+ 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.",
1825
+ argsSchema: {
1826
+ email: z12.string().describe("Customer's email address"),
1827
+ order_number: z12.string().optional().describe("Order name/number the customer says earned nothing, e.g. #1234")
1828
+ }
1829
+ }, ({ email, order_number }) => ({
1830
+ messages: [
1831
+ {
1832
+ role: "user",
1833
+ content: {
1834
+ type: "text",
1835
+ text: [
1836
+ `A customer (${email}) reports they did not receive loyalty points${order_number ? ` for order ${order_number}` : ""}. Investigate before changing anything:`,
1837
+ "",
1838
+ "1. Look up the member with joy_find_member \u2014 flag anything unusual (disabled state, guest, recently created).",
1839
+ `2. Check joy_get_member_activity for an earn entry matching the order${order_number ? "" : " they describe (ask me for the order number if you cannot identify it)"}.`,
1840
+ order_number ? `3. Run joy_explain_order_points for ${order_number} \u2014 it re-runs the actual earning engine and reports which gate or rule condition blocked earning, if any.` : "3. Once the order is identified, run joy_explain_order_points on it \u2014 it re-runs the actual earning engine and reports which gate or rule condition blocked earning, if any.",
1841
+ "4. Conclude with exactly one of: (a) points were already awarded (show the activity entry), (b) points were correctly not awarded (show the blocking gate/rule \u2014 cite its configured threshold or condition, do not paraphrase from memory), or (c) points are genuinely missing.",
1842
+ "5. Only for case (c): state the exact number of points owed and propose a joy_award_points call with a reason that names the order and the root cause. Wait for my explicit confirmation before executing \u2014 never award in the same turn as the diagnosis.",
1843
+ "",
1844
+ "Caveat to include when you rely on joy_explain_order_points: it recomputes against the order as it exists now \u2014 if the order changed after the original webhook (e.g. partial refund), the explanation reflects the current state, not the historical moment."
1845
+ ].join("\n")
1846
+ }
1847
+ }
1848
+ ]
1849
+ }));
1850
+ server.registerPrompt("program_review", {
1851
+ title: "Loyalty program review (health + revenue + tiers + referral)",
1852
+ 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.",
1853
+ argsSchema: {
1854
+ period: z12.string().optional().describe("Reporting period, e.g. 'last 30 days', 'last month', 'this quarter'. Defaults to last 30 days.")
1855
+ }
1856
+ }, ({ period }) => ({
1857
+ messages: [
1858
+ {
1859
+ role: "user",
1860
+ content: {
1861
+ type: "text",
1862
+ text: [
1863
+ `Review my loyalty program for ${period ?? "the last 30 days"}. Run, in parallel where possible:`,
1864
+ "",
1865
+ "1. joy_get_loyalty_health_check \u2014 overall score and flags.",
1866
+ "2. joy_get_loyalty_revenue_report \u2014 revenue impact (Shopify sales data).",
1867
+ "3. joy_get_tier_performance_breakdown \u2014 which VIP tier earns its keep.",
1868
+ "4. joy_get_analytics_report with report_type referral_summary, and points_movement for the same period.",
1869
+ "",
1870
+ "Data-source rules (important):",
1871
+ "- joy_get_loyalty_revenue_report and joy_get_tier_performance_breakdown read Shopify sales data (ShopifyQL); joy_get_analytics_report reads the Joy event log (BigQuery). These measure differently \u2014 label the source of every figure and never combine numbers across the two sources into one derived metric.",
1872
+ "- If a ShopifyQL tool returns a 403 about the read_reports scope, relay its grant instructions instead of substituting numbers from the other source.",
1873
+ "- If any response carries a backfill WARNING, repeat that caveat next to the affected numbers \u2014 do not present them as final.",
1874
+ "",
1875
+ "Format: a 3-sentence executive summary first, then anomalies worth my attention (with the flag or figure that triggered each), then exactly one concrete recommended action. Skip metrics that are unremarkable."
1876
+ ].join("\n")
1877
+ }
1878
+ }
1879
+ ]
1880
+ }));
1881
+ server.registerPrompt("investigate_member", {
1882
+ title: "Investigate a member for abuse risk",
1883
+ 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.",
1884
+ argsSchema: {
1885
+ email: z12.string().describe("Email of the member to investigate")
1886
+ }
1887
+ }, ({ email }) => ({
1888
+ messages: [
1889
+ {
1890
+ role: "user",
1891
+ content: {
1892
+ type: "text",
1893
+ text: [
1894
+ `I suspect member ${email} may be abusing the loyalty program (referral self-dealing, unusual redemptions, or similar). Investigate:`,
1895
+ "",
1896
+ "1. joy_find_member \u2014 profile basics and state.",
1897
+ "2. joy_investigate_member_risk \u2014 the pre-defined abuse flags. For every flag that fires, show the concrete supporting data the tool returns; do not add suspicions of your own beyond the flags.",
1898
+ "3. joy_get_member_activity and joy_get_member_referrals \u2014 recent point movements and referral results, to give the flags context.",
1899
+ "",
1900
+ "Mandatory caveat in your answer: device/IP matching compares the MOST RECENT values only \u2014 there is no full history over time \u2014 so these flags are advisory signals to support my judgement, not proof of abuse.",
1901
+ "",
1902
+ "End with: which flags fired (if any), what specific records I should open in the Joy admin to verify each one, and your read on severity. Do NOT deduct points, refund coupons, or take any corrective action \u2014 this session is investigation only; I will decide separately."
1903
+ ].join("\n")
1904
+ }
1905
+ }
1906
+ ]
1907
+ }));
1908
+ }
1909
+
1910
+ // ../mcp-core/build/registry.js
1911
+ function registerAllTools(server, ctx) {
1912
+ registerOverviewTools(server, ctx);
1913
+ registerAnalyticsTools(server, ctx);
1914
+ registerMemberTools(server, ctx);
1915
+ registerTierTools(server, ctx);
1916
+ registerProgramTools(server, ctx);
1917
+ registerReferralTools(server, ctx);
1918
+ registerRetentionTools(server, ctx);
1919
+ registerRiskTools(server, ctx);
1920
+ registerOrderExplainTools(server, ctx);
1921
+ registerActivityAuditTools(server, ctx);
1922
+ registerTierUpgradeTools(server, ctx);
1923
+ if (ctx.config.writesEnabled) {
1924
+ registerPointWriteTools(server, ctx);
1925
+ registerTierWriteTools(server, ctx);
1926
+ registerRewardWriteTools(server, ctx);
1927
+ registerReferralWriteTools(server, ctx);
1928
+ registerAnalyticsWriteTools(server, ctx);
1929
+ 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.");
1930
+ }
1931
+ }
1932
+
1933
+ // src/index.ts
1934
+ var require2 = createRequire(import.meta.url);
1935
+ var { version } = require2("../package.json");
1936
+ async function main() {
1937
+ const config = loadConfig();
1938
+ const api = new JoyApi(config);
1939
+ const server = new McpServer({ name: "joy-loyalty", version });
1940
+ registerAllTools(server, { api, config, cache: new TtlCache() });
1941
+ registerAllPrompts(server);
1942
+ const transport = new StdioServerTransport();
1943
+ await server.connect(transport);
1944
+ log.info(
1945
+ `joy-mcp v${version} ready \u2014 mode=${config.mode}, tools=${config.writesEnabled ? "read+write" : "read-only"}, api=${config.apiBaseUrl}`
1946
+ );
1947
+ }
1948
+ main().catch((error) => {
1949
+ if (error instanceof ConfigError) {
1950
+ log.error(error.message);
1951
+ } else {
1952
+ log.error(`fatal: ${error instanceof Error ? error.message : String(error)}`);
1953
+ }
1954
+ process.exit(1);
1955
+ });