@joy-loyalty/mcp 0.3.8 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -1
- package/build/config.js +11 -0
- package/build/index.js +2144 -384
- package/package.json +1 -1
package/build/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
5
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
8
|
|
|
@@ -67,17 +68,368 @@ function loadConfig(env = process.env) {
|
|
|
67
68
|
deductDryRunThreshold
|
|
68
69
|
};
|
|
69
70
|
}
|
|
71
|
+
var DEFAULT_TELEMETRY_MODE = "headers";
|
|
72
|
+
function telemetryMode(env = process.env) {
|
|
73
|
+
const mode = (env.JOY_MCP_TELEMETRY ?? "").trim().toLowerCase();
|
|
74
|
+
const doNotTrack = (env.DO_NOT_TRACK ?? "").trim().toLowerCase();
|
|
75
|
+
if (["1", "true"].includes(doNotTrack) || ["off", "false", "0"].includes(mode)) return "off";
|
|
76
|
+
if (mode === "on") return "full";
|
|
77
|
+
return DEFAULT_TELEMETRY_MODE;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ../mcp-core/build/telemetry/callContext.js
|
|
81
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
82
|
+
|
|
83
|
+
// ../mcp-core/build/logger.js
|
|
84
|
+
var log = {
|
|
85
|
+
info(message) {
|
|
86
|
+
console.error(`[joy-mcp] ${message}`);
|
|
87
|
+
},
|
|
88
|
+
error(message) {
|
|
89
|
+
console.error(`[joy-mcp:error] ${message}`);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ../mcp-core/build/telemetry/schema.js
|
|
94
|
+
var SCHEMA_VERSION = 1;
|
|
95
|
+
var UUID = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$";
|
|
96
|
+
var SHOP_ID = "^[A-Za-z0-9]{1,40}$";
|
|
97
|
+
var SHOP_DOMAIN = "^[a-z0-9][a-z0-9.-]{0,254}$";
|
|
98
|
+
var PLAN = "^[A-Za-z0-9_. -]{1,64}$";
|
|
99
|
+
var VERSION = "^[A-Za-z0-9._+-]{1,64}$";
|
|
100
|
+
var TOOL = "^[a-z0-9_]{1,64}$";
|
|
101
|
+
var ERROR_CODE = "^[A-Za-z][A-Za-z0-9_]{0,63}$";
|
|
102
|
+
var CLIENT_NAME = "^[\\x20-\\x7E]{1,64}$";
|
|
103
|
+
var CLIENT_VERSION = "^[A-Za-z0-9._+-]{1,32}$";
|
|
104
|
+
var CAPABILITIES = "^[a-z][a-zA-Z0-9_]{0,31}(,[a-z][a-zA-Z0-9_]{0,31}){0,9}$";
|
|
105
|
+
var MAX_MS = 36e5;
|
|
106
|
+
var MCP = ["remote", "stdio"];
|
|
107
|
+
var REMOTE = ["remote"];
|
|
108
|
+
var STDIO = ["stdio"];
|
|
109
|
+
var BACKEND = ["backend"];
|
|
110
|
+
var OUTCOMES = ["success", "tool_error", "rejected", "internal_error", "cancelled"];
|
|
111
|
+
var ERROR_KINDS = [
|
|
112
|
+
// JoyApiError kinds (client/joyApi.ts ErrorKind)
|
|
113
|
+
"auth",
|
|
114
|
+
"plan_gate",
|
|
115
|
+
"forbidden",
|
|
116
|
+
"rate_limit",
|
|
117
|
+
"not_found",
|
|
118
|
+
"validation",
|
|
119
|
+
"conflict",
|
|
120
|
+
"server",
|
|
121
|
+
"timeout",
|
|
122
|
+
"network",
|
|
123
|
+
// handler / toolkit
|
|
124
|
+
"exception",
|
|
125
|
+
"store_mismatch",
|
|
126
|
+
"writes_disabled",
|
|
127
|
+
"config_writes_disabled",
|
|
128
|
+
// rejected before the handler ran
|
|
129
|
+
"invalid_input",
|
|
130
|
+
"tool_not_exposed",
|
|
131
|
+
"unknown_tool",
|
|
132
|
+
// recorder
|
|
133
|
+
"unclassified",
|
|
134
|
+
"jsonrpc_error",
|
|
135
|
+
"duplicate_request_id",
|
|
136
|
+
"client_cancelled",
|
|
137
|
+
"no_response"
|
|
138
|
+
];
|
|
139
|
+
function envelope(event, origins) {
|
|
140
|
+
return {
|
|
141
|
+
event: { type: "string", required: true, transports: origins, enum: [event] },
|
|
142
|
+
schema_version: { type: "integer", required: true, transports: origins, min: SCHEMA_VERSION, max: SCHEMA_VERSION },
|
|
143
|
+
event_id: { type: "string", required: true, transports: origins, pattern: UUID }
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
var MCP_IDENTITY = {
|
|
147
|
+
transport: { type: "string", required: true, transports: MCP, server_stamped: true, enum: ["remote", "stdio"] },
|
|
148
|
+
server_version: { type: "string", required: true, transports: MCP, pattern: VERSION },
|
|
149
|
+
shop_id: { type: "string", required: true, transports: MCP, server_stamped: true, pattern: SHOP_ID },
|
|
150
|
+
shop_domain: { type: "string", transports: MCP, server_stamped: true, pattern: SHOP_DOMAIN },
|
|
151
|
+
joy_plan: { type: "string", transports: MCP, server_stamped: true, pattern: PLAN },
|
|
152
|
+
shopify_plan: { type: "string", transports: MCP, server_stamped: true, pattern: PLAN },
|
|
153
|
+
auth_mode: { type: "string", required: true, transports: MCP, server_stamped: true, enum: ["oauth", "api_key", "private_bot"] },
|
|
154
|
+
access_scope: { type: "string", required: true, transports: MCP, server_stamped: true, enum: ["full", "read"] },
|
|
155
|
+
oauth_client_id: { type: "string", transports: REMOTE, pattern: "^[\\x21-\\x7E]{1,512}$" },
|
|
156
|
+
connection_id: { type: "string", transports: REMOTE, pattern: UUID },
|
|
157
|
+
user_id: { type: "string", transports: REMOTE, pattern: "^[A-Za-z0-9_-]{1,64}$" },
|
|
158
|
+
session_id: { type: "string", transports: STDIO, pattern: UUID },
|
|
159
|
+
connector_mode: { type: "string", transports: REMOTE, enum: ["soft", "strict"] }
|
|
160
|
+
};
|
|
161
|
+
var TELEMETRY_SCHEMA = {
|
|
162
|
+
schema_version: SCHEMA_VERSION,
|
|
163
|
+
ingest_events: ["mcp.tool_call", "mcp.session_start"],
|
|
164
|
+
forbidden_keys: [
|
|
165
|
+
"args",
|
|
166
|
+
"arguments",
|
|
167
|
+
"params",
|
|
168
|
+
"input",
|
|
169
|
+
"result",
|
|
170
|
+
"content",
|
|
171
|
+
"text",
|
|
172
|
+
"prompt",
|
|
173
|
+
"sql",
|
|
174
|
+
"query",
|
|
175
|
+
"message",
|
|
176
|
+
"severity",
|
|
177
|
+
"model_message",
|
|
178
|
+
"error_message",
|
|
179
|
+
"path",
|
|
180
|
+
"url",
|
|
181
|
+
"store_name",
|
|
182
|
+
"email",
|
|
183
|
+
"ip",
|
|
184
|
+
"user_agent",
|
|
185
|
+
"secret",
|
|
186
|
+
"secret_key",
|
|
187
|
+
"token",
|
|
188
|
+
"authorization"
|
|
189
|
+
],
|
|
190
|
+
events: {
|
|
191
|
+
"mcp.tool_call": {
|
|
192
|
+
fields: {
|
|
193
|
+
...envelope("mcp.tool_call", MCP),
|
|
194
|
+
...MCP_IDENTITY,
|
|
195
|
+
mcp_client_name: { type: "string", transports: STDIO, pattern: CLIENT_NAME },
|
|
196
|
+
mcp_client_version: { type: "string", transports: STDIO, pattern: CLIENT_VERSION },
|
|
197
|
+
call_id: { type: "string", required: true, transports: MCP, pattern: UUID },
|
|
198
|
+
tool: { type: "string", required: true, transports: MCP, pattern: TOOL },
|
|
199
|
+
tool_kind: { type: "string", required: true, transports: MCP, enum: ["read", "write", "config_write", "unknown"] },
|
|
200
|
+
outcome: { type: "string", required: true, transports: MCP, enum: OUTCOMES },
|
|
201
|
+
error_kind: { type: "string", transports: MCP, enum: ERROR_KINDS },
|
|
202
|
+
error_code: { type: "string", transports: MCP, pattern: ERROR_CODE },
|
|
203
|
+
http_status: { type: "integer", transports: MCP, min: 100, max: 599 },
|
|
204
|
+
duration_ms: { type: "integer", required: true, transports: MCP, min: 0, max: MAX_MS },
|
|
205
|
+
pre_ms: { type: "integer", transports: REMOTE, min: 0, max: MAX_MS },
|
|
206
|
+
api_calls: { type: "integer", required: true, transports: MCP, min: 0, max: 1e4 },
|
|
207
|
+
api_ms: { type: "integer", required: true, transports: MCP, min: 0, max: MAX_MS },
|
|
208
|
+
result_bytes: { type: "integer", required: true, transports: MCP, min: 0, max: 1e9 }
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
"mcp.session_start": {
|
|
212
|
+
fields: {
|
|
213
|
+
...envelope("mcp.session_start", MCP),
|
|
214
|
+
...MCP_IDENTITY,
|
|
215
|
+
mcp_client_name: { type: "string", transports: MCP, pattern: CLIENT_NAME },
|
|
216
|
+
mcp_client_version: { type: "string", transports: MCP, pattern: CLIENT_VERSION },
|
|
217
|
+
protocol_version: { type: "string", transports: MCP, pattern: "^\\d{4}-\\d{2}-\\d{2}$" },
|
|
218
|
+
client_capabilities: { type: "string", transports: MCP, pattern: CAPABILITIES },
|
|
219
|
+
tools_exposed: { type: "integer", transports: MCP, min: 0, max: 1e4 }
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
"api.request": {
|
|
223
|
+
fields: {
|
|
224
|
+
...envelope("api.request", BACKEND),
|
|
225
|
+
source: { type: "string", required: true, transports: BACKEND, enum: ["mcp", "direct_api", "private_bot", "internal"] },
|
|
226
|
+
call_id: { type: "string", transports: BACKEND, pattern: UUID },
|
|
227
|
+
tool: { type: "string", transports: BACKEND, pattern: TOOL },
|
|
228
|
+
transport: { type: "string", transports: BACKEND, enum: ["remote", "stdio"] },
|
|
229
|
+
server_version: { type: "string", transports: BACKEND, pattern: VERSION },
|
|
230
|
+
mcp_client_name: { type: "string", transports: BACKEND, pattern: CLIENT_NAME },
|
|
231
|
+
session_id: { type: "string", transports: BACKEND, pattern: UUID },
|
|
232
|
+
shop_verified: { type: "boolean", required: true, transports: BACKEND },
|
|
233
|
+
shop_id: { type: "string", transports: BACKEND, pattern: SHOP_ID },
|
|
234
|
+
claimed_shop_id: { type: "string", transports: BACKEND, pattern: SHOP_ID },
|
|
235
|
+
shop_domain: { type: "string", transports: BACKEND, pattern: SHOP_DOMAIN },
|
|
236
|
+
joy_plan: { type: "string", transports: BACKEND, pattern: PLAN },
|
|
237
|
+
shopify_plan: { type: "string", transports: BACKEND, pattern: PLAN },
|
|
238
|
+
auth_mode: { type: "string", transports: BACKEND, enum: ["api_key", "private_bot"] },
|
|
239
|
+
access_scope: { type: "string", transports: BACKEND, enum: ["full", "read"] },
|
|
240
|
+
access_policy: {
|
|
241
|
+
type: "string",
|
|
242
|
+
transports: BACKEND,
|
|
243
|
+
enum: ["plan_quota", "unlimited_plan", "manual_grant", "shop_override", "private_bot", "no_access"]
|
|
244
|
+
},
|
|
245
|
+
method: { type: "string", required: true, transports: BACKEND, enum: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] },
|
|
246
|
+
route: { type: "string", transports: BACKEND, pattern: "^/[A-Za-z0-9/:_.()*-]{0,199}$" },
|
|
247
|
+
http_status: { type: "integer", required: true, transports: BACKEND, min: 100, max: 599 },
|
|
248
|
+
error_code: { type: "string", transports: BACKEND, pattern: ERROR_CODE },
|
|
249
|
+
response_error: { type: "boolean", transports: BACKEND },
|
|
250
|
+
duration_ms: { type: "integer", required: true, transports: BACKEND, min: 0, max: MAX_MS },
|
|
251
|
+
bq_jobs: { type: "integer", transports: BACKEND, min: 0, max: 1e4 },
|
|
252
|
+
bq_bytes_processed: { type: "integer", transports: BACKEND, min: 0, max: 1e15 },
|
|
253
|
+
bq_bytes_billed_est: { type: "integer", transports: BACKEND, min: 0, max: 1e15 }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// ../mcp-core/build/telemetry/validate.js
|
|
260
|
+
var patternCache = /* @__PURE__ */ new Map();
|
|
261
|
+
function regexFor(pattern) {
|
|
262
|
+
let regex = patternCache.get(pattern);
|
|
263
|
+
if (!regex) {
|
|
264
|
+
regex = new RegExp(pattern);
|
|
265
|
+
patternCache.set(pattern, regex);
|
|
266
|
+
}
|
|
267
|
+
return regex;
|
|
268
|
+
}
|
|
269
|
+
function isValidValue(spec, value) {
|
|
270
|
+
if (spec.type === "boolean")
|
|
271
|
+
return typeof value === "boolean";
|
|
272
|
+
if (spec.type === "integer") {
|
|
273
|
+
if (typeof value !== "number" || !Number.isInteger(value))
|
|
274
|
+
return false;
|
|
275
|
+
if (spec.min !== void 0 && value < spec.min)
|
|
276
|
+
return false;
|
|
277
|
+
return spec.max === void 0 || value <= spec.max;
|
|
278
|
+
}
|
|
279
|
+
if (typeof value !== "string")
|
|
280
|
+
return false;
|
|
281
|
+
if (spec.maxLength !== void 0 && value.length > spec.maxLength)
|
|
282
|
+
return false;
|
|
283
|
+
if (spec.enum && !spec.enum.includes(value))
|
|
284
|
+
return false;
|
|
285
|
+
return !spec.pattern || regexFor(spec.pattern).test(value);
|
|
286
|
+
}
|
|
287
|
+
function own(table, key) {
|
|
288
|
+
return Object.prototype.hasOwnProperty.call(table, key) ? table[key] : void 0;
|
|
289
|
+
}
|
|
290
|
+
function isApplicable(spec, transport) {
|
|
291
|
+
return !transport || spec.transports.includes(transport);
|
|
292
|
+
}
|
|
293
|
+
function validateEvent(schema, event, options) {
|
|
294
|
+
const errors = [];
|
|
295
|
+
const cleaned = {};
|
|
296
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
297
|
+
return { ok: false, event: cleaned, errors: ["not_an_object"] };
|
|
298
|
+
}
|
|
299
|
+
const record = event;
|
|
300
|
+
const spec = typeof record.event === "string" ? own(schema.events, record.event) : void 0;
|
|
301
|
+
if (!spec)
|
|
302
|
+
return { ok: false, event: cleaned, errors: ["unknown_event"] };
|
|
303
|
+
for (const key of schema.forbidden_keys) {
|
|
304
|
+
if (Object.prototype.hasOwnProperty.call(record, key))
|
|
305
|
+
errors.push(`forbidden:${key}`);
|
|
306
|
+
}
|
|
307
|
+
for (const [key, value] of Object.entries(record)) {
|
|
308
|
+
const field = own(spec.fields, key);
|
|
309
|
+
if (!field || !isApplicable(field, options.transport) || value === void 0)
|
|
310
|
+
continue;
|
|
311
|
+
if (isValidValue(field, value)) {
|
|
312
|
+
cleaned[key] = value;
|
|
313
|
+
} else {
|
|
314
|
+
errors.push(`invalid:${key}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
for (const [key, field] of Object.entries(spec.fields)) {
|
|
318
|
+
if (!field.required || !isApplicable(field, options.transport))
|
|
319
|
+
continue;
|
|
320
|
+
if (options.mode === "client" && field.server_stamped)
|
|
321
|
+
continue;
|
|
322
|
+
if (!(key in cleaned) && !errors.includes(`invalid:${key}`))
|
|
323
|
+
errors.push(`missing:${key}`);
|
|
324
|
+
}
|
|
325
|
+
return { ok: errors.length === 0, event: cleaned, errors };
|
|
326
|
+
}
|
|
327
|
+
function sanitizeString(schema, eventName, field, value) {
|
|
328
|
+
const eventSpec = own(schema.events, eventName);
|
|
329
|
+
const spec = eventSpec ? own(eventSpec.fields, field) : void 0;
|
|
330
|
+
if (!spec || spec.type !== "string" || typeof value !== "string")
|
|
331
|
+
return void 0;
|
|
332
|
+
return isValidValue(spec, value) ? value : void 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ../mcp-core/build/telemetry/callContext.js
|
|
336
|
+
var als = new AsyncLocalStorage();
|
|
337
|
+
var reported = /* @__PURE__ */ new Set();
|
|
338
|
+
function reportOnce(message) {
|
|
339
|
+
if (reported.has(message))
|
|
340
|
+
return;
|
|
341
|
+
reported.add(message);
|
|
342
|
+
try {
|
|
343
|
+
log.error(message);
|
|
344
|
+
} catch {
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function telemetrySafe(label, action) {
|
|
348
|
+
try {
|
|
349
|
+
return action();
|
|
350
|
+
} catch {
|
|
351
|
+
reportOnce(`Telemetry ${label} failed`);
|
|
352
|
+
return void 0;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function currentCall() {
|
|
356
|
+
return telemetrySafe("context", () => als.getStore());
|
|
357
|
+
}
|
|
358
|
+
function update(action) {
|
|
359
|
+
telemetrySafe("context", () => {
|
|
360
|
+
const record = currentCall();
|
|
361
|
+
if (record && !record.closed)
|
|
362
|
+
action(record);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
function markEntered() {
|
|
366
|
+
update((record) => {
|
|
367
|
+
record.entered = true;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
function setOutcome(value, kind) {
|
|
371
|
+
update((record) => {
|
|
372
|
+
record.outcome = value;
|
|
373
|
+
delete record.error_kind;
|
|
374
|
+
delete record.error_code;
|
|
375
|
+
delete record.http_status;
|
|
376
|
+
if (kind)
|
|
377
|
+
record.error_kind = kind;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
function markSuccess() {
|
|
381
|
+
setOutcome("success");
|
|
382
|
+
}
|
|
383
|
+
function markRejected(kind) {
|
|
384
|
+
setOutcome("rejected", kind);
|
|
385
|
+
}
|
|
386
|
+
function markInternalError() {
|
|
387
|
+
setOutcome("internal_error", "exception");
|
|
388
|
+
}
|
|
389
|
+
function markToolError(error) {
|
|
390
|
+
update((record) => {
|
|
391
|
+
setOutcome("tool_error");
|
|
392
|
+
const kind = sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", "error_kind", error.kind);
|
|
393
|
+
record.error_kind = kind ?? "unclassified";
|
|
394
|
+
const code = sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", "error_code", error.serverCode);
|
|
395
|
+
if (code)
|
|
396
|
+
record.error_code = code;
|
|
397
|
+
const status = error.status;
|
|
398
|
+
if (typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599) {
|
|
399
|
+
record.http_status = status;
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
function apiStart() {
|
|
404
|
+
update((record) => {
|
|
405
|
+
record.api_calls++;
|
|
406
|
+
if (record.api_inflight++ === 0)
|
|
407
|
+
record.api_busy_since = record.now();
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
function apiEnd() {
|
|
411
|
+
update((record) => {
|
|
412
|
+
if (record.api_inflight === 0)
|
|
413
|
+
return;
|
|
414
|
+
if (--record.api_inflight !== 0)
|
|
415
|
+
return;
|
|
416
|
+
record.api_ms_acc += Math.max(0, record.now() - (record.api_busy_since ?? record.now()));
|
|
417
|
+
delete record.api_busy_since;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
70
420
|
|
|
71
421
|
// ../mcp-core/build/client/joyApi.js
|
|
72
422
|
var JoyApiError = class extends Error {
|
|
73
423
|
kind;
|
|
74
424
|
modelMessage;
|
|
75
425
|
status;
|
|
76
|
-
|
|
426
|
+
serverCode;
|
|
427
|
+
constructor(kind, modelMessage, status, serverCode) {
|
|
77
428
|
super(modelMessage);
|
|
78
429
|
this.kind = kind;
|
|
79
430
|
this.modelMessage = modelMessage;
|
|
80
431
|
this.status = status;
|
|
432
|
+
this.serverCode = serverCode;
|
|
81
433
|
this.name = "JoyApiError";
|
|
82
434
|
}
|
|
83
435
|
};
|
|
@@ -110,20 +462,20 @@ function extractServerCode(body) {
|
|
|
110
462
|
}
|
|
111
463
|
return void 0;
|
|
112
464
|
}
|
|
113
|
-
function classifyError(status, serverMessage, retryAfter, credentialHint) {
|
|
465
|
+
function classifyError(status, serverMessage, retryAfter, credentialHint, serverCode) {
|
|
114
466
|
const detail = serverMessage ? ` Server said: "${serverMessage}".` : "";
|
|
115
467
|
if (status === 401) {
|
|
116
|
-
return new JoyApiError("auth", `Authentication failed \u2014 the app key or credential is wrong.${detail} This is a server configuration problem; do NOT retry. Ask the merchant to re-check ${credentialHint} in the MCP config.`, status);
|
|
468
|
+
return new JoyApiError("auth", `Authentication failed \u2014 the app key or credential is wrong.${detail} This is a server configuration problem; do NOT retry. Ask the merchant to re-check ${credentialHint} in the MCP config.`, status, serverCode);
|
|
117
469
|
}
|
|
118
470
|
if (status === 403) {
|
|
119
471
|
const text = (serverMessage ?? "").toLowerCase();
|
|
120
472
|
if (text.includes("plan")) {
|
|
121
|
-
return new JoyApiError("plan_gate", `The shop's subscription plan does not include REST API access (Advanced plan or higher is required).${detail} Do NOT retry \u2014 the merchant must upgrade their Joy plan first.`, status);
|
|
473
|
+
return new JoyApiError("plan_gate", `The shop's subscription plan does not include REST API access (Advanced plan or higher is required).${detail} Do NOT retry \u2014 the merchant must upgrade their Joy plan first.`, status, serverCode);
|
|
122
474
|
}
|
|
123
|
-
return new JoyApiError("forbidden", `This operation is not permitted with the current credential (read-only key or insufficient scope).${detail} Do NOT retry with the same credential.`, status);
|
|
475
|
+
return new JoyApiError("forbidden", `This operation is not permitted with the current credential (read-only key or insufficient scope).${detail} Do NOT retry with the same credential.`, status, serverCode);
|
|
124
476
|
}
|
|
125
477
|
if (status === 402) {
|
|
126
|
-
return new JoyApiError("plan_gate", `The shop's plan does not include REST API or MCP access.${detail} Do NOT retry \u2014 tell the merchant to upgrade their Joy plan; the response's upgrade_url links straight to their subscription page.`, status);
|
|
478
|
+
return new JoyApiError("plan_gate", `The shop's plan does not include REST API or MCP access.${detail} Do NOT retry \u2014 tell the merchant to upgrade their Joy plan; the response's upgrade_url links straight to their subscription page.`, status, serverCode);
|
|
127
479
|
}
|
|
128
480
|
if (status === 429) {
|
|
129
481
|
const wait = retryAfter ? `${retryAfter} seconds` : "~10 seconds";
|
|
@@ -133,22 +485,23 @@ function classifyError(status, serverMessage, retryAfter, credentialHint) {
|
|
|
133
485
|
// window and a daily quota), so any number written into this string is wrong for most
|
|
134
486
|
// shops. The server states the real one in `detail`, and Retry-After carries the wait.
|
|
135
487
|
`Rate limited by the Joy API.${detail} Wait ${wait} before the next call, and prefer fewer, more specific queries (e.g. filter by email instead of listing pages). Do NOT retry immediately.`,
|
|
136
|
-
status
|
|
488
|
+
status,
|
|
489
|
+
serverCode
|
|
137
490
|
);
|
|
138
491
|
}
|
|
139
492
|
if (status === 404) {
|
|
140
493
|
if (!serverMessage) {
|
|
141
|
-
return new JoyApiError("not_found", "This endpoint does not exist on the Joy backend this shop is connected to. Nothing is wrong with the arguments and there is no identifier to re-check \u2014 the shop is on a deployment older than this tool. Do NOT retry, do NOT try a lookup tool, and do NOT substitute data from another tool. Tell the merchant this feature is unavailable on their current Joy version.", status);
|
|
494
|
+
return new JoyApiError("not_found", "This endpoint does not exist on the Joy backend this shop is connected to. Nothing is wrong with the arguments and there is no identifier to re-check \u2014 the shop is on a deployment older than this tool. Do NOT retry, do NOT try a lookup tool, and do NOT substitute data from another tool. Tell the merchant this feature is unavailable on their current Joy version.", status, serverCode);
|
|
142
495
|
}
|
|
143
|
-
return new JoyApiError("not_found", `Not found.${detail} Verify the identifier and try a lookup tool first (e.g. joy_find_member) instead of guessing IDs.`, status);
|
|
496
|
+
return new JoyApiError("not_found", `Not found.${detail} Verify the identifier and try a lookup tool first (e.g. joy_find_member) instead of guessing IDs.`, status, serverCode);
|
|
144
497
|
}
|
|
145
498
|
if (status === 400 || status === 422) {
|
|
146
|
-
return new JoyApiError("validation", `Invalid request.${detail} Fix the named parameter and call again.`, status);
|
|
499
|
+
return new JoyApiError("validation", `Invalid request.${detail} Fix the named parameter and call again.`, status, serverCode);
|
|
147
500
|
}
|
|
148
501
|
if (status === 409) {
|
|
149
|
-
return new JoyApiError("conflict", `Conflict.${detail} Another attempt with the same Idempotency-Key is still in flight \u2014 wait ~30 seconds and call again with the SAME idempotency_key to retrieve its result. Never switch to a new key for the same operation; if this persists for minutes, verify the outcome via joy_get_member_activity instead.`, status);
|
|
502
|
+
return new JoyApiError("conflict", `Conflict.${detail} Another attempt with the same Idempotency-Key is still in flight \u2014 wait ~30 seconds and call again with the SAME idempotency_key to retrieve its result. Never switch to a new key for the same operation; if this persists for minutes, verify the outcome via joy_get_member_activity instead.`, status, serverCode);
|
|
150
503
|
}
|
|
151
|
-
return new JoyApiError("server", `Joy API internal error (HTTP ${status}).${detail} Safe to retry ONCE after a short wait; if it persists, stop and report the failure to the merchant.`, status);
|
|
504
|
+
return new JoyApiError("server", `Joy API internal error (HTTP ${status}).${detail} Safe to retry ONCE after a short wait; if it persists, stop and report the failure to the merchant.`, status, serverCode);
|
|
152
505
|
}
|
|
153
506
|
function errorFromOkBody(body) {
|
|
154
507
|
if (!body || typeof body !== "object")
|
|
@@ -160,12 +513,28 @@ function errorFromOkBody(body) {
|
|
|
160
513
|
const message = extractServerMessage(record);
|
|
161
514
|
const code = extractServerCode(record);
|
|
162
515
|
if (code === "SHOP_UNINSTALLED" || /uninstall/i.test(message ?? "")) {
|
|
163
|
-
return new JoyApiError("forbidden", "The shop has uninstalled the Joy app \u2014 no loyalty data is available. Stop and inform the merchant; do NOT retry.");
|
|
516
|
+
return new JoyApiError("forbidden", "The shop has uninstalled the Joy app \u2014 no loyalty data is available. Stop and inform the merchant; do NOT retry.", void 0, telemetryServerCode(record));
|
|
164
517
|
}
|
|
165
518
|
if (code === "VALIDATION_ERROR") {
|
|
166
|
-
return new JoyApiError("validation", `Invalid request${message ? `: "${message}"` : ""}. Fix the named parameter and call again
|
|
519
|
+
return new JoyApiError("validation", `Invalid request${message ? `: "${message}"` : ""}. Fix the named parameter and call again.`, void 0, telemetryServerCode(record));
|
|
520
|
+
}
|
|
521
|
+
return new JoyApiError("server", `The Joy API reported an error: "${message ?? "unknown error"}". Do not treat this as an empty result. First re-check your parameters against the message; if they look correct, retry ONCE, then report the failure to the merchant.`, void 0, telemetryServerCode(record));
|
|
522
|
+
}
|
|
523
|
+
function telemetryServerCode(body) {
|
|
524
|
+
const record = body && typeof body === "object" ? body : {};
|
|
525
|
+
return sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", "error_code", extractServerCode(body)) ?? sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", "error_code", record.error);
|
|
526
|
+
}
|
|
527
|
+
function authHeaders(config) {
|
|
528
|
+
const headers = {
|
|
529
|
+
"Content-Type": "application/json",
|
|
530
|
+
"X-Joy-Loyalty-App-Key": config.appKey
|
|
531
|
+
};
|
|
532
|
+
if (config.privateBotKey) {
|
|
533
|
+
headers["X-Joy-Private-Bot-Key"] = config.privateBotKey;
|
|
534
|
+
} else if (config.secretKey) {
|
|
535
|
+
headers["X-Joy-Loyalty-Secret-Key"] = config.secretKey;
|
|
167
536
|
}
|
|
168
|
-
return
|
|
537
|
+
return headers;
|
|
169
538
|
}
|
|
170
539
|
var JoyApi = class {
|
|
171
540
|
config;
|
|
@@ -176,16 +545,7 @@ var JoyApi = class {
|
|
|
176
545
|
return this.config.mode === "internal-bot" ? "JOY_APP_KEY and JOY_PRIVATE_BOT_KEY" : "JOY_APP_KEY and JOY_SECRET_KEY";
|
|
177
546
|
}
|
|
178
547
|
headers() {
|
|
179
|
-
|
|
180
|
-
"Content-Type": "application/json",
|
|
181
|
-
"X-Joy-Loyalty-App-Key": this.config.appKey
|
|
182
|
-
};
|
|
183
|
-
if (this.config.privateBotKey) {
|
|
184
|
-
headers["X-Joy-Private-Bot-Key"] = this.config.privateBotKey;
|
|
185
|
-
} else if (this.config.secretKey) {
|
|
186
|
-
headers["X-Joy-Loyalty-Secret-Key"] = this.config.secretKey;
|
|
187
|
-
}
|
|
188
|
-
return headers;
|
|
548
|
+
return authHeaders(this.config);
|
|
189
549
|
}
|
|
190
550
|
async request(path, options = {}) {
|
|
191
551
|
const { method = "GET", query, body, timeoutMs = DEFAULT_TIMEOUT_MS, headers } = options;
|
|
@@ -194,49 +554,57 @@ var JoyApi = class {
|
|
|
194
554
|
if (value !== void 0)
|
|
195
555
|
url.searchParams.set(key, String(value));
|
|
196
556
|
}
|
|
197
|
-
|
|
557
|
+
const record = currentCall();
|
|
558
|
+
if (record)
|
|
559
|
+
apiStart();
|
|
198
560
|
try {
|
|
199
|
-
response
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
561
|
+
let response;
|
|
562
|
+
try {
|
|
563
|
+
response = await fetch(url, {
|
|
564
|
+
method,
|
|
565
|
+
headers: { ...headers, ...record?.headers, ...this.headers() },
|
|
566
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
567
|
+
// fetch strips authorization on cross-origin redirects but NOT custom
|
|
568
|
+
// headers — an open redirect would forward the shop key elsewhere
|
|
569
|
+
redirect: "error",
|
|
570
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
571
|
+
});
|
|
572
|
+
} catch (error) {
|
|
573
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
574
|
+
throw new JoyApiError("timeout", `The Joy API did not respond within ${Math.round(timeoutMs / 1e3)}s. The first call after an idle period pays a Cloud Functions cold start, so a lone timeout is usually transient rather than an outage. Safe to retry ONCE; if it times out again, report the outage instead of retrying further.`);
|
|
575
|
+
}
|
|
576
|
+
throw new JoyApiError("network", `Could not reach the Joy API at ${this.config.apiBaseUrl}. Check network connectivity and JOY_API_BASE_URL. Safe to retry ONCE.`);
|
|
211
577
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
578
|
+
let parsed;
|
|
579
|
+
try {
|
|
580
|
+
parsed = await response.json();
|
|
581
|
+
} catch {
|
|
582
|
+
parsed = void 0;
|
|
583
|
+
}
|
|
584
|
+
if (!response.ok) {
|
|
585
|
+
throw classifyError(response.status, extractServerMessage(parsed), response.headers.get("Retry-After"), this.credentialHint(), telemetryServerCode(parsed));
|
|
586
|
+
}
|
|
587
|
+
const okBodyError = errorFromOkBody(parsed);
|
|
588
|
+
if (okBodyError)
|
|
589
|
+
throw okBodyError;
|
|
590
|
+
return parsed ?? {};
|
|
591
|
+
} finally {
|
|
592
|
+
if (record)
|
|
593
|
+
apiEnd();
|
|
222
594
|
}
|
|
223
|
-
const okBodyError = errorFromOkBody(parsed);
|
|
224
|
-
if (okBodyError)
|
|
225
|
-
throw okBodyError;
|
|
226
|
-
return parsed ?? {};
|
|
227
595
|
}
|
|
228
596
|
/** GET that unwraps the {success, data, meta} envelope — agent context never sees the wrapper. */
|
|
229
597
|
async getData(path, options = {}) {
|
|
230
|
-
const
|
|
598
|
+
const envelope2 = await this.request(path, options);
|
|
231
599
|
return {
|
|
232
|
-
data:
|
|
233
|
-
hasNext:
|
|
600
|
+
data: envelope2.data,
|
|
601
|
+
hasNext: envelope2.meta?.pagination?.hasNext === true
|
|
234
602
|
};
|
|
235
603
|
}
|
|
236
604
|
/** POST/PUT that unwraps the envelope — for write tools. */
|
|
237
605
|
async mutateData(path, options = {}) {
|
|
238
|
-
const
|
|
239
|
-
return
|
|
606
|
+
const envelope2 = await this.request(path, options);
|
|
607
|
+
return envelope2.data;
|
|
240
608
|
}
|
|
241
609
|
};
|
|
242
610
|
|
|
@@ -255,6 +623,9 @@ var TtlCache = class {
|
|
|
255
623
|
this.store.set(key, { expiresAt: now + ttlMs, promise });
|
|
256
624
|
return promise;
|
|
257
625
|
}
|
|
626
|
+
delete(key) {
|
|
627
|
+
this.store.delete(key);
|
|
628
|
+
}
|
|
258
629
|
};
|
|
259
630
|
|
|
260
631
|
// ../mcp-core/build/tools/members.js
|
|
@@ -263,65 +634,139 @@ import { z as z2 } from "zod";
|
|
|
263
634
|
// ../mcp-core/build/toolkit.js
|
|
264
635
|
import { z } from "zod";
|
|
265
636
|
|
|
266
|
-
// ../mcp-core/build/
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
637
|
+
// ../mcp-core/build/store.js
|
|
638
|
+
function displayName(store) {
|
|
639
|
+
return store.name.replace(/[\u0000-\u001f\u007f[\]]+/g, " ").trim() || store.myshopifyDomain;
|
|
640
|
+
}
|
|
641
|
+
function storeLabel(store, maxNameLength = 60) {
|
|
642
|
+
const safeName = displayName(store);
|
|
643
|
+
const name = safeName.length > maxNameLength ? `${safeName.slice(0, Math.max(0, maxNameLength - 1))}\u2026` : safeName;
|
|
644
|
+
return `${name} (${store.myshopifyDomain})`;
|
|
645
|
+
}
|
|
646
|
+
function storeDescriptionPrefix(store) {
|
|
647
|
+
return `[Store: ${storeLabel(store)}] `;
|
|
648
|
+
}
|
|
649
|
+
function stampResult(text, store) {
|
|
650
|
+
return `[Store: ${storeLabel(store)}]
|
|
651
|
+
${text}`;
|
|
652
|
+
}
|
|
653
|
+
function normalizeStoreInput(raw) {
|
|
654
|
+
let normalized = raw.trim().toLowerCase();
|
|
655
|
+
normalized = normalized.replace(/^https?:\/\//, "");
|
|
656
|
+
normalized = normalized.replace(/^www\./, "");
|
|
657
|
+
const slashIdx = normalized.indexOf("/");
|
|
658
|
+
if (slashIdx !== -1) {
|
|
659
|
+
normalized = normalized.slice(0, slashIdx);
|
|
660
|
+
}
|
|
661
|
+
normalized = normalized.split(/[?#]/)[0];
|
|
662
|
+
normalized = normalized.replace(/[./]+$/, "");
|
|
663
|
+
return normalized;
|
|
664
|
+
}
|
|
665
|
+
function matchesStore(raw, store) {
|
|
666
|
+
if (!raw || !raw.trim()) {
|
|
667
|
+
return false;
|
|
273
668
|
}
|
|
274
|
-
|
|
669
|
+
const normalized = normalizeStoreInput(raw);
|
|
670
|
+
if (normalized === normalizeStoreInput(store.myshopifyDomain)) {
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
if (store.primaryDomain && normalized === normalizeStoreInput(store.primaryDomain)) {
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
if (raw.trim().toLowerCase() === store.name.trim().toLowerCase()) {
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
function storeMismatchMessage(raw, store) {
|
|
682
|
+
const target = raw.length > 100 ? `${raw.slice(0, 99)}\u2026` : raw;
|
|
683
|
+
return `Rejected \u2014 this connector is bound to ${storeLabel(store)} but the call targeted "${target}". Nothing was changed. Use the connector for that store. Only retry here if the user explicitly confirms they mean ${displayName(store)} \u2014 ask them, do not assume.`;
|
|
684
|
+
}
|
|
685
|
+
var STORE_ARG_DESCRIPTION = `The store the USER asked to change \u2014 its name or domain, taken from the user's own words or an earlier turn, never copied from this tool's description. If it is not clear which store the user means, ask them before calling. The server rejects the call when this does not match the store this connector is bound to. If the user has not named a store and this is the only Joy connector enabled, the store this connector is bound to is the one meant.`;
|
|
275
686
|
|
|
276
687
|
// ../mcp-core/build/toolkit.js
|
|
277
|
-
async function runHandler(name, run) {
|
|
688
|
+
async function runHandler(name, run, store) {
|
|
278
689
|
try {
|
|
279
690
|
const text = await run();
|
|
280
|
-
|
|
691
|
+
markSuccess();
|
|
692
|
+
return { content: [{ type: "text", text: store ? stampResult(text, store) : text }] };
|
|
281
693
|
} catch (error) {
|
|
282
694
|
if (error instanceof JoyApiError) {
|
|
283
|
-
|
|
695
|
+
markToolError(error);
|
|
696
|
+
const text2 = store ? stampResult(error.modelMessage, store) : error.modelMessage;
|
|
697
|
+
return { isError: true, content: [{ type: "text", text: text2 }] };
|
|
284
698
|
}
|
|
699
|
+
markInternalError();
|
|
285
700
|
const message = error instanceof Error ? error.message : String(error);
|
|
286
701
|
log.error(`${name} failed unexpectedly: ${message}`);
|
|
702
|
+
const text = `Unexpected error in ${name}: ${message}. This is likely a bug in the Joy MCP server, not your input \u2014 report it to the merchant rather than retrying.`;
|
|
287
703
|
return {
|
|
288
704
|
isError: true,
|
|
289
|
-
content: [
|
|
290
|
-
{
|
|
291
|
-
type: "text",
|
|
292
|
-
text: `Unexpected error in ${name}: ${message}. This is likely a bug in the Joy MCP server, not your input \u2014 report it to the merchant rather than retrying.`
|
|
293
|
-
}
|
|
294
|
-
]
|
|
705
|
+
content: [{ type: "text", text: store ? stampResult(text, store) : text }]
|
|
295
706
|
};
|
|
296
707
|
}
|
|
297
708
|
}
|
|
298
|
-
function
|
|
299
|
-
|
|
709
|
+
function withTelemetry(ctx, name, callback) {
|
|
710
|
+
if (!ctx.telemetry)
|
|
711
|
+
return callback;
|
|
712
|
+
telemetrySafe("expose", () => ctx.telemetry?.expose(name));
|
|
713
|
+
return ((...args) => {
|
|
714
|
+
markEntered();
|
|
715
|
+
return callback(...args);
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
function registerReadTool(server, ctx, spec) {
|
|
719
|
+
const callback = withTelemetry(ctx, spec.name, (async (args) => runHandler(spec.name, () => spec.handler(args ?? {}), ctx.store)));
|
|
300
720
|
server.registerTool(spec.name, {
|
|
301
721
|
title: spec.title,
|
|
302
|
-
description: spec.description,
|
|
722
|
+
description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
|
|
303
723
|
inputSchema: spec.inputSchema,
|
|
304
724
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
305
725
|
}, callback);
|
|
306
726
|
}
|
|
727
|
+
function withStoreArg(shape, store) {
|
|
728
|
+
if (!store)
|
|
729
|
+
return shape;
|
|
730
|
+
return { ...shape, store: z.string().describe(STORE_ARG_DESCRIPTION) };
|
|
731
|
+
}
|
|
732
|
+
function checkStoreMismatch(name, args, store) {
|
|
733
|
+
const raw = typeof args?.store === "string" ? args.store : "";
|
|
734
|
+
if (matchesStore(raw, store))
|
|
735
|
+
return void 0;
|
|
736
|
+
log.info(`${name} rejected: store mismatch (bound=${store.myshopifyDomain})`);
|
|
737
|
+
return {
|
|
738
|
+
isError: true,
|
|
739
|
+
content: [{ type: "text", text: stampResult(storeMismatchMessage(raw, store), store) }]
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
function stripStoreArg(args) {
|
|
743
|
+
const { store: _store, ...rest } = args ?? {};
|
|
744
|
+
return rest;
|
|
745
|
+
}
|
|
307
746
|
function registerConfigWriteTool(server, ctx, spec) {
|
|
308
|
-
const
|
|
747
|
+
const strictStore = ctx.store && ctx.requireStoreArg ? ctx.store : void 0;
|
|
748
|
+
const callback = withTelemetry(ctx, spec.name, (async (args) => {
|
|
749
|
+
if (strictStore) {
|
|
750
|
+
const mismatch = checkStoreMismatch(spec.name, args, strictStore);
|
|
751
|
+
if (mismatch) {
|
|
752
|
+
markRejected("store_mismatch");
|
|
753
|
+
return mismatch;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
309
756
|
if (!ctx.config.configWritesEnabled) {
|
|
757
|
+
markRejected("config_writes_disabled");
|
|
758
|
+
const text = `${spec.name} is disabled: this server cannot change shop configuration. The merchant must set BOTH JOY_MCP_ENABLE_WRITES="true" and JOY_MCP_ENABLE_CONFIG_WRITES="true" (quoted strings, with their secret key) in the MCP config. Do not retry until they have \u2014 and do not fall back to a member-level write tool to approximate a configuration change.`;
|
|
310
759
|
return {
|
|
311
760
|
isError: true,
|
|
312
|
-
content: [
|
|
313
|
-
{
|
|
314
|
-
type: "text",
|
|
315
|
-
text: `${spec.name} is disabled: this server cannot change shop configuration. The merchant must set BOTH JOY_MCP_ENABLE_WRITES="true" and JOY_MCP_ENABLE_CONFIG_WRITES="true" (quoted strings, with their secret key) in the MCP config. Do not retry until they have \u2014 and do not fall back to a member-level write tool to approximate a configuration change.`
|
|
316
|
-
}
|
|
317
|
-
]
|
|
761
|
+
content: [{ type: "text", text: ctx.store ? stampResult(text, ctx.store) : text }]
|
|
318
762
|
};
|
|
319
763
|
}
|
|
320
|
-
|
|
321
|
-
|
|
764
|
+
const handlerArgs = strictStore ? stripStoreArg(args) : args ?? {};
|
|
765
|
+
return runHandler(spec.name, () => spec.handler(handlerArgs), ctx.store);
|
|
766
|
+
}));
|
|
322
767
|
const registration = {
|
|
323
768
|
title: spec.title,
|
|
324
|
-
description: spec.description,
|
|
769
|
+
description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
|
|
325
770
|
annotations: {
|
|
326
771
|
readOnlyHint: false,
|
|
327
772
|
destructiveHint: spec.destructiveHint,
|
|
@@ -330,31 +775,37 @@ function registerConfigWriteTool(server, ctx, spec) {
|
|
|
330
775
|
}
|
|
331
776
|
};
|
|
332
777
|
if (spec.strictArguments) {
|
|
333
|
-
const inputSchema = z.object(spec.inputSchema).strict();
|
|
778
|
+
const inputSchema = z.object(withStoreArg(spec.inputSchema, strictStore)).strict();
|
|
334
779
|
server.registerTool(spec.name, { ...registration, inputSchema }, callback);
|
|
335
780
|
return;
|
|
336
781
|
}
|
|
337
|
-
server.registerTool(spec.name, { ...registration, inputSchema: spec.inputSchema }, callback);
|
|
782
|
+
server.registerTool(spec.name, { ...registration, inputSchema: withStoreArg(spec.inputSchema, strictStore) }, callback);
|
|
338
783
|
}
|
|
339
784
|
function registerWriteTool(server, ctx, spec) {
|
|
340
|
-
const
|
|
785
|
+
const strictStore = ctx.store && ctx.requireStoreArg ? ctx.store : void 0;
|
|
786
|
+
const callback = withTelemetry(ctx, spec.name, (async (args) => {
|
|
787
|
+
if (strictStore) {
|
|
788
|
+
const mismatch = checkStoreMismatch(spec.name, args, strictStore);
|
|
789
|
+
if (mismatch) {
|
|
790
|
+
markRejected("store_mismatch");
|
|
791
|
+
return mismatch;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
341
794
|
if (!ctx.config.writesEnabled) {
|
|
795
|
+
markRejected("writes_disabled");
|
|
796
|
+
const text = `${spec.name} is disabled: this server is running read-only. The merchant must set JOY_MCP_ENABLE_WRITES=true (with their secret key) in the MCP config to allow write operations. Do not retry until they have.`;
|
|
342
797
|
return {
|
|
343
798
|
isError: true,
|
|
344
|
-
content: [
|
|
345
|
-
{
|
|
346
|
-
type: "text",
|
|
347
|
-
text: `${spec.name} is disabled: this server is running read-only. The merchant must set JOY_MCP_ENABLE_WRITES=true (with their secret key) in the MCP config to allow write operations. Do not retry until they have.`
|
|
348
|
-
}
|
|
349
|
-
]
|
|
799
|
+
content: [{ type: "text", text: ctx.store ? stampResult(text, ctx.store) : text }]
|
|
350
800
|
};
|
|
351
801
|
}
|
|
352
|
-
|
|
353
|
-
|
|
802
|
+
const handlerArgs = strictStore ? stripStoreArg(args) : args ?? {};
|
|
803
|
+
return runHandler(spec.name, () => spec.handler(handlerArgs), ctx.store);
|
|
804
|
+
}));
|
|
354
805
|
server.registerTool(spec.name, {
|
|
355
806
|
title: spec.title,
|
|
356
|
-
description: spec.description,
|
|
357
|
-
inputSchema: spec.inputSchema,
|
|
807
|
+
description: ctx.store ? storeDescriptionPrefix(ctx.store) + spec.description : spec.description,
|
|
808
|
+
inputSchema: withStoreArg(spec.inputSchema, strictStore),
|
|
358
809
|
annotations: {
|
|
359
810
|
readOnlyHint: false,
|
|
360
811
|
destructiveHint: spec.destructiveHint,
|
|
@@ -428,6 +879,9 @@ var MEMBER_DETAIL_KEYS = [
|
|
|
428
879
|
"pointsRemain",
|
|
429
880
|
"tierPoint",
|
|
430
881
|
"pendingPoint",
|
|
882
|
+
// REST v2 detail fields, plus computed pointExpiration on the single-customer GETs.
|
|
883
|
+
"pointExpiration",
|
|
884
|
+
"pointExpirationUnavailable",
|
|
431
885
|
"totalSpent",
|
|
432
886
|
"totalEarnedPoints",
|
|
433
887
|
"tierId",
|
|
@@ -470,6 +924,8 @@ var REWARD_KEYS = [
|
|
|
470
924
|
"orderReqAmount",
|
|
471
925
|
"expiredAt",
|
|
472
926
|
"usedAt",
|
|
927
|
+
"isRevokedCode",
|
|
928
|
+
"revokedAt",
|
|
473
929
|
"createdAt"
|
|
474
930
|
];
|
|
475
931
|
var memberIdentifierSchema = {
|
|
@@ -514,19 +970,30 @@ function reclassifyCursorError(error, after) {
|
|
|
514
970
|
throw error;
|
|
515
971
|
}
|
|
516
972
|
function registerMemberTools(server, ctx) {
|
|
517
|
-
registerReadTool(server, {
|
|
973
|
+
registerReadTool(server, ctx, {
|
|
518
974
|
name: "joy_find_member",
|
|
519
975
|
title: "Find a loyalty member",
|
|
520
|
-
description:
|
|
976
|
+
description: `Look up ONE loyalty member by email or Shopify customer ID and return their points balance, VIP tier, and lifetime stats. Use this first whenever the merchant refers to a specific customer. Email matching is exact (case-insensitive) \u2014 if it misses, confirm spelling before concluding the customer is not a member. Field notes: "point" is the CURRENT spendable balance; "totalEarnedPoints" is lifetime earned (not spendable); "pointsRemain" is points needed to reach the next tier. Shopify store credit (money) is NOT included here \u2014 use joy_get_member_store_credit for that. "pointExpiration" is the NEXT scheduled expiry: nextExpiryAt (ISO date) and nextExpiryPoints (how many points expire then). It is omitted when the shop's points never expire, the balance is 0, or no expiry is scheduled. If "pointExpirationUnavailable" is true the expiry could not be loaded \u2014 do not claim points never expire.`,
|
|
521
977
|
inputSchema: memberIdentifierSchema,
|
|
522
978
|
handler: async (args) => {
|
|
523
|
-
|
|
979
|
+
let member = await resolveMember(ctx.api, args);
|
|
980
|
+
if (!args.shopify_customer_id && member.id && !Object.hasOwn(member, "pointExpiration")) {
|
|
981
|
+
try {
|
|
982
|
+
const { data } = await ctx.api.getData(`/customers/${encodeURIComponent(String(member.id))}`);
|
|
983
|
+
if (data && typeof data === "object")
|
|
984
|
+
member = data;
|
|
985
|
+
} catch {
|
|
986
|
+
member = { ...member, pointExpirationUnavailable: true };
|
|
987
|
+
}
|
|
988
|
+
}
|
|
524
989
|
const shaped = compact(member, MEMBER_DETAIL_KEYS);
|
|
525
990
|
const tier = member.tierName ? `, tier ${String(member.tierName)}` : "";
|
|
526
|
-
|
|
991
|
+
const pointExpiration = member.pointExpiration && typeof member.pointExpiration === "object" ? member.pointExpiration : void 0;
|
|
992
|
+
const expiry = pointExpiration && typeof pointExpiration.nextExpiryAt === "string" && typeof pointExpiration.nextExpiryPoints === "number" ? `, ${formatPoints(pointExpiration.nextExpiryPoints)} expire on ${pointExpiration.nextExpiryAt.slice(0, 10)}` : "";
|
|
993
|
+
return summaryWithJson(`Member ${memberDisplayName(member)}: ${formatPoints(member.point)} points${tier}${expiry}.`, shaped);
|
|
527
994
|
}
|
|
528
995
|
});
|
|
529
|
-
registerReadTool(server, {
|
|
996
|
+
registerReadTool(server, ctx, {
|
|
530
997
|
name: "joy_get_member_store_credit",
|
|
531
998
|
title: "Get a member's store credit balance",
|
|
532
999
|
description: "Shopify STORE CREDIT balance for ONE member (by email or Shopify customer ID). Store credit is real MONEY on the customer's Shopify account (e.g. from referral rewards or refunds) \u2014 it is NOT loyalty points; for the points balance use joy_find_member. A customer holds at most one account per currency, so multiple accounts mean multiple currencies. No account = zero store credit.",
|
|
@@ -554,7 +1021,7 @@ function registerMemberTools(server, ctx) {
|
|
|
554
1021
|
});
|
|
555
1022
|
}
|
|
556
1023
|
});
|
|
557
|
-
registerReadTool(server, {
|
|
1024
|
+
registerReadTool(server, ctx, {
|
|
558
1025
|
name: "joy_list_members",
|
|
559
1026
|
title: "List loyalty members",
|
|
560
1027
|
description: 'List loyalty members a page at a time, with sorting and an optional VIP-tier filter. Default order is most RECENT ACTIVITY (updatedAt desc). For ranking questions use sort_by \u2014 "which customer has the most points?" / "top 10 members" is ONE call with sort_by "points" (the first page IS the leaderboard; never page through the whole list for this). "How many customers/members does the shop have?" is ONE call with include_total_count true and limit 1 \u2014 never sum tier counts or page the list for this. sort_by "created_at" answers newest/oldest signups; tier_id narrows any of these to one tier. To inspect ONE known customer, prefer joy_find_member (cheaper and more precise). Returns up to `limit` members plus `has_more` and `next_after`; pass `next_after` back as `after` for the next page. Do not page through the whole list to answer aggregate questions \u2014 that burns the shop\'s API rate limit.',
|
|
@@ -572,9 +1039,9 @@ function registerMemberTools(server, ctx) {
|
|
|
572
1039
|
const direction = args.direction ?? "desc";
|
|
573
1040
|
const withTotal = args.include_total_count === true;
|
|
574
1041
|
const sort = MEMBER_SORTS[sortBy];
|
|
575
|
-
let
|
|
1042
|
+
let envelope2;
|
|
576
1043
|
try {
|
|
577
|
-
|
|
1044
|
+
envelope2 = await ctx.api.request("/customers", {
|
|
578
1045
|
query: {
|
|
579
1046
|
limit,
|
|
580
1047
|
after: args.after,
|
|
@@ -587,10 +1054,10 @@ function registerMemberTools(server, ctx) {
|
|
|
587
1054
|
reclassifyCursorError(error, args.after);
|
|
588
1055
|
}
|
|
589
1056
|
const result = {
|
|
590
|
-
data:
|
|
591
|
-
hasNext:
|
|
1057
|
+
data: envelope2.data,
|
|
1058
|
+
hasNext: envelope2.meta?.pagination?.hasNext === true
|
|
592
1059
|
};
|
|
593
|
-
const totalMembers = withTotal ?
|
|
1060
|
+
const totalMembers = withTotal ? envelope2.meta?.pagination?.total : void 0;
|
|
594
1061
|
const members = Array.isArray(result.data) ? result.data : [];
|
|
595
1062
|
if (members.length === 0) {
|
|
596
1063
|
if (args.after)
|
|
@@ -615,7 +1082,7 @@ function registerMemberTools(server, ctx) {
|
|
|
615
1082
|
});
|
|
616
1083
|
}
|
|
617
1084
|
});
|
|
618
|
-
registerReadTool(server, {
|
|
1085
|
+
registerReadTool(server, ctx, {
|
|
619
1086
|
name: "joy_get_member_activity",
|
|
620
1087
|
title: "Get a member's point activity",
|
|
621
1088
|
description: 'Point transactions (earned, spent, adjusted, refund deductions\u2026) for ONE member, most recent first. Use to answer "why is this customer\'s balance X?" or to audit changes. Each entry shows the point change, balance before/after (oldPoint \u2192 newPoint), the source system, and any notes. Returns `next_after` when more history exists \u2014 pass it back as `after` to keep auditing.',
|
|
@@ -652,10 +1119,10 @@ function registerMemberTools(server, ctx) {
|
|
|
652
1119
|
});
|
|
653
1120
|
}
|
|
654
1121
|
});
|
|
655
|
-
registerReadTool(server, {
|
|
1122
|
+
registerReadTool(server, ctx, {
|
|
656
1123
|
name: "joy_get_member_rewards",
|
|
657
1124
|
title: "Get a member's reward/coupon history",
|
|
658
|
-
description: `Coupons and rewards ONE member has already redeemed \u2014 code, discount program, status (active/used/expired), expiry. This is the member's OWN history, NOT the shop's catalog of what CAN be redeemed (use joy_list_redemption_rules for that). Use to answer "what coupons does this customer have?" or "did they already use that discount code?".`,
|
|
1125
|
+
description: `Coupons and rewards ONE member has already redeemed \u2014 code, discount program, status (active/used/expired/revoked), expiry. A revoked coupon has discountStatus "revoked" plus isRevokedCode/revokedAt: its points were refunded and the code no longer works. This is the member's OWN history, NOT the shop's catalog of what CAN be redeemed (use joy_list_redemption_rules for that). Use to answer "what coupons does this customer have?" or "did they already use that discount code?".`,
|
|
659
1126
|
inputSchema: {
|
|
660
1127
|
...memberIdentifierSchema,
|
|
661
1128
|
limit: z2.number().int().min(1).max(50).optional().describe("Rewards per page (default 10, max 50)."),
|
|
@@ -708,23 +1175,68 @@ var TIER_KEYS = [
|
|
|
708
1175
|
"systemType"
|
|
709
1176
|
];
|
|
710
1177
|
var TIERS_TTL_MS = 15 * 60 * 1e3;
|
|
1178
|
+
var TIER_PROGRAM_KEYS = [
|
|
1179
|
+
"status",
|
|
1180
|
+
"entryMethod",
|
|
1181
|
+
"startDate",
|
|
1182
|
+
"prefix",
|
|
1183
|
+
"isUsePrefixDiscountCode",
|
|
1184
|
+
"showTierPointSettings",
|
|
1185
|
+
"allowGiveEntryRewardAgain",
|
|
1186
|
+
"useDemotionTier",
|
|
1187
|
+
"typeDemotion",
|
|
1188
|
+
"useResetTier",
|
|
1189
|
+
"resetFrequency"
|
|
1190
|
+
];
|
|
1191
|
+
var LEVEL_UP_RULE_LABELS = {
|
|
1192
|
+
pointEarned: "points earned",
|
|
1193
|
+
moneySpent: "amount spent",
|
|
1194
|
+
numberOfOrders: "number of orders"
|
|
1195
|
+
};
|
|
1196
|
+
function calculationSummary(settings, tiers) {
|
|
1197
|
+
const entryMethod = typeof settings.entryMethod === "string" && settings.entryMethod || tiers.find((tier) => typeof tier.entryMethod === "string")?.entryMethod;
|
|
1198
|
+
const label = typeof entryMethod === "string" ? LEVEL_UP_RULE_LABELS[entryMethod] : void 0;
|
|
1199
|
+
if (!label)
|
|
1200
|
+
return "";
|
|
1201
|
+
const start = typeof settings.startDate === "string" && settings.startDate ? `, since ${settings.startDate.slice(0, 10)}` : "";
|
|
1202
|
+
return ` Level-up rule: ${label}${start}.`;
|
|
1203
|
+
}
|
|
1204
|
+
async function readTierProgramSettings(ctx) {
|
|
1205
|
+
try {
|
|
1206
|
+
const { data } = await ctx.api.getData("/tiers/settings");
|
|
1207
|
+
return data && typeof data === "object" ? data : {};
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
if (error instanceof JoyApiError && error.kind === "not_found")
|
|
1210
|
+
return {};
|
|
1211
|
+
throw error;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
711
1214
|
function getTiersCached(ctx) {
|
|
712
1215
|
return ctx.cache.get("tiers", TIERS_TTL_MS, async () => {
|
|
713
|
-
const
|
|
714
|
-
|
|
1216
|
+
const [tiersResult, settings] = await Promise.all([
|
|
1217
|
+
ctx.api.getData("/tiers"),
|
|
1218
|
+
readTierProgramSettings(ctx)
|
|
1219
|
+
]);
|
|
1220
|
+
return {
|
|
1221
|
+
tiers: Array.isArray(tiersResult.data) ? tiersResult.data : [],
|
|
1222
|
+
settings
|
|
1223
|
+
};
|
|
715
1224
|
});
|
|
716
1225
|
}
|
|
717
1226
|
function registerTierTools(server, ctx) {
|
|
718
|
-
registerReadTool(server, {
|
|
1227
|
+
registerReadTool(server, ctx, {
|
|
719
1228
|
name: "joy_list_tiers",
|
|
720
1229
|
title: "List VIP tiers",
|
|
721
|
-
description: 'List the shop\'s VIP tiers
|
|
1230
|
+
description: 'List the shop\'s VIP tiers plus the program "Tier calculation" settings (entryMethod, startDate, prefix). Thresholds (targetPoint) are in the unit of entryMethod \u2014 150 means 150 points, $150 spent, or 150 orders depending on that rule. Also includes earning multipliers and per-tier benefits (tierRewards). For "how many customers are in tier X?" set include_member_counts \u2014 it returns memberCount (live count of current members) per tier; the plain `members` field is a stale cached snapshot, NEVER quote it as the member count. Config-only calls are cached ~15 minutes \u2014 one call per conversation is enough. To change calculation / demotion / reset settings, use joy_configure_tier_settings.',
|
|
722
1231
|
inputSchema: {
|
|
723
1232
|
include_member_counts: z3.boolean().optional().describe("Set true ONLY when the merchant asks how many customers/members are in tiers \u2014 adds memberCount (live count) per tier. Leave unset for tier-config questions; counting costs extra database reads.")
|
|
724
1233
|
},
|
|
725
1234
|
handler: async (args) => {
|
|
726
1235
|
if (args.include_member_counts === true) {
|
|
727
|
-
const { data } = await
|
|
1236
|
+
const [{ data }, settings2] = await Promise.all([
|
|
1237
|
+
ctx.api.getData("/tiers", { query: { hasCount: true } }),
|
|
1238
|
+
readTierProgramSettings(ctx)
|
|
1239
|
+
]);
|
|
728
1240
|
const tiers2 = Array.isArray(data) ? data : [];
|
|
729
1241
|
if (tiers2.length === 0) {
|
|
730
1242
|
return "This shop has no VIP tiers configured. Tier-related questions do not apply.";
|
|
@@ -732,13 +1244,19 @@ function registerTierTools(server, ctx) {
|
|
|
732
1244
|
const keys = [...TIER_KEYS.filter((key) => key !== "members"), "memberCount"];
|
|
733
1245
|
const total = tiers2.reduce((sum, tier) => sum + (Number(tier.memberCount) || 0), 0);
|
|
734
1246
|
const top = [...tiers2].sort((a, b) => (Number(b.memberCount) || 0) - (Number(a.memberCount) || 0))[0];
|
|
735
|
-
return summaryWithJson(`${tiers2.length} VIP tier(s), ${total} member(s) total \u2014 largest: "${String(top.name)}" (${Number(top.memberCount) || 0} members)
|
|
1247
|
+
return summaryWithJson(`${tiers2.length} VIP tier(s), ${total} member(s) total \u2014 largest: "${String(top.name)}" (${Number(top.memberCount) || 0} members).${calculationSummary(settings2, tiers2)}`, {
|
|
1248
|
+
program: compact(settings2, TIER_PROGRAM_KEYS),
|
|
1249
|
+
tiers: tiers2.map((tier) => compact(tier, keys))
|
|
1250
|
+
});
|
|
736
1251
|
}
|
|
737
|
-
const tiers = await getTiersCached(ctx);
|
|
1252
|
+
const { tiers, settings } = await getTiersCached(ctx);
|
|
738
1253
|
if (tiers.length === 0) {
|
|
739
1254
|
return "This shop has no VIP tiers configured. Tier-related questions do not apply.";
|
|
740
1255
|
}
|
|
741
|
-
return summaryWithJson(`${tiers.length} VIP tier(s) configured
|
|
1256
|
+
return summaryWithJson(`${tiers.length} VIP tier(s) configured.${calculationSummary(settings, tiers)}`, {
|
|
1257
|
+
program: compact(settings, TIER_PROGRAM_KEYS),
|
|
1258
|
+
tiers: tiers.map((tier) => compact(tier, TIER_KEYS))
|
|
1259
|
+
});
|
|
742
1260
|
}
|
|
743
1261
|
});
|
|
744
1262
|
}
|
|
@@ -788,6 +1306,9 @@ var EARNING_KEYS = [
|
|
|
788
1306
|
"rateMoney",
|
|
789
1307
|
"earnPoint",
|
|
790
1308
|
"earnPointsTiers",
|
|
1309
|
+
"isAppliedVipTier",
|
|
1310
|
+
"appliedPlaceOrderTo",
|
|
1311
|
+
"skipEarnPointGuest",
|
|
791
1312
|
"conditions",
|
|
792
1313
|
"typeMilestone",
|
|
793
1314
|
"milestones",
|
|
@@ -795,7 +1316,15 @@ var EARNING_KEYS = [
|
|
|
795
1316
|
"endDate",
|
|
796
1317
|
"priority",
|
|
797
1318
|
"isDraft",
|
|
798
|
-
"expired"
|
|
1319
|
+
"expired",
|
|
1320
|
+
// The artwork on this program's card, wherever the loyalty page draws it.
|
|
1321
|
+
// Read-only here, and included because their ABSENCE was unreadable: the page
|
|
1322
|
+
// design tool offers iconColor/iconBackground/iconRadius, which tells an agent
|
|
1323
|
+
// a glyph exists without telling it where — one cold-context run spent three
|
|
1324
|
+
// refused calls guessing `cardStyle.icon` before concluding, correctly, that
|
|
1325
|
+
// artwork is not part of the page design at all.
|
|
1326
|
+
"loyaltyPageIcon",
|
|
1327
|
+
"loyaltyPageCustomIcon"
|
|
799
1328
|
];
|
|
800
1329
|
var SPENDING_KEYS = [
|
|
801
1330
|
"id",
|
|
@@ -821,13 +1350,49 @@ var SPENDING_KEYS = [
|
|
|
821
1350
|
"hasLimitShipping",
|
|
822
1351
|
"limitShipping",
|
|
823
1352
|
"enableMaxCapShipping",
|
|
824
|
-
"maxCapShipping"
|
|
1353
|
+
"maxCapShipping",
|
|
1354
|
+
// See EARNING_KEYS — same reason, same pair.
|
|
1355
|
+
"loyaltyPageIcon",
|
|
1356
|
+
"loyaltyPageCustomIcon"
|
|
825
1357
|
];
|
|
1358
|
+
var CATALOGUE_ARRAYS = ["specificProducts", "variantIds", "freeProductIds", "appliedCollectionIds"];
|
|
1359
|
+
function compactReward(reward) {
|
|
1360
|
+
if (!reward || typeof reward !== "object" || Array.isArray(reward))
|
|
1361
|
+
return reward;
|
|
1362
|
+
const out = {};
|
|
1363
|
+
for (const [key, value] of Object.entries(reward)) {
|
|
1364
|
+
if (CATALOGUE_ARRAYS.includes(key)) {
|
|
1365
|
+
if (Array.isArray(value) && value.length)
|
|
1366
|
+
out[`${key}Count`] = value.length;
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
out[key] = value;
|
|
1370
|
+
}
|
|
1371
|
+
return out;
|
|
1372
|
+
}
|
|
1373
|
+
function compactMilestones(milestones) {
|
|
1374
|
+
if (!Array.isArray(milestones))
|
|
1375
|
+
return milestones;
|
|
1376
|
+
return milestones.map((milestone) => {
|
|
1377
|
+
if (!milestone || typeof milestone !== "object" || Array.isArray(milestone))
|
|
1378
|
+
return milestone;
|
|
1379
|
+
const entry = milestone;
|
|
1380
|
+
if (!Array.isArray(entry.rewards))
|
|
1381
|
+
return entry;
|
|
1382
|
+
return { ...entry, rewards: entry.rewards.map(compactReward) };
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
function compactEarningProgram(program) {
|
|
1386
|
+
const compacted = compact(program, EARNING_KEYS);
|
|
1387
|
+
if (compacted.milestones === void 0)
|
|
1388
|
+
return compacted;
|
|
1389
|
+
return { ...compacted, milestones: compactMilestones(compacted.milestones) };
|
|
1390
|
+
}
|
|
826
1391
|
function registerProgramTools(server, ctx) {
|
|
827
|
-
registerReadTool(server, {
|
|
1392
|
+
registerReadTool(server, ctx, {
|
|
828
1393
|
name: "joy_list_earning_rules",
|
|
829
1394
|
title: "List point-earning rules",
|
|
830
|
-
description: 'List the ways customers EARN points (place order, sign up, birthday, social follows\u2026). Use to answer "how do customers get points?".
|
|
1395
|
+
description: 'List the ways customers EARN points (place order, sign up, birthday, social follows\u2026). Use to answer "how do customers get points?". For place_order, appliedPlaceOrderTo is the admin "Who to reward" radio (all = All loyalty members, vip-tier = VIP Tier only, member_and_guest = All customers) \u2014 quote that, not just earnPointsTiers. When a rule has earnPointsTiers AND appliedPlaceOrderTo is vip-tier, the per-VIP-tier rates override the flat earnPoint.',
|
|
831
1396
|
inputSchema: {},
|
|
832
1397
|
handler: async () => {
|
|
833
1398
|
const { data } = await ctx.api.getData("/programs/earning");
|
|
@@ -835,10 +1400,10 @@ function registerProgramTools(server, ctx) {
|
|
|
835
1400
|
if (programs.length === 0) {
|
|
836
1401
|
return "No earning rules configured \u2014 customers currently have no way to earn points.";
|
|
837
1402
|
}
|
|
838
|
-
return summaryWithJson(`${programs.length} earning rule(s) configured.`, programs.map(
|
|
1403
|
+
return summaryWithJson(`${programs.length} earning rule(s) configured.`, programs.map(compactEarningProgram));
|
|
839
1404
|
}
|
|
840
1405
|
});
|
|
841
|
-
registerReadTool(server, {
|
|
1406
|
+
registerReadTool(server, ctx, {
|
|
842
1407
|
name: "joy_list_redemption_rules",
|
|
843
1408
|
title: "List point-redemption rules",
|
|
844
1409
|
description: 'List the ways customers SPEND points (discount amount, percentage off, free shipping, free gift\u2026), including point costs (spendPoint) and min/max limits. Use to answer "what can customers redeem?" and to find the programId for a redemption. For USAGE questions \u2014 "which reward is redeemed most?", "how many times was program X redeemed?" \u2014 set include_redeem_counts to get redeemCount (all-time REDEMPTIONS, not unique customers) per rule in the same call. Redeeming itself is a separate write operation, not available through this tool.',
|
|
@@ -1113,7 +1678,7 @@ function formatReferralReward(amount, type) {
|
|
|
1113
1678
|
return `${String(amount)} (${String(type ?? "unknown type")})`;
|
|
1114
1679
|
}
|
|
1115
1680
|
function registerReferralTools(server, ctx) {
|
|
1116
|
-
registerReadTool(server, {
|
|
1681
|
+
registerReadTool(server, ctx, {
|
|
1117
1682
|
name: "joy_get_referral_program",
|
|
1118
1683
|
title: "Get referral program status & configuration",
|
|
1119
1684
|
description: 'Whether the referral program is ENABLED plus its reward configuration \u2014 use this FIRST for "is the referral program on or off?", "what do referrers/friends get?". `status` is the on/off toggle: true = customers can refer friends, false = the program is turned off. Field naming: customerReward* = what the REFERRER (the advocate) gets; friendReward* = what the referred FRIEND gets; reward type may be "percentage", "amount" (fixed discount), "point", or store-credit. When isAppliedVipTier is true, earnPointsTiers overrides the flat referrer reward per VIP tier. For usage numbers use joy_get_referral_performance; for one member\'s results use joy_get_member_referrals.',
|
|
@@ -1135,7 +1700,7 @@ function registerReferralTools(server, ctx) {
|
|
|
1135
1700
|
return summaryWithJson(`Referral program is ${enabled ? "ENABLED (ON)" : "DISABLED (OFF)"} \u2014 referrer gets ${referrerReward}, referred friend gets ${friendReward}.`, compact(program, REFERRAL_PROGRAM_KEYS));
|
|
1136
1701
|
}
|
|
1137
1702
|
});
|
|
1138
|
-
registerReadTool(server, {
|
|
1703
|
+
registerReadTool(server, ctx, {
|
|
1139
1704
|
name: "joy_get_referral_performance",
|
|
1140
1705
|
title: "Get referral program performance",
|
|
1141
1706
|
description: "Shop-level referral analytics. NOTE: the underlying API currently reports one metric only \u2014 how many referral coupon codes were used (i.e. completed referrals). It does NOT report clicks, signups, or revenue, and it does NOT tell you whether the program is enabled \u2014 use joy_get_referral_program for status/config. Optionally filter by date range.",
|
|
@@ -1152,7 +1717,7 @@ function registerReferralTools(server, ctx) {
|
|
|
1152
1717
|
return summaryWithJson(`${count} referral coupon(s) were used${range} (completed referrals).`, compact(data ?? {}));
|
|
1153
1718
|
}
|
|
1154
1719
|
});
|
|
1155
|
-
registerReadTool(server, {
|
|
1720
|
+
registerReadTool(server, ctx, {
|
|
1156
1721
|
name: "joy_get_member_referrals",
|
|
1157
1722
|
title: "Get one member's referral results",
|
|
1158
1723
|
description: "Referral results for ONE member (identified by email or Shopify customer ID). Returns (1) aggregate totals \u2014 coupons redeemed via their referrals and points they earned from referring \u2014 and (2) their recent referral activity records. To generate/fetch their personal invite LINK, use joy_create_referral_invite instead.",
|
|
@@ -1211,7 +1776,7 @@ function registerReferralWriteTools(server, ctx) {
|
|
|
1211
1776
|
// ../mcp-core/build/tools/overview.js
|
|
1212
1777
|
var OVERVIEW_TTL_MS = 15 * 60 * 1e3;
|
|
1213
1778
|
function registerOverviewTools(server, ctx) {
|
|
1214
|
-
registerReadTool(server, {
|
|
1779
|
+
registerReadTool(server, ctx, {
|
|
1215
1780
|
name: "joy_get_loyalty_overview",
|
|
1216
1781
|
title: "Get loyalty program overview",
|
|
1217
1782
|
description: 'Orient yourself: shop identity (name, domain, plan, currency, timezone) plus the loyalty program settings. Call this FIRST in a conversation before other Joy tools \u2014 it tells you which shop you are operating on and how its program is configured. Cached ~15 minutes; repeat calls within a conversation are free. This is configuration only \u2014 for performance numbers (revenue impact, points issued, member growth, program health) use the joy_get_* analytics report tools instead, and for "how many members does the shop have?" use joy_list_members with include_total_count.',
|
|
@@ -1341,13 +1906,13 @@ var reportDateRangeSchema = {
|
|
|
1341
1906
|
var SCOPE_HELP = "A 403 mentioning read_reports means the MERCHANT has not granted Shopify reports access to Joy \u2014 not a credential problem, do not retry; ask them to grant it from Joy settings.";
|
|
1342
1907
|
function withScopeGuidance(error) {
|
|
1343
1908
|
if (error instanceof JoyApiError && error.status === 403 && /read_reports/i.test(error.message)) {
|
|
1344
|
-
return new JoyApiError("forbidden", "This report needs the Shopify read_reports permission, which the merchant has not granted to Joy yet. This is NOT a credential problem \u2014 do NOT retry. Ask the merchant to grant reports access from the Joy admin settings, then call again.", 403);
|
|
1909
|
+
return new JoyApiError("forbidden", "This report needs the Shopify read_reports permission, which the merchant has not granted to Joy yet. This is NOT a credential problem \u2014 do NOT retry. Ask the merchant to grant reports access from the Joy admin settings, then call again.", 403, error.serverCode);
|
|
1345
1910
|
}
|
|
1346
1911
|
return error;
|
|
1347
1912
|
}
|
|
1348
1913
|
function withV2EnablementGuidance(error) {
|
|
1349
1914
|
if (error instanceof JoyApiError && error.status === 409 && /Analytics V2 is not enabled/i.test(error.message)) {
|
|
1350
|
-
return new JoyApiError("conflict", "This shop is still on Analytics V1 \u2014 Analytics V2 report data does not exist yet, so there are NO numbers to quote (do not report zeros). Ask the merchant if they want to switch to the new Analytics; switching also backfills their historical data. If they agree and the joy_enable_analytics_v2 tool is available, call it; otherwise tell them to switch in Joy admin \u2192 Analytics. Do not retry this report until V2 is enabled.", 409);
|
|
1915
|
+
return new JoyApiError("conflict", "This shop is still on Analytics V1 \u2014 Analytics V2 report data does not exist yet, so there are NO numbers to quote (do not report zeros). Ask the merchant if they want to switch to the new Analytics; switching also backfills their historical data. If they agree and the joy_enable_analytics_v2 tool is available, call it; otherwise tell them to switch in Joy admin \u2192 Analytics. Do not retry this report until V2 is enabled.", 409, error.serverCode);
|
|
1351
1916
|
}
|
|
1352
1917
|
return error;
|
|
1353
1918
|
}
|
|
@@ -1379,7 +1944,7 @@ async function getScopedReport(ctx, path, options = {}) {
|
|
|
1379
1944
|
}
|
|
1380
1945
|
}
|
|
1381
1946
|
function registerAnalyticsTools(server, ctx) {
|
|
1382
|
-
registerReadTool(server, {
|
|
1947
|
+
registerReadTool(server, ctx, {
|
|
1383
1948
|
name: "joy_get_analytics_report",
|
|
1384
1949
|
title: "Loyalty analytics report (Analytics V2)",
|
|
1385
1950
|
description: 'THE primary tool for loyalty analytics questions \u2014 members, VIP tiers, points economy, rewards ROI, coupons, referrals, and loyalty revenue impact. Retrieves any Analytics V2 report using the SAME data and cache as the Analytics dashboard the merchant sees in the Joy admin, so your numbers always match theirs. Joy-internal data \u2014 no Shopify permission needed. Defaults to the last 30 days (max 366-day range, cached server-side ~1h). Returns a pre-digested text summary (time series are collapsed to count/total/latest; tables capped at 8 rows). A 409 error means the shop is still on Analytics V1 (no V2 data exists \u2014 follow the error guidance about migrating, never report zeros); a summary starting with "WARNING: \u2026 backfill" means the historical backfill is unfinished and numbers may be incomplete \u2014 relay that caveat. For Shopify-sales-attributed revenue (order-tag attribution) use joy_get_loyalty_revenue_report instead \u2014 a DIFFERENT data source whose numbers may legitimately differ from assisted_revenue.\n' + V2_REPORT_MENU,
|
|
@@ -1407,7 +1972,7 @@ function registerAnalyticsTools(server, ctx) {
|
|
|
1407
1972
|
${summary}`);
|
|
1408
1973
|
}
|
|
1409
1974
|
});
|
|
1410
|
-
registerReadTool(server, {
|
|
1975
|
+
registerReadTool(server, ctx, {
|
|
1411
1976
|
name: "joy_get_loyalty_revenue_report",
|
|
1412
1977
|
title: "Loyalty revenue impact (Shopify sales data)",
|
|
1413
1978
|
description: `Loyalty revenue impact measured from SHOPIFY'S OWN sales dataset (ShopifyQL), attributed by the shop's "Joy assisted" order tag. Use for: loyalty ROI, "how much revenue does the loyalty program drive?", "is the program making money?", loyalty AOV, year-over-year (YoY) comparison. Metrics: total_sales = revenue from orders tagged "Joy assisted"; aov = average order value of those orders; returning_rate = returning-customer rate among customers tagged as active redeemers; orders = count of Joy-assisted orders. compare_to_last_year applies to total_sales ONLY and is IGNORED when a custom range is given. Custom range = BOTH start_date and end_date (ISO YYYY-MM-DD), max 366 days, not in the future. ` + SCOPE_HELP + " joy_get_analytics_report (reportType assisted_revenue) measures assisted revenue from Joy's own event log \u2014 a DIFFERENT data source with different attribution; the two numbers may legitimately disagree.",
|
|
@@ -1427,7 +1992,7 @@ ${summary}`);
|
|
|
1427
1992
|
return summaryWithJson(summary, compact(data, LOYALTY_REVENUE_KEYS));
|
|
1428
1993
|
}
|
|
1429
1994
|
});
|
|
1430
|
-
registerReadTool(server, {
|
|
1995
|
+
registerReadTool(server, ctx, {
|
|
1431
1996
|
name: "joy_get_tier_performance_breakdown",
|
|
1432
1997
|
title: "Sales performance by VIP tier",
|
|
1433
1998
|
description: 'Shopify sales performance broken down by VIP tier (ShopifyQL, grouped by the Joy tier metafield). Use for: "which tier spends the most?", "revenue by tier", "AOV per tier", "orders per customer by tier". Default window is ALL-TIME; pass BOTH start_date and end_date (YYYY-MM-DD, max 366 days) to bound it. Rows are tier names sorted by value; "(unassigned)" = customers without a tier. A 422 METAFIELD_NOT_CONFIGURED error means the Joy tier metafield definition is DISABLED on this shop \u2014 that is a setup gate, NOT empty data; ask the merchant to enable it in Joy \u2192 Settings \u2192 General \u2192 Metafield section. For loyalty-side tier reports (member counts, upgrades/downgrades) use joy_get_analytics_report with tier_performance / tier_trend / tier_transitions. ' + SCOPE_HELP,
|
|
@@ -1445,7 +2010,7 @@ ${summary}`);
|
|
|
1445
2010
|
});
|
|
1446
2011
|
} catch (error) {
|
|
1447
2012
|
if (error instanceof JoyApiError && error.status === 422) {
|
|
1448
|
-
throw new JoyApiError("validation", "Tier performance needs the Joy tier metafield definition, which is currently DISABLED on this shop. This is a setup gate, not empty data and not a parameter problem \u2014 do not retry. Ask the merchant to enable it in Joy \u2192 Settings \u2192 General \u2192 Metafield section, then call again.", 422);
|
|
2013
|
+
throw new JoyApiError("validation", "Tier performance needs the Joy tier metafield definition, which is currently DISABLED on this shop. This is a setup gate, not empty data and not a parameter problem \u2014 do not retry. Ask the merchant to enable it in Joy \u2192 Settings \u2192 General \u2192 Metafield section, then call again.", 422, error.serverCode);
|
|
1449
2014
|
}
|
|
1450
2015
|
throw error;
|
|
1451
2016
|
}
|
|
@@ -1454,7 +2019,7 @@ ${summary}`);
|
|
|
1454
2019
|
return summaryWithJson(summary, compact(data, TIER_PERFORMANCE_KEYS));
|
|
1455
2020
|
}
|
|
1456
2021
|
});
|
|
1457
|
-
registerReadTool(server, {
|
|
2022
|
+
registerReadTool(server, ctx, {
|
|
1458
2023
|
name: "joy_get_loyalty_health_check",
|
|
1459
2024
|
title: "Loyalty program health check",
|
|
1460
2025
|
description: `Loyalty program health diagnostic \u2014 use FIRST for open-ended questions: "how is my loyalty program doing?", "is anything wrong?", "why aren't customers earning points?". Compares the current window (default last 30 days) against the equal-length prior window using Joy-internal data only \u2014 aggregate numbers, no customer PII, and NO Shopify permission needed. healthScore = 100 minus a penalty per flag (low 5, medium 15, high 25; floor 0): >=80 Healthy, 60-79 Watch, <60 Needs attention. Flags check: sandbox/test earning mode (real orders are NOT earning points \u2014 the most urgent flag), redemption rate, points-earned trend, redeem-revenue trend, data availability, and tier concentration. Pass start_date+end_date (both, YYYY-MM-DD) to move the current window. For the underlying detail behind a flag, follow up with joy_get_analytics_report.`,
|
|
@@ -1505,19 +2070,19 @@ function resolveIdempotencyKey(provided) {
|
|
|
1505
2070
|
}
|
|
1506
2071
|
function rethrowWithRetryKey(error, idempotencyKey) {
|
|
1507
2072
|
if (error instanceof JoyApiError && (error.kind === "timeout" || error.kind === "network" || error.kind === "server")) {
|
|
1508
|
-
throw new JoyApiError(error.kind, `${error.modelMessage} IMPORTANT: this was a write \u2014 it may or may not have been applied. Do NOT assume failure and do NOT retry with a fresh call. To retry safely, call the same tool again with idempotency_key: "${idempotencyKey}" within 24 hours (keys expire after that) \u2014 the backend will return the original result instead of applying the change twice.`, error.status);
|
|
2073
|
+
throw new JoyApiError(error.kind, `${error.modelMessage} IMPORTANT: this was a write \u2014 it may or may not have been applied. Do NOT assume failure and do NOT retry with a fresh call. To retry safely, call the same tool again with idempotency_key: "${idempotencyKey}" within 24 hours (keys expire after that) \u2014 the backend will return the original result instead of applying the change twice.`, error.status, error.serverCode);
|
|
1509
2074
|
}
|
|
1510
2075
|
throw error;
|
|
1511
2076
|
}
|
|
1512
2077
|
function rethrowRedeemUncertain(error) {
|
|
1513
2078
|
if (error instanceof JoyApiError && (error.kind === "timeout" || error.kind === "network" || error.kind === "server")) {
|
|
1514
|
-
throw new JoyApiError(error.kind, `The redeem request failed with an UNCERTAIN outcome (${error.kind}) \u2014 it may have completed anyway, and redeem has NO retry protection. Do NOT retry now. First call joy_get_member_activity for this member and check for a redemption in the last few minutes: if one exists, the redeem succeeded (do not repeat it); only if none exists is it safe to try again.`, error.status);
|
|
2079
|
+
throw new JoyApiError(error.kind, `The redeem request failed with an UNCERTAIN outcome (${error.kind}) \u2014 it may have completed anyway, and redeem has NO retry protection. Do NOT retry now. First call joy_get_member_activity for this member and check for a redemption in the last few minutes: if one exists, the redeem succeeded (do not repeat it); only if none exists is it safe to try again.`, error.status, error.serverCode);
|
|
1515
2080
|
}
|
|
1516
2081
|
throw error;
|
|
1517
2082
|
}
|
|
1518
2083
|
function rethrowRefundUncertain(error) {
|
|
1519
2084
|
if (error instanceof JoyApiError && (error.kind === "timeout" || error.kind === "network" || error.kind === "server")) {
|
|
1520
|
-
throw new JoyApiError(error.kind, `The refund request failed with an UNCERTAIN outcome (${error.kind}) \u2014 it may have completed anyway, and refund has NO retry protection. Do NOT retry now. First call joy_get_member_activity for this member and look for a refund_point_redeem entry with this coupon code: if one exists, the refund succeeded and points were already restored (do not award them again). If the retry itself returns "coupon does not exist or has expired", that most likely means the first attempt SUCCEEDED \u2014 a revoked coupon no longer shows up in lookups.`, error.status);
|
|
2085
|
+
throw new JoyApiError(error.kind, `The refund request failed with an UNCERTAIN outcome (${error.kind}) \u2014 it may have completed anyway, and refund has NO retry protection. Do NOT retry now. First call joy_get_member_activity for this member and look for a refund_point_redeem entry with this coupon code: if one exists, the refund succeeded and points were already restored (do not award them again). If the retry itself returns "coupon does not exist or has expired", that most likely means the first attempt SUCCEEDED \u2014 a revoked coupon no longer shows up in lookups.`, error.status, error.serverCode);
|
|
1521
2086
|
}
|
|
1522
2087
|
throw error;
|
|
1523
2088
|
}
|
|
@@ -1695,7 +2260,7 @@ function registerPointWriteTools(server, ctx) {
|
|
|
1695
2260
|
inputSchema: {
|
|
1696
2261
|
...memberIdentifierSchema,
|
|
1697
2262
|
program_id: z7.string().describe("Redemption program ID (from joy_list_redemption_rules)."),
|
|
1698
|
-
redeem_points: z7.number().int().positive().describe("Points to spend
|
|
2263
|
+
redeem_points: z7.number().int().positive().describe("Points to spend for dynamic rewards only. Fixed rewards use the program's server-side cost.")
|
|
1699
2264
|
},
|
|
1700
2265
|
destructiveHint: true,
|
|
1701
2266
|
idempotentHint: false,
|
|
@@ -1717,11 +2282,14 @@ function registerPointWriteTools(server, ctx) {
|
|
|
1717
2282
|
}
|
|
1718
2283
|
const discount = result?.discount ?? {};
|
|
1719
2284
|
const customer = result?.customer ?? {};
|
|
2285
|
+
const chargedPoints = typeof result.redeemPoint === "number" && Number.isFinite(result.redeemPoint) ? result.redeemPoint : typeof member.point === "number" && typeof customer.point === "number" && Number.isFinite(member.point) && Number.isFinite(customer.point) && member.point >= customer.point ? member.point - customer.point : void 0;
|
|
1720
2286
|
if (!discount.code) {
|
|
1721
2287
|
throw new JoyApiError("server", "The redeem response did not include a coupon code \u2014 treat this redemption as NOT completed. Common causes: insufficient points, program unavailable, or gift out of stock. Check the member balance and program via joy_find_member / joy_list_redemption_rules, and verify no redemption was recorded via joy_get_member_activity before trying again.");
|
|
1722
2288
|
}
|
|
1723
|
-
|
|
2289
|
+
const redemptionSummary = Number.isFinite(chargedPoints) ? `Redeemed ${formatPoints(chargedPoints)} points from ${memberDisplayName(member)}` : `Redeemed a server-priced reward for ${memberDisplayName(member)}`;
|
|
2290
|
+
return summaryWithJson(`${redemptionSummary} for coupon "${String(discount.code)}" \u2014 balance now ${formatPoints(customer.point)}.`, {
|
|
1724
2291
|
coupon: compact(discount, ["code", "title", "expiredAt"]),
|
|
2292
|
+
charged_points: chargedPoints,
|
|
1725
2293
|
member_balance: customer.point
|
|
1726
2294
|
});
|
|
1727
2295
|
}
|
|
@@ -1798,7 +2366,7 @@ function toRetentionRiskQuery(args) {
|
|
|
1798
2366
|
return query;
|
|
1799
2367
|
}
|
|
1800
2368
|
function registerRetentionTools(server, ctx) {
|
|
1801
|
-
registerReadTool(server, {
|
|
2369
|
+
registerReadTool(server, ctx, {
|
|
1802
2370
|
name: "joy_get_retention_risk_members",
|
|
1803
2371
|
title: "Find members at risk of losing points or dropping a VIP tier",
|
|
1804
2372
|
description: 'READ-ONLY retention/outreach tool \u2014 "which members are about to lose points or drop a VIP tier, so I can proactively reach out?" Two independent signals via `signal`: "points_expiring" = members with points expiring within `window_days` (ONLY covers shops on earn-time/FIFO point expiration \u2014 shops using all-at-once expiration are not covered by this signal, so an empty result here does not mean no one is losing points); "tier_drop_risk" = members the REAL tier-demotion engine will demote soon \u2014 Joy demotes on INACTIVITY (no order since the tier program\'s configured demotion window), so a member is at risk when their last order is already past that cutoff (`daysUntilDemotion: 0` = demotable on the very next demotion run) or will pass it within `window_days` unless they order again; each row carries `demotion`: "down_one_tier" or "reset_to_lowest_tier" per the program\'s setting. A shop can also (or instead) run a SCHEDULED STORE-WIDE reset on a fixed calendar day \u2014 then the response carries `tier_scheduled_reset` (resetAt + daysUntilReset): EVERY member resets to the lowest tier on that date at once, so the correct answer is that DATE, not a per-member list. If the shop has tier demotion disabled, the response includes `tierDropRiskNote` saying the signal does not apply \u2014 report that note, never conclude "no one is at risk" from the empty list. Known limit: shops using assessment-based demotion (periodic spending re-evaluation) are not predicted by this signal. Optionally scope to one tier with `tier_id` (the lowest tier is never at risk \u2014 nothing to drop to). Default "both" returns both signals in one call. Results are HARD-CAPPED: window_days max 30, limit max 50 per signal (values above the cap are silently clamped, not rejected) \u2014 this is a shortlist for outreach, never the full at-risk population. Returns only the fields needed to act (name/email, expiring points/days, last-order date/days-until-demotion) plus has_more \u2014 never the member\'s full activity history.',
|
|
@@ -1876,7 +2444,7 @@ function shapeSupportingData(supportingData) {
|
|
|
1876
2444
|
return shaped;
|
|
1877
2445
|
}
|
|
1878
2446
|
function registerRiskTools(server, ctx) {
|
|
1879
|
-
registerReadTool(server, {
|
|
2447
|
+
registerReadTool(server, ctx, {
|
|
1880
2448
|
name: "joy_investigate_member_risk",
|
|
1881
2449
|
title: "Investigate a member for referral/redemption abuse risk",
|
|
1882
2450
|
description: `READ-ONLY fraud/abuse investigation for ONE member \u2014 "is this member doing anything suspicious?" Checks a FIXED, hard-coded set of flags (the same rules run every time, not a judgment call): referral_email_similarity (this member referred someone whose email closely matches their own \u2014 e.g. john+1@/john+2@ patterns); referral_device_match (this member's CURRENT last-known IP/device value matches their referrer's CURRENT last-known value); referee_had_prior_order (this member already had an order before being referred, so was not actually a new customer); high_daily_redemptions (redeemed more than a fixed threshold of rewards today). The response includes supporting data ONLY for flags that actually fired (e.g. the masked similar-email referrals, or today's redemption list) \u2014 if nothing is flagged, no supporting data is returned; this is NOT a full activity/history dump, so do not ask follow-up questions expecting more raw data from this same call. CRITICAL DATA LIMITS \u2014 do not claim more than this: there is NO IP/device HISTORY anywhere in Joy, only a single last-known value per customer that gets overwritten on every storefront visit \u2014 so referral_device_match is a live snapshot comparison, NOT a check of what the IP/device was at the actual moment the referral was claimed (that comparison happens live during the claim and is never stored). Do NOT say things like "this customer used N different IPs" or "here is their IP address" \u2014 that data does not exist and IPs are never returned, only a boolean match. Redemption velocity is checked at DAY granularity only \u2014 there is no minute/hour-level velocity check, so do not claim to detect rapid-fire redemption bursts within a single day. This tool never locks accounts, cancels rewards, or takes any action \u2014 it only surfaces flags for the merchant to review and decide next steps.`,
|
|
@@ -1917,7 +2485,7 @@ function describeRule(rule) {
|
|
|
1917
2485
|
return `${title}: did not match${reasons ? ` (${reasons})` : ""}`;
|
|
1918
2486
|
}
|
|
1919
2487
|
function registerOrderExplainTools(server, ctx) {
|
|
1920
|
-
registerReadTool(server, {
|
|
2488
|
+
registerReadTool(server, ctx, {
|
|
1921
2489
|
name: "joy_explain_order_points",
|
|
1922
2490
|
title: "Explain why an order did or didn't earn points",
|
|
1923
2491
|
description: "READ-ONLY explanation of the loyalty points earning rule engine for ONE order \u2014 \"why did/didn't this order earn points (or earn fewer than expected)?\" The response's `explainSource` field tells you which of two modes produced it: `recorded` means the answer comes from the decision-time trace the REAL earn pipeline stored on the order's activity when it was processed (historical ground truth \u2014 per-rule pass/fail with the exact condition fields that failed, recorded at `recordedAt`); `replay` means no stored trace exists (order processed before tracing shipped, or a legacy calculation path) and this is a RECONSTRUCTION run against the order's CURRENT Shopify snapshot, reusing the exact same gate and rule-matching functions the real earn pipeline uses \u2014 NOT a historical record. If the order was modified after points were originally calculated (e.g. a partial refund applied after the initial award), this tool reports against the order's current state, which may not match what was actually awarded at the time \u2014 do not present this as infallible ground truth for what happened historically; treat it as \"what the rules say NOW\". Evaluation order: (1) pre-rule-engine gates \u2014 no customer matched on the order, guest checkout not opted in, customer left the loyalty program, order not paid/fulfilled per the shop's setting, order fully refunded, or an update arriving after the order was already closed; if any gate blocks, `blockedByGate` explains which one and no rules were evaluated. (2) If gates pass, every active place-order earning rule is evaluated in priority order (`rules`), each with `matched` true/false/null and human-readable `reasons` \u2014 `matched: null` means the rule was NEVER evaluated because a higher-priority rule matched first with \"stop processing\" enabled (distinct from `false`, which means it WAS evaluated and did not match). (3) `maxTheoreticalPoints` is the total before the global per-customer/tier earning cap; `totalEarnedPoints` is after; `cappedBy` is set only when that cap actually reduced the total. Scope: covers the place_order (checkout) earning event only \u2014 birthday, sign-up, and milestone rewards are NOT covered by this tool.",
|
|
@@ -2004,7 +2572,7 @@ function toActivityAuditQuery(args) {
|
|
|
2004
2572
|
return query;
|
|
2005
2573
|
}
|
|
2006
2574
|
function registerActivityAuditTools(server, ctx) {
|
|
2007
|
-
registerReadTool(server, {
|
|
2575
|
+
registerReadTool(server, ctx, {
|
|
2008
2576
|
name: "joy_list_activities",
|
|
2009
2577
|
title: "List loyalty activities across members (audit by activity group)",
|
|
2010
2578
|
description: 'READ-ONLY shop-wide activity audit \u2014 "WHICH members did X in this period", the cross-member counterpart of joy_get_member_activity (one member\'s history) and of joy_get_analytics_report report_type=tier_transitions (aggregate COUNTS only, no names). Use this when the merchant asks WHO, use analytics when they ask HOW MANY. One semantic `group` per call, each spanning every raw activity type that feature area produces: "tier_downgraded" covers ALL THREE demotion mechanisms (reassessment demote_tier, inactivity downgrade_tier, relaunch reset_tier \u2014 never query one of these alone or you will miss mechanisms); "tier_upgraded" = tier upgrades; "points_earned" = program point earns (rows include orderName when the earn came from an order); "points_redeemed" = redemptions web + POS (each row carries programId/programTitle \u2014 combine with `program_id` for "who redeemed program A?"); "points_refunded" / "points_expired" / "points_adjusted" = the corresponding point movements; "referral" = referral outcomes (referred members, referred orders, referral rewards + revocations \u2014 rows carry referralCustomerEmail linking referrer and referee); "members_joined" = joins/rejoins. Time range: from_date/to_date (defaults to the last 30 days, range hard-capped at 366 days \u2014 an over-wide range is clamped to the most recent 366 days, not rejected). Results are a HARD-CAPPED shortlist: limit max 50 (default 20), newest first, plus has_more \u2014 never the full population; treat has_more:true as "narrow the range or filter, do not page exhaustively".',
|
|
@@ -2061,7 +2629,7 @@ function toTierUpgradeQuery(args) {
|
|
|
2061
2629
|
return query;
|
|
2062
2630
|
}
|
|
2063
2631
|
function registerTierUpgradeTools(server, ctx) {
|
|
2064
|
-
registerReadTool(server, {
|
|
2632
|
+
registerReadTool(server, ctx, {
|
|
2065
2633
|
name: "joy_get_tier_upgrade_candidates",
|
|
2066
2634
|
title: "Find members close to reaching the next VIP tier",
|
|
2067
2635
|
description: 'READ-ONLY growth/upsell outreach tool \u2014 "which members are ABOUT TO reach the next VIP tier, so I can nudge them over the line?" (e.g. "kh\xE1ch n\xE0o s\u1EAFp l\xEAn Gold?"). The mirror of joy_get_retention_risk_members\' tier_drop_risk signal: that one finds members about to FALL, this one finds members about to CLIMB. Upgrades in Joy apply immediately when tierPoint reaches the next tier\'s threshold, so distance-to-threshold is the exact signal \u2014 each row reports the member\'s current tier, tierPoint, the next tier and its threshold, and `pointsToNextTier` (how little they still need). Sorted closest-first. `margin_points` defines "close" (default: 10% of the next tier\'s own threshold, so it scales with the shop); note tierPoint is measured in the tier program\'s entry metric \u2014 points, money spent, or order count depending on the program setup \u2014 so quote distances in that unit, not always "points". Optionally scope to one CURRENT tier with `tier_id` (from joy_list_tiers); the highest tier is never a candidate (nothing to climb to), exclusive/VIP-deal tiers are excluded on both ends. Results are a HARD-CAPPED shortlist (limit max 50, default 20, split evenly across tiers when unscoped) plus has_more \u2014 never the full population. For ONE member\'s own distance to the next tier, prefer joy_find_member (field pointsRemain) instead.',
|
|
@@ -2087,8 +2655,290 @@ function registerTierUpgradeTools(server, ctx) {
|
|
|
2087
2655
|
});
|
|
2088
2656
|
}
|
|
2089
2657
|
|
|
2090
|
-
// ../mcp-core/build/tools/
|
|
2658
|
+
// ../mcp-core/build/tools/loyaltyPage.js
|
|
2091
2659
|
import { z as z13 } from "zod";
|
|
2660
|
+
function summarizeDesign(data) {
|
|
2661
|
+
const sections = data.design?.sections ?? [];
|
|
2662
|
+
const on = sections.filter((section) => section.enabled !== false).map((section) => section.id);
|
|
2663
|
+
const legacyWarning = data.renderPath === "legacy-blocks" ? `\u26A0 The storefront loyalty page renders through legacy blocks, so new loyalty page design changes will not appear there. The merchant must open ${data.v2BlockAddUrl || "the new loyalty page block link from joy_check_theme_setup"}, add the Joy: New Loyalty Page block, and save the theme. ` : "";
|
|
2664
|
+
let live = "The shop still serves the classic loyalty page, so changes here are not visible to customers yet.";
|
|
2665
|
+
if (data.pageVersionActive && (data.renderPath === "v2-block" || data.renderPath === void 0)) {
|
|
2666
|
+
live = "This page IS the shop\u2019s live loyalty page \u2014 changes are visible to customers.";
|
|
2667
|
+
}
|
|
2668
|
+
if (data.pageVersionActive && data.renderPath === "legacy-blocks") {
|
|
2669
|
+
live = "The new loyalty page is active for this shop.";
|
|
2670
|
+
}
|
|
2671
|
+
if (data.pageVersionActive && data.renderPath === "none") {
|
|
2672
|
+
live = "The new loyalty page is active for this shop, but no loyalty page block was detected in the live theme, so changes are not visible to customers.";
|
|
2673
|
+
}
|
|
2674
|
+
const css = data.canEditCustomCss ? "" : " Custom CSS is not available on this shop\u2019s plan.";
|
|
2675
|
+
return `${legacyWarning}New loyalty page design. Sections on: ${on.join(", ") || "none"}. ${live}${css}`;
|
|
2676
|
+
}
|
|
2677
|
+
function summarizeAppliedChanges(applied2, published, warnings2 = []) {
|
|
2678
|
+
const headline = applied2.slice(0, 5).map((change) => `${change.field}: ${String(change.from ?? "\u2014")} \u2192 ${String(change.to)}`).join("; ");
|
|
2679
|
+
const more = applied2.length > 5 ? ` (+${applied2.length - 5} more)` : "";
|
|
2680
|
+
const where = published ? "Published: live on the storefront now." : "Saved to the draft \u2014 visible in the merchant\u2019s editor now, and to customers once they publish.";
|
|
2681
|
+
const caveat = warnings2.length ? `\u26A0 ${warnings2.join(" \u26A0 ")} ` : "";
|
|
2682
|
+
return `${caveat}${applied2.length} design field${applied2.length === 1 ? "" : "s"} changed \u2014 ${headline}${more}. ${where} Undo with joy_revert_loyalty_page_design.`;
|
|
2683
|
+
}
|
|
2684
|
+
function registerLoyaltyPageTools(server, ctx) {
|
|
2685
|
+
registerReadTool(server, ctx, {
|
|
2686
|
+
name: "joy_get_loyalty_page_design",
|
|
2687
|
+
title: "Get loyalty page design",
|
|
2688
|
+
description: "Read the shop's new loyalty page appearance \u2014 page theme (colours, fonts, radii, widths), which sections are switched on, and each section's layout and card styling. It also audits whether the live theme renders this design through the new loyalty page block, legacy blocks, or no loyalty page block; when legacy blocks are present, the result leads with the server-provided theme-editor link the merchant must use to add and save the new loyalty page block. This read-only tool cannot add or enable a theme block itself. Also returns `writableFields`: the exact field names joy_update_loyalty_page_design accepts, per section. CALL THIS BEFORE any design change \u2014 the field vocabulary is large and specific, and guessing a name costs a failed call. This is appearance only: page text (headings, FAQ questions, program descriptions) is NOT here and cannot be changed through these tools.",
|
|
2689
|
+
inputSchema: {},
|
|
2690
|
+
handler: async () => {
|
|
2691
|
+
const { data } = await ctx.api.getData("/loyalty-page/design");
|
|
2692
|
+
return summaryWithJson(summarizeDesign(data), data);
|
|
2693
|
+
}
|
|
2694
|
+
});
|
|
2695
|
+
}
|
|
2696
|
+
function registerLoyaltyPageWriteTools(server, ctx) {
|
|
2697
|
+
registerWriteTool(server, ctx, {
|
|
2698
|
+
name: "joy_update_loyalty_page_design",
|
|
2699
|
+
title: "Update loyalty page design",
|
|
2700
|
+
// Not idempotent in the annotation sense: repeating the call is harmless for
|
|
2701
|
+
// the page, but each call overwrites the single undo snapshot, so a second
|
|
2702
|
+
// identical call is not a no-op for what revert would restore.
|
|
2703
|
+
destructiveHint: false,
|
|
2704
|
+
idempotentHint: false,
|
|
2705
|
+
description: "Change the appearance of the shop's new loyalty page, including its media and program or tier artwork. Send ONLY the fields you are changing \u2014 anything you omit keeps its current value. WARNING: the `icons` branch has no draft \u2014 program and tier artwork is live data, so those changes reach shoppers immediately even when publish is false, and they also appear anywhere else that reads the program or tier. Tier artwork is dual-written to `imageBlock` for the loyalty page and `icon` for the tier-achieved email, membership card Widget V4, and account block: changing a tier icon changes those email and widget surfaces too, not just this page. For image artwork, first call joy_upload_image, then pass its returned URL to `hero.backgroundImage`, `hero.mobileBackgroundImage`, `inlineBanner.backgroundImage`, `inlineBanner.mobileBackgroundImage`, `earn.bannerImage`, `referral.image`, or `icons`; the other media fields are `hero.backgroundVideo` and `hero.mobileBackgroundVideo`. Image URLs must use http: or https: and contain no username or password; video URLs must use https: and end in .mp4. For a storefront-based starting point, send `applyScan` inside `design` in this tool \u2014 there is no separate apply-scan tool. Call joy_get_loyalty_page_design first and use the field names from its `writableFields`; a name outside that list is refused with the offending path rather than silently ignored. Saves to the DRAFT: the merchant sees it in their loyalty page editor immediately, and customers see it when they publish. Iterate freely here \u2014 refining a design costs nothing. Only pass publish=true when the merchant has asked for the change to go live, because publishing writes a Shopify metafield and re-syncs the FAQ projection every time. Undo with joy_revert_loyalty_page_design. Appearance only \u2014 this cannot change any text on the page.",
|
|
2706
|
+
inputSchema: {
|
|
2707
|
+
design: z13.record(z13.unknown()).describe('The fields to change, shaped like the `design` object from joy_get_loyalty_page_design. Branches: `theme` (page-wide tokens), `sections` (array of {id, enabled?, settings:{theme}}), `content` (per-section layout/style, keyed by section id), `advanced` ({customCss}), `icons` ({earning?:{...}, spending?:{...}, tiers?:{...}|[...]}, valued with an image URL or a built-in icon name), and `applyScan` ({url?: string, storefrontPassword?: string, colors?: boolean, images?: boolean}). Address earning/spending artwork by program id OR event; address tier artwork by tier id OR tier name (`name`/`title`), or pass an array in tier order. A tier artwork write updates both `imageBlock` (loyalty page) and `icon` (tier-achieved email, membership card Widget V4, and account block), so it changes email and widget surfaces too. Read current program artwork from joy_list_earning_rules / joy_list_redemption_rules. For `applyScan`, url defaults to this shop\'s storefront, storefrontPassword is only for a gated store, colors defaults to true, and images defaults to false so a scan does not unexpectedly replace artwork. If the scan returns 409 PASSWORD_REQUIRED, ask the merchant for the storefront password and pass it as `storefrontPassword`; never guess it. If it fails with 503, do not invent colours, fonts, or button styling; ask the merchant to retry later or configure the draft manually. Scan output is design input, not visual evidence of how the page looks after the change. NOTE on precedence: a value under `sections[].settings.theme` BEATS the same key in `theme`, so recolouring a page whose sections carry overrides means patching both. Example: {"theme":{"primary":"#0F4C3A"},"content":{"earn":{"columns":2}},"icons":{"earning":{"birthday":"https://cdn.../cake.png"}}}'),
|
|
2708
|
+
publish: z13.boolean().optional().describe("Default false \u2014 the change lands on the draft, which the merchant sees in the loyalty page editor and nobody else. Pass true only when they asked for it to go live; each publish costs a Shopify metafield write, so do not send it on every tweak.")
|
|
2709
|
+
},
|
|
2710
|
+
handler: async (args) => {
|
|
2711
|
+
const result = await ctx.api.mutateData("/loyalty-page/design", {
|
|
2712
|
+
method: "PATCH",
|
|
2713
|
+
timeoutMs: 2e4,
|
|
2714
|
+
body: { design: args.design, ...args.publish === void 0 ? {} : { publish: args.publish } }
|
|
2715
|
+
});
|
|
2716
|
+
const applied2 = result?.applied ?? [];
|
|
2717
|
+
if (!applied2.length) {
|
|
2718
|
+
return "No change \u2014 every field you sent already had that value.";
|
|
2719
|
+
}
|
|
2720
|
+
const published = result?.published ?? false;
|
|
2721
|
+
const warnings2 = result?.warnings ?? [];
|
|
2722
|
+
return summaryWithJson(summarizeAppliedChanges(applied2, published, warnings2), {
|
|
2723
|
+
applied: applied2,
|
|
2724
|
+
published,
|
|
2725
|
+
...warnings2.length ? { warnings: warnings2 } : {}
|
|
2726
|
+
});
|
|
2727
|
+
}
|
|
2728
|
+
});
|
|
2729
|
+
registerWriteTool(server, ctx, {
|
|
2730
|
+
name: "joy_revert_loyalty_page_design",
|
|
2731
|
+
title: "Revert the last loyalty page design change",
|
|
2732
|
+
// It removes work the merchant may have wanted, and calling it twice
|
|
2733
|
+
// restores the same snapshot rather than stepping further back.
|
|
2734
|
+
destructiveHint: true,
|
|
2735
|
+
idempotentHint: true,
|
|
2736
|
+
description: "Undo the last design change made through joy_update_loyalty_page_design, restoring the page exactly as it was before that call \u2014 including any program or tier artwork it changed, which goes back LIVE immediately because artwork has no draft \u2014 and republish. ONE step only \u2014 it is not a history, and a second call restores the same snapshot again rather than going further back. WARNING: it restores a whole saved design, so any edit the merchant made in the admin editor AFTER that update is discarded too \u2014 if they may have been editing, ask before calling this. Use it promptly when a change is judged wrong; the next update overwrites the snapshot.",
|
|
2737
|
+
inputSchema: {
|
|
2738
|
+
publish: z13.boolean().optional().describe("Default false \u2014 the restored design lands on the draft. Pass true to put it back on the storefront immediately.")
|
|
2739
|
+
},
|
|
2740
|
+
handler: async (args) => {
|
|
2741
|
+
const result = await ctx.api.mutateData("/loyalty-page/design/revert", {
|
|
2742
|
+
method: "POST",
|
|
2743
|
+
timeoutMs: 2e4,
|
|
2744
|
+
body: args.publish === void 0 ? {} : { publish: args.publish }
|
|
2745
|
+
});
|
|
2746
|
+
const programIcons = result?.programIconsRestored ?? 0;
|
|
2747
|
+
const tierIcons = result?.tierIconsRestored ?? 0;
|
|
2748
|
+
const icons = programIcons || tierIcons ? ` ${[
|
|
2749
|
+
programIcons ? `${programIcons} program icon${programIcons === 1 ? "" : "s"}` : "",
|
|
2750
|
+
tierIcons ? `${tierIcons} tier icon${tierIcons === 1 ? "" : "s"}` : ""
|
|
2751
|
+
].filter(Boolean).join(" and ")} were put back too, and artwork has no draft \u2014 those are LIVE now.` : "";
|
|
2752
|
+
return summaryWithJson(`Reverted to the design from before the last update. ${result?.published ? "Live on the storefront again." : "The page is restored on the draft \u2014 publish to put it back for customers."}${icons}`, { design: result?.design, programIconsRestored: programIcons, tierIconsRestored: tierIcons });
|
|
2753
|
+
}
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
// ../mcp-core/build/tools/loyaltyPageText.js
|
|
2758
|
+
import { z as z14 } from "zod";
|
|
2759
|
+
var localeSchema = z14.string().min(2).optional().describe("Active locale code. Omit to use the shop's primary language.");
|
|
2760
|
+
function registerLoyaltyPageTextTools(server, ctx) {
|
|
2761
|
+
registerReadTool(server, ctx, {
|
|
2762
|
+
name: "joy_get_loyalty_page_text",
|
|
2763
|
+
title: "Get loyalty page text",
|
|
2764
|
+
description: "New loyalty page text has no draft: text edits are LIVE on the storefront immediately. Read the current text for one active locale, the placeholders each value must preserve, and the stable ids/keys for steps and tier benefits. CALL THIS BEFORE joy_update_loyalty_page_text; never guess keys or item positions. Editing the primary language automatically retranslates only those changed keys into every other active locale. `points.singular` and `points.plural` are shown for context but are read-only through this tool surface.",
|
|
2765
|
+
inputSchema: { locale: localeSchema },
|
|
2766
|
+
handler: async (args) => {
|
|
2767
|
+
const { data } = await ctx.api.getData("/loyalty-page/text", {
|
|
2768
|
+
query: { locale: args.locale }
|
|
2769
|
+
});
|
|
2770
|
+
return summaryWithJson(`${Object.keys(data.values).length} live new loyalty page text values for ${data.locale}. Primary language: ${data.primaryLanguage}; active locales: ${data.activeLocales.join(", ")}.`, data);
|
|
2771
|
+
}
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
function registerLoyaltyPageTextWriteTools(server, ctx) {
|
|
2775
|
+
registerWriteTool(server, ctx, {
|
|
2776
|
+
name: "joy_update_loyalty_page_text",
|
|
2777
|
+
title: "Update loyalty page text",
|
|
2778
|
+
destructiveHint: true,
|
|
2779
|
+
idempotentHint: false,
|
|
2780
|
+
description: "New loyalty page text has no draft: every accepted change is LIVE on the storefront immediately. Update only exact keys returned by joy_get_loyalty_page_text; unknown or duplicate keys are skipped. Preserve every returned placeholder unless the merchant explicitly authored the replacement, in which case pass manual: true. Editing the primary language automatically retranslates the changed keys into every other active locale in the same operation; editing a secondary locale changes only that locale. This can replace merchant-authored translations for those same keys. Undo the latest text or design update with joy_revert_loyalty_page_design.",
|
|
2781
|
+
inputSchema: {
|
|
2782
|
+
locale: localeSchema,
|
|
2783
|
+
changes: z14.array(z14.object({
|
|
2784
|
+
key: z14.string().min(1),
|
|
2785
|
+
value: z14.string(),
|
|
2786
|
+
manual: z14.boolean().optional().describe("Set true only when the merchant authored this exact replacement.")
|
|
2787
|
+
})).min(1).describe("Exact key/value edits from joy_get_loyalty_page_text.")
|
|
2788
|
+
},
|
|
2789
|
+
handler: async (args) => {
|
|
2790
|
+
const result = await ctx.api.mutateData("/loyalty-page/text", {
|
|
2791
|
+
method: "PATCH",
|
|
2792
|
+
timeoutMs: 12e4,
|
|
2793
|
+
body: { changes: args.changes, ...args.locale ? { locale: args.locale } : {} }
|
|
2794
|
+
});
|
|
2795
|
+
const propagation = result.translatedLocales.length ? ` Retranslated in ${result.translatedLocales.join(", ")}.` : "";
|
|
2796
|
+
const skipped = result.skipped.length ? ` ${result.skipped.length} change(s) skipped.` : "";
|
|
2797
|
+
return summaryWithJson(`\u26A0 New loyalty page text has no draft \u2014 ${result.applied.length} text change(s) are LIVE now.${propagation}${skipped} Undo with joy_revert_loyalty_page_design.`, result);
|
|
2798
|
+
}
|
|
2799
|
+
});
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
// ../mcp-core/build/tools/loyaltyHub.js
|
|
2803
|
+
import { z as z15 } from "zod";
|
|
2804
|
+
function summarizeHubDesign(data) {
|
|
2805
|
+
const sections = data.design?.sections ?? [];
|
|
2806
|
+
const on = sections.filter((s) => s.enabled !== false).map((s) => s.id);
|
|
2807
|
+
return `Loyalty Hub design. Sections on: ${on.join(", ") || "none"}. The Hub is LIVE \u2014 every logged-in customer sees changes immediately. Programs, tier icons and program icons are shared with the Loyalty Page, widget and emails.`;
|
|
2808
|
+
}
|
|
2809
|
+
function summarizeHubAppliedChanges(applied2, warnings2 = []) {
|
|
2810
|
+
const headline = applied2.slice(0, 5).join("; ");
|
|
2811
|
+
const more = applied2.length > 5 ? ` (+${applied2.length - 5} more)` : "";
|
|
2812
|
+
const caveat = warnings2.length ? `\u26A0 ${warnings2.join(" \u26A0 ")} ` : "";
|
|
2813
|
+
return `${caveat}${applied2.length} design field${applied2.length === 1 ? "" : "s"} changed \u2014 ${headline}${more}. LIVE now for every logged-in customer. Undo with joy_revert_loyalty_hub_design.`;
|
|
2814
|
+
}
|
|
2815
|
+
function registerLoyaltyHubTools(server, ctx) {
|
|
2816
|
+
registerReadTool(server, ctx, {
|
|
2817
|
+
name: "joy_get_loyalty_hub_design",
|
|
2818
|
+
title: "Get Loyalty Hub design",
|
|
2819
|
+
description: "Read the shop's Loyalty Hub appearance \u2014 which sections are switched on and their layout and styling, plus the shared program and tier icons. Also returns `writableFields`: the exact field names joy_update_loyalty_hub_design accepts, with the allowed values of each. redeemButtonVariant (secondary by default, or primary) changes only voucher-card Redeem buttons. Section card border overrides are rewardsCardBorderStyle (My coupons), earnCardBorderStyle (Ways to earn), redeemCardBorderStyle (Redeem), and vipCardBorderStyle (VIP); each accepts inherit (default), solid, or dashed. Inherit follows the global border style. IMPORTANT: the Loyalty Hub has NO draft \u2014 every change is LIVE for every logged-in customer immediately. Programs, program icons and tier icons are SHARED with the Loyalty Page, widget and emails: changing them here also changes those other surfaces. CALL THIS BEFORE any design change \u2014 the field vocabulary is large and specific, and guessing a name costs a failed call. This tool reads appearance only: Hub text (headings, descriptions) is read by joy_get_loyalty_hub_text.",
|
|
2820
|
+
inputSchema: {},
|
|
2821
|
+
handler: async () => {
|
|
2822
|
+
const { data } = await ctx.api.getData("/loyalty-hub/design");
|
|
2823
|
+
return summaryWithJson(summarizeHubDesign(data), data);
|
|
2824
|
+
}
|
|
2825
|
+
});
|
|
2826
|
+
}
|
|
2827
|
+
function registerLoyaltyHubWriteTools(server, ctx) {
|
|
2828
|
+
registerWriteTool(server, ctx, {
|
|
2829
|
+
name: "joy_update_loyalty_hub_design",
|
|
2830
|
+
title: "Update Loyalty Hub design",
|
|
2831
|
+
destructiveHint: false,
|
|
2832
|
+
idempotentHint: true,
|
|
2833
|
+
description: "Change the Loyalty Hub's appearance. WARNING: the Hub has NO draft \u2014 every accepted change is LIVE for every logged-in customer immediately. Send ONLY the fields you are changing; anything omitted keeps its current value. redeemButtonVariant (secondary by default, or primary) changes only voucher-card Redeem buttons. Section card border overrides: rewardsCardBorderStyle (My coupons), earnCardBorderStyle (Ways to earn), redeemCardBorderStyle (Redeem), vipCardBorderStyle (VIP). Each accepts inherit (default), solid, or dashed and changes only card border line style. Inherit follows the global border style; border width, radius, background, density, inner separators and current-tier highlights remain unchanged. Programs, tier icons and program icons are SHARED with the Loyalty Page, widget and emails \u2014 changing them here also changes those other surfaces. If the merchant has the Hub editor open, it will ask them to reload. Call joy_get_loyalty_hub_design first and use the field names from its `writableFields`; a name outside that list is refused with the offending path rather than silently ignored. Undo with joy_revert_loyalty_hub_design. Appearance only \u2014 this cannot change any text on the Hub.",
|
|
2834
|
+
inputSchema: {
|
|
2835
|
+
design: z15.record(z15.unknown()).describe('The fields to change. Flat Hub keys named in `writableFields` from joy_get_loyalty_hub_design (section switches, layouts, visible counts, hero image, Redeem-only redeemButtonVariant: secondary|primary, section border overrides rewardsCardBorderStyle/earnCardBorderStyle/redeemCardBorderStyle/vipCardBorderStyle: inherit|solid|dashed (default inherit follows global), and the Design tab: cardBorderWidth, cardBorderStyle, cardRadius, cardBackground, density, earnIconColor as #RRGGBB), plus optional `programs` ({earning?:[{id, showLoyaltyPage}], spending?:[...]}), `programOrder` ({earning?:[program ids], spending?:[...]}, position sets the order) and `icons` ({earning?:{...}, spending?:{...}, tiers?:{...}|[...]}, valued with an image URL or a built-in icon name; address earning/spending artwork by program id OR event, tiers by tier id, tier name or array position). Read current program artwork from joy_list_earning_rules / joy_list_redemption_rules. Example: {"cardRadius":"max","earnIconColor":"#0F4C3A","icons":{"earning":{"birthday":"https://cdn.../cake.png"}}}')
|
|
2836
|
+
},
|
|
2837
|
+
handler: async (args) => {
|
|
2838
|
+
const result = await ctx.api.mutateData("/loyalty-hub/design", {
|
|
2839
|
+
method: "PATCH",
|
|
2840
|
+
timeoutMs: 2e4,
|
|
2841
|
+
body: { design: args.design }
|
|
2842
|
+
});
|
|
2843
|
+
const applied2 = result?.applied ?? [];
|
|
2844
|
+
if (!applied2.length) {
|
|
2845
|
+
return "No change \u2014 every field you sent already had that value.";
|
|
2846
|
+
}
|
|
2847
|
+
const warnings2 = result?.warnings ?? [];
|
|
2848
|
+
return summaryWithJson(summarizeHubAppliedChanges(applied2, warnings2), {
|
|
2849
|
+
applied: applied2,
|
|
2850
|
+
...warnings2.length ? { warnings: warnings2 } : {}
|
|
2851
|
+
});
|
|
2852
|
+
}
|
|
2853
|
+
});
|
|
2854
|
+
registerWriteTool(server, ctx, {
|
|
2855
|
+
name: "joy_revert_loyalty_hub_design",
|
|
2856
|
+
title: "Revert the last Loyalty Hub design change",
|
|
2857
|
+
destructiveHint: true,
|
|
2858
|
+
idempotentHint: true,
|
|
2859
|
+
description: "Undo the last design change made through joy_update_loyalty_hub_design, restoring the Hub exactly as it was before that call \u2014 LIVE immediately for every logged-in customer. ONE step only \u2014 a second call restores the same snapshot again rather than going further back. Program and tier icons go back LIVE immediately because they are shared data with no draft. WARNING: it restores a whole saved design, so any edit the merchant made in the Hub editor AFTER that update is discarded too \u2014 if they may have been editing, ask before calling this. Use it promptly; the next update overwrites the snapshot.",
|
|
2860
|
+
inputSchema: {},
|
|
2861
|
+
handler: async () => {
|
|
2862
|
+
const result = await ctx.api.mutateData("/loyalty-hub/design/revert", {
|
|
2863
|
+
method: "POST",
|
|
2864
|
+
timeoutMs: 2e4,
|
|
2865
|
+
body: {}
|
|
2866
|
+
});
|
|
2867
|
+
const programIcons = result?.programIconsRestored ?? 0;
|
|
2868
|
+
const tierIcons = result?.tierIconsRestored ?? 0;
|
|
2869
|
+
const icons = programIcons || tierIcons ? ` ${[
|
|
2870
|
+
programIcons ? `${programIcons} program icon${programIcons === 1 ? "" : "s"}` : "",
|
|
2871
|
+
tierIcons ? `${tierIcons} tier icon${tierIcons === 1 ? "" : "s"}` : ""
|
|
2872
|
+
].filter(Boolean).join(" and ")} restored \u2014 LIVE now (shared data, no draft).` : "";
|
|
2873
|
+
return summaryWithJson(`Reverted to the Loyalty Hub design from before the last update. LIVE immediately for every logged-in customer.${icons}`, { design: result?.design, programIconsRestored: programIcons, tierIconsRestored: tierIcons });
|
|
2874
|
+
}
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
// ../mcp-core/build/tools/loyaltyHubText.js
|
|
2879
|
+
import { z as z16 } from "zod";
|
|
2880
|
+
var localeSchema2 = z16.string().min(2).optional().describe("Active locale code. Omit to use the shop's primary language.");
|
|
2881
|
+
function registerLoyaltyHubTextTools(server, ctx) {
|
|
2882
|
+
registerReadTool(server, ctx, {
|
|
2883
|
+
name: "joy_get_loyalty_hub_text",
|
|
2884
|
+
title: "Get Loyalty Hub text",
|
|
2885
|
+
description: "Loyalty Hub text has no draft: text edits are LIVE on the Hub immediately. Read the current text for one active locale, the placeholders each value must preserve, and the stable ids/keys for steps and tier benefits. CALL THIS BEFORE joy_update_loyalty_hub_text; never guess keys or item positions. Editing the primary language automatically retranslates only those changed keys into every other active locale. `points.singular` and `points.plural` are shown for context but are read-only through this tool surface.",
|
|
2886
|
+
inputSchema: { locale: localeSchema2 },
|
|
2887
|
+
handler: async (args) => {
|
|
2888
|
+
const { data } = await ctx.api.getData("/loyalty-hub/text", {
|
|
2889
|
+
query: { locale: args.locale }
|
|
2890
|
+
});
|
|
2891
|
+
return summaryWithJson(`${Object.keys(data.values).length} live Loyalty Hub text values for ${data.locale}. Primary language: ${data.primaryLanguage}; active locales: ${data.activeLocales.join(", ")}.`, data);
|
|
2892
|
+
}
|
|
2893
|
+
});
|
|
2894
|
+
}
|
|
2895
|
+
function registerLoyaltyHubTextWriteTools(server, ctx) {
|
|
2896
|
+
registerWriteTool(server, ctx, {
|
|
2897
|
+
name: "joy_update_loyalty_hub_text",
|
|
2898
|
+
title: "Update Loyalty Hub text",
|
|
2899
|
+
destructiveHint: true,
|
|
2900
|
+
idempotentHint: false,
|
|
2901
|
+
description: "Loyalty Hub text has no draft: every accepted change is LIVE on the Hub immediately. Update only exact keys returned by joy_get_loyalty_hub_text; unknown or duplicate keys are skipped. Preserve every returned placeholder unless the merchant explicitly authored the replacement, in which case pass manual: true. Editing the primary language automatically retranslates the changed keys into every other active locale in the same operation; editing a secondary locale changes only that locale. This can replace merchant-authored translations for those same keys. Undo the latest Hub text change with joy_revert_loyalty_hub_text.",
|
|
2902
|
+
inputSchema: {
|
|
2903
|
+
locale: localeSchema2,
|
|
2904
|
+
changes: z16.array(z16.object({
|
|
2905
|
+
key: z16.string().min(1),
|
|
2906
|
+
value: z16.string(),
|
|
2907
|
+
manual: z16.boolean().optional().describe("Set true only when the merchant authored this exact replacement.")
|
|
2908
|
+
})).min(1).describe("Exact key/value edits from joy_get_loyalty_hub_text.")
|
|
2909
|
+
},
|
|
2910
|
+
handler: async (args) => {
|
|
2911
|
+
const result = await ctx.api.mutateData("/loyalty-hub/text", {
|
|
2912
|
+
method: "PATCH",
|
|
2913
|
+
timeoutMs: 12e4,
|
|
2914
|
+
body: { changes: args.changes, ...args.locale ? { locale: args.locale } : {} }
|
|
2915
|
+
});
|
|
2916
|
+
const propagation = result.translatedLocales.length ? ` Retranslated in ${result.translatedLocales.join(", ")}.` : "";
|
|
2917
|
+
const skipped = result.skipped.length ? ` ${result.skipped.length} change(s) skipped.` : "";
|
|
2918
|
+
return summaryWithJson(`\u26A0 Loyalty Hub text has no draft \u2014 ${result.applied.length} text change(s) are LIVE now.${propagation}${skipped} Undo with joy_revert_loyalty_hub_text.`, result);
|
|
2919
|
+
}
|
|
2920
|
+
});
|
|
2921
|
+
registerWriteTool(server, ctx, {
|
|
2922
|
+
name: "joy_revert_loyalty_hub_text",
|
|
2923
|
+
title: "Revert the last Loyalty Hub text change",
|
|
2924
|
+
destructiveHint: true,
|
|
2925
|
+
idempotentHint: true,
|
|
2926
|
+
description: "Undo the last text change made through joy_update_loyalty_hub_text, restoring the Loyalty Hub text exactly as it was before that call \u2014 LIVE immediately. ONE step only \u2014 a second call restores the same snapshot again rather than going further back. This undoes Hub text only; use joy_revert_loyalty_hub_design to undo Hub design changes.",
|
|
2927
|
+
inputSchema: {},
|
|
2928
|
+
handler: async () => {
|
|
2929
|
+
const result = await ctx.api.mutateData("/loyalty-hub/text/revert", {
|
|
2930
|
+
method: "POST",
|
|
2931
|
+
timeoutMs: 2e4,
|
|
2932
|
+
body: {}
|
|
2933
|
+
});
|
|
2934
|
+
const count = result?.textKeysRestored ?? 0;
|
|
2935
|
+
return summaryWithJson(`Reverted Loyalty Hub text \u2014 ${count} value${count === 1 ? "" : "s"} restored. LIVE immediately.`, result);
|
|
2936
|
+
}
|
|
2937
|
+
});
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
// ../mcp-core/build/tools/programsWrite.js
|
|
2941
|
+
import { z as z17 } from "zod";
|
|
2092
2942
|
var PROGRAM_TYPE_BY_KIND = {
|
|
2093
2943
|
earning: "earning",
|
|
2094
2944
|
redemption: "spending"
|
|
@@ -2116,7 +2966,7 @@ var REVIEW_APPS = [
|
|
|
2116
2966
|
"okendoApp",
|
|
2117
2967
|
"ReviewsIoApp",
|
|
2118
2968
|
"growaveApp",
|
|
2119
|
-
"
|
|
2969
|
+
"segunoReviewApp"
|
|
2120
2970
|
];
|
|
2121
2971
|
var REVIEW_FLOW_APPS = /* @__PURE__ */ new Set([
|
|
2122
2972
|
"stampedApp",
|
|
@@ -2124,6 +2974,7 @@ var REVIEW_FLOW_APPS = /* @__PURE__ */ new Set([
|
|
|
2124
2974
|
"okendoApp",
|
|
2125
2975
|
"ReviewsIoApp",
|
|
2126
2976
|
"growaveApp",
|
|
2977
|
+
"segunoReviewApp",
|
|
2127
2978
|
"typdalApp"
|
|
2128
2979
|
]);
|
|
2129
2980
|
var REVIEW_PHOTO_VIDEO_APPS = /* @__PURE__ */ new Set(["reviewRocketApp", "looxApp"]);
|
|
@@ -2167,7 +3018,8 @@ var EARNING_FIELDS = [
|
|
|
2167
3018
|
"extraPointsForVideos",
|
|
2168
3019
|
"isAppliedVipTier",
|
|
2169
3020
|
"earnPointsTiers",
|
|
2170
|
-
"appliedPlaceOrderTo"
|
|
3021
|
+
"appliedPlaceOrderTo",
|
|
3022
|
+
"skipEarnPointGuest"
|
|
2171
3023
|
];
|
|
2172
3024
|
var REDEMPTION_FIELDS = [
|
|
2173
3025
|
"event",
|
|
@@ -2190,8 +3042,8 @@ var REDEMPTION_FIELDS = [
|
|
|
2190
3042
|
function withoutUnsetFields(body) {
|
|
2191
3043
|
return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== void 0));
|
|
2192
3044
|
}
|
|
2193
|
-
function missingFields(event,
|
|
2194
|
-
return { ok: false, error: `Creating ${event} requires: ${
|
|
3045
|
+
function missingFields(event, fields2) {
|
|
3046
|
+
return { ok: false, error: `Creating ${event} requires: ${fields2.join(", ")}.` };
|
|
2195
3047
|
}
|
|
2196
3048
|
function unsupportedCreate(event) {
|
|
2197
3049
|
return {
|
|
@@ -2484,45 +3336,45 @@ function isProgramChangeNoop(current, prepared) {
|
|
|
2484
3336
|
return diffFields(current, prepared.body).length === 0;
|
|
2485
3337
|
}
|
|
2486
3338
|
var programConfigInputSchema = {
|
|
2487
|
-
program_id:
|
|
2488
|
-
event:
|
|
2489
|
-
title:
|
|
2490
|
-
status:
|
|
2491
|
-
priority:
|
|
2492
|
-
start_date:
|
|
2493
|
-
end_date:
|
|
2494
|
-
earn_by:
|
|
2495
|
-
rate_money:
|
|
2496
|
-
rate_item:
|
|
2497
|
-
earn_point:
|
|
2498
|
-
date_format:
|
|
2499
|
-
type_reward_timing:
|
|
2500
|
-
limit_unit:
|
|
2501
|
-
limit_interval:
|
|
2502
|
-
url_account:
|
|
2503
|
-
review_app:
|
|
2504
|
-
description_review:
|
|
2505
|
-
extra_points_for_images:
|
|
2506
|
-
extra_points_for_videos:
|
|
2507
|
-
spend_point:
|
|
2508
|
-
earn_amount:
|
|
2509
|
-
shipping_discount_type:
|
|
2510
|
-
shipping_discount_amount:
|
|
2511
|
-
applied_to:
|
|
2512
|
-
specific_products:
|
|
2513
|
-
specific_collections:
|
|
2514
|
-
applied_collection_ids:
|
|
2515
|
-
collection_quantity:
|
|
2516
|
-
product_url:
|
|
2517
|
-
applied_place_order_to:
|
|
2518
|
-
is_applied_vip_tier:
|
|
2519
|
-
earn_points_tiers:
|
|
2520
|
-
earn_point:
|
|
2521
|
-
rate_money:
|
|
2522
|
-
type_earn:
|
|
3339
|
+
program_id: z17.string().min(1).optional().describe("Existing Joy program ID. Omit to create a new program."),
|
|
3340
|
+
event: z17.string().optional().describe("Program event. Required for create; optional for update."),
|
|
3341
|
+
title: z17.string().min(1).optional().describe("Customer-facing program title."),
|
|
3342
|
+
status: z17.boolean().optional().describe("Set false to disable this rule instead of deleting it."),
|
|
3343
|
+
priority: z17.number().int().optional().describe("Optional program priority."),
|
|
3344
|
+
start_date: z17.string().optional().describe("Optional ISO start date."),
|
|
3345
|
+
end_date: z17.string().optional().describe("Optional ISO end date."),
|
|
3346
|
+
earn_by: z17.enum(["price", "fixed_order", "quantity", "order"]).optional(),
|
|
3347
|
+
rate_money: z17.number().optional(),
|
|
3348
|
+
rate_item: z17.number().optional(),
|
|
3349
|
+
earn_point: z17.number().optional(),
|
|
3350
|
+
date_format: z17.enum(["MM/DD", "DD/MM", "MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD"]).optional(),
|
|
3351
|
+
type_reward_timing: z17.enum(["exact_birthday", "begin_of_month", "around_birthday"]).optional(),
|
|
3352
|
+
limit_unit: z17.string().optional(),
|
|
3353
|
+
limit_interval: z17.number().int().positive().optional(),
|
|
3354
|
+
url_account: z17.string().regex(/^https?:\/\//, "url_account must start with http:// or https://").optional().describe("REQUIRED when creating a social rule (follow_instagram, subscribe_youtube, like_facebook, follow_tiktok, follow_twitter, follow_pinterest, join_discord, join_telegram, join_reddit). The full URL of the shop's profile on that network \u2014 customers are sent here to complete the action."),
|
|
3355
|
+
review_app: z17.enum(REVIEW_APPS).optional().describe("REQUIRED when creating a write_review rule: the review app the shop actually runs. Points cannot be awarded for reviews unless this matches the installed app."),
|
|
3356
|
+
description_review: z17.string().optional().describe("Required only when review_app is a Shopify Flow app (stampedApp, looxApp, okendoApp, ReviewsIoApp, growaveApp, segunoReviewApp, typdalApp) \u2014 the merchant-facing note describing how the Flow awards points."),
|
|
3357
|
+
extra_points_for_images: z17.number().int().nonnegative().optional().describe("Bonus points for a review with a photo. Only reviewRocketApp and looxApp report photos; ignored for every other review app."),
|
|
3358
|
+
extra_points_for_videos: z17.number().int().nonnegative().optional().describe("Bonus points for a review with a video. Only reviewRocketApp and looxApp report videos; ignored for every other review app."),
|
|
3359
|
+
spend_point: z17.number().int().positive().optional(),
|
|
3360
|
+
earn_amount: z17.number().optional(),
|
|
3361
|
+
shipping_discount_type: z17.enum(["percentage", "fixedAmount"]).optional(),
|
|
3362
|
+
shipping_discount_amount: z17.number().optional(),
|
|
3363
|
+
applied_to: z17.enum(FREE_GIFT_PROGRAM_APPLIED_TO).optional().describe("free_gift only \u2014 sf_product, specific, or exclude_collection. This is a program-level enum and differs from tier rewards; it has no all value."),
|
|
3364
|
+
specific_products: z17.array(z17.unknown()).optional().describe("free_gift with applied_to sf_product only \u2014 the merchant-provided product records/IDs. Do not invent product IDs."),
|
|
3365
|
+
specific_collections: z17.array(z17.unknown()).optional(),
|
|
3366
|
+
applied_collection_ids: z17.array(z17.string()).optional().describe("free_gift with applied_to specific or exclude_collection only \u2014 merchant-provided collection IDs."),
|
|
3367
|
+
collection_quantity: z17.number().int().positive().optional().describe("Required for collection-based free_gift rules (applied_to specific or exclude_collection)."),
|
|
3368
|
+
product_url: z17.array(z17.string()).optional().describe("free_gift with applied_to sf_product only \u2014 Shopify product URLs to resolve into product IDs. Accepts /products/<handle> or /collections/<collection>/products/<handle>; do not invent IDs."),
|
|
3369
|
+
applied_place_order_to: z17.enum(["all", "vip-tier", "member_and_guest"]).optional().describe('place_order "Who to reward" radio: all = All loyalty members, vip-tier = VIP Tier only, member_and_guest = All customers. The admin UI and the earning engine both key off this field, not is_applied_vip_tier. Enabling is_applied_vip_tier or earn_points_tiers FORCES vip-tier \u2014 sending all here is why per-tier rates can save while the radio stays on All loyalty members.'),
|
|
3370
|
+
is_applied_vip_tier: z17.boolean().optional().describe("Turn per-VIP-tier earning on for this rule. MUST be true for earn_points_tiers to have any effect \u2014 the backend reads the per-tier map only when this flag is set, and otherwise silently uses the flat earn_point."),
|
|
3371
|
+
earn_points_tiers: z17.record(z17.object({
|
|
3372
|
+
earn_point: z17.number().optional().describe("Points this tier earns, replacing the flat earn_point."),
|
|
3373
|
+
rate_money: z17.number().optional().describe("place_order only: money amount that earns earn_point for this tier."),
|
|
3374
|
+
type_earn: z17.enum(["point", "store_credit"]).optional()
|
|
2523
3375
|
})).optional().describe("Per-VIP-tier earning, keyed by TIER ID (from joy_list_tiers) \u2014 not tier name. For place_order a \xD71.25 multiplier on a 1-point-per-$1 base is earn_point 1.25, not 1.25 as a factor: the value REPLACES the base rate. Requires is_applied_vip_tier: true."),
|
|
2524
|
-
reason:
|
|
2525
|
-
apply:
|
|
3376
|
+
reason: z17.string().min(3).describe("Why the merchant wants this configuration change."),
|
|
3377
|
+
apply: z17.boolean().optional().describe("Set true only after reviewing this tool's dry-run preview.")
|
|
2526
3378
|
};
|
|
2527
3379
|
var PLACE_ORDER_EVENTS = /* @__PURE__ */ new Set(["place_order", "place_order_subscription"]);
|
|
2528
3380
|
function placeOrderVipTierPatch(event, args) {
|
|
@@ -2531,9 +3383,7 @@ function placeOrderVipTierPatch(event, args) {
|
|
|
2531
3383
|
return {};
|
|
2532
3384
|
if (!PLACE_ORDER_EVENTS.has(String(event)))
|
|
2533
3385
|
return {};
|
|
2534
|
-
|
|
2535
|
-
return {};
|
|
2536
|
-
return { appliedPlaceOrderTo: "vip-tier" };
|
|
3386
|
+
return { appliedPlaceOrderTo: "vip-tier", skipEarnPointGuest: true };
|
|
2537
3387
|
}
|
|
2538
3388
|
function toIntegerRatio(earnPoint, rateMoney) {
|
|
2539
3389
|
const isWhole = (value) => Math.abs(value - Math.round(value)) < 1e-9;
|
|
@@ -2626,11 +3476,14 @@ async function executeProgramChange(ctx, kind, args, toChange) {
|
|
|
2626
3476
|
if (stop2)
|
|
2627
3477
|
return stop2;
|
|
2628
3478
|
await ctx.api.mutateData(`/programs/${prepared.programId}`, { method: "PUT", body: prepared.body });
|
|
2629
|
-
if (resolvedArgs.earn_points_tiers) {
|
|
3479
|
+
if (resolvedArgs.earn_points_tiers || prepared.body.appliedPlaceOrderTo === "vip-tier") {
|
|
2630
3480
|
const { data: after } = await ctx.api.getData(`/programs/${prepared.programId}`);
|
|
2631
3481
|
if (earnPointsTiersDropped(resolvedArgs.earn_points_tiers, after?.earnPointsTiers)) {
|
|
2632
3482
|
return `Every other field saved, but earnPointsTiers did NOT: the backend stored an empty map. REST v2 declares earnPointsTiers as a shapeless Yup.object() and validates with stripUnknown, which strips every tier key. Per-VIP-tier earning cannot currently be set through this API by any caller \u2014 configure it in Joy admin, and report this so the schema gets a real shape. Do not retry; the result will be the same.`;
|
|
2633
3483
|
}
|
|
3484
|
+
if (prepared.body.appliedPlaceOrderTo === "vip-tier" && after?.appliedPlaceOrderTo !== "vip-tier") {
|
|
3485
|
+
return `Per-tier rates may have saved, but Who to reward did not: appliedPlaceOrderTo is still "${String(after?.appliedPlaceOrderTo ?? "unset")}" (All loyalty members), not vip-tier. The admin radio and the earning engine both read this field. Do not report VIP Tier only as applied; verify the place_order program in Joy admin.`;
|
|
3486
|
+
}
|
|
2634
3487
|
}
|
|
2635
3488
|
return `Updated ${kind} program ${prepared.programId}. This affects every customer immediately.`;
|
|
2636
3489
|
}
|
|
@@ -2649,7 +3502,7 @@ function registerProgramConfigTools(server, ctx) {
|
|
|
2649
3502
|
registerConfigWriteTool(server, ctx, {
|
|
2650
3503
|
name: "joy_configure_earning_program",
|
|
2651
3504
|
title: "Create or update an earning program",
|
|
2652
|
-
description: "WRITE \u2014 changes earning behaviour for every customer immediately. Creates place_order, sign_up, birthday, interact_website, write_review, and the nine social-follow rules (Instagram, YouTube, TikTok, Facebook, X/Twitter, Pinterest, Discord, Telegram, Reddit); social rules need url_account and write_review needs review_app. Use program_id to update ANY existing earning rule, including types this tool cannot create. Previews by default. Joy has no safe delete here: set status: false to reversibly disable a rule instead.",
|
|
3505
|
+
description: "WRITE \u2014 changes earning behaviour for every customer immediately. Creates place_order, sign_up, birthday, interact_website, write_review, and the nine social-follow rules (Instagram, YouTube, TikTok, Facebook, X/Twitter, Pinterest, Discord, Telegram, Reddit); social rules need url_account and write_review needs review_app. For place_order, is_applied_vip_tier / earn_points_tiers also sets Who to reward (appliedPlaceOrderTo) to vip-tier \u2014 the admin radio is that field, not the per-tier map. Use program_id to update ANY existing earning rule, including types this tool cannot create. Previews by default. Joy has no safe delete here: set status: false to reversibly disable a rule instead.",
|
|
2653
3506
|
inputSchema: programConfigInputSchema,
|
|
2654
3507
|
destructiveHint: true,
|
|
2655
3508
|
handler: (args) => executeProgramChange(ctx, "earning", args, toEarningProgramChange)
|
|
@@ -2665,8 +3518,10 @@ function registerProgramConfigTools(server, ctx) {
|
|
|
2665
3518
|
}
|
|
2666
3519
|
|
|
2667
3520
|
// ../mcp-core/build/tools/tiersWrite.js
|
|
2668
|
-
import { z as
|
|
3521
|
+
import { z as z18 } from "zod";
|
|
2669
3522
|
var TIER_THRESHOLD_WARNING = "Threshold changes are STAGED, not live. Members are not re-evaluated until the merchant relaunches tiers in Joy admin \u2014 this tool cannot trigger that.";
|
|
3523
|
+
var ENTRY_METHOD_WARNING = "Level-up rule (entryMethod) is saved immediately. NEW points, spend, and orders start counting in the new unit at once. Existing members keep their current tierPoint until the merchant relaunches tiers in Joy admin \u2014 this tool cannot trigger that. Review each tier threshold (target_point): 0/150/350 means points, currency, or orders depending on this rule.";
|
|
3524
|
+
var START_DATE_WARNING = "Program start date (startDate) is saved immediately and is the calculation window for NEW activity. Existing members keep their current tierPoint until the merchant relaunches tiers in Joy admin \u2014 this tool cannot trigger that. This is the VIP program start date, not reset_start_date.";
|
|
2670
3525
|
var RESET_SCHEDULE_FIELDS = [
|
|
2671
3526
|
"useResetTier",
|
|
2672
3527
|
"resetFrequency",
|
|
@@ -2728,6 +3583,12 @@ var TIER_REWARD_FIELDS = [
|
|
|
2728
3583
|
];
|
|
2729
3584
|
var TIER_PERK_FIELDS = ["contentPerk"];
|
|
2730
3585
|
var TIER_SETTINGS_FIELDS = [
|
|
3586
|
+
"entryMethod",
|
|
3587
|
+
"startDate",
|
|
3588
|
+
"prefix",
|
|
3589
|
+
"isUsePrefixDiscountCode",
|
|
3590
|
+
"showTierPointSettings",
|
|
3591
|
+
"allowGiveEntryRewardAgain",
|
|
2731
3592
|
"useDemotionTier",
|
|
2732
3593
|
"typeDemotion",
|
|
2733
3594
|
"downgradeAfter",
|
|
@@ -2746,7 +3607,7 @@ var TIER_SETTINGS_FIELDS = [
|
|
|
2746
3607
|
"notificationDowngradeBefore",
|
|
2747
3608
|
"notificationDowngradeBeforeUnit"
|
|
2748
3609
|
];
|
|
2749
|
-
var rewardEventSchema =
|
|
3610
|
+
var rewardEventSchema = z18.enum([
|
|
2750
3611
|
"amount_discount",
|
|
2751
3612
|
"percentage_discount",
|
|
2752
3613
|
"free_shipping",
|
|
@@ -2754,95 +3615,101 @@ var rewardEventSchema = z14.enum([
|
|
|
2754
3615
|
"bonus_point",
|
|
2755
3616
|
"buy_x_get_y"
|
|
2756
3617
|
]);
|
|
2757
|
-
var finiteNumber = () =>
|
|
2758
|
-
var tierRewardInputSchema =
|
|
3618
|
+
var finiteNumber = () => z18.number().finite();
|
|
3619
|
+
var tierRewardInputSchema = z18.object({
|
|
2759
3620
|
event: rewardEventSchema.describe("Required reward kind."),
|
|
2760
|
-
title:
|
|
2761
|
-
type:
|
|
2762
|
-
status:
|
|
3621
|
+
title: z18.string().min(1).optional(),
|
|
3622
|
+
type: z18.enum(["tier_spending", "tier_privileges", "pre_tier_reward"]).optional(),
|
|
3623
|
+
status: z18.boolean().optional(),
|
|
2763
3624
|
earn_amount: finiteNumber().optional(),
|
|
2764
3625
|
spend_point: finiteNumber().optional(),
|
|
2765
3626
|
bonus_points: finiteNumber().optional(),
|
|
2766
|
-
code_name:
|
|
2767
|
-
redeem_type:
|
|
2768
|
-
applied_to:
|
|
2769
|
-
order_req:
|
|
3627
|
+
code_name: z18.string().optional(),
|
|
3628
|
+
redeem_type: z18.string().optional(),
|
|
3629
|
+
applied_to: z18.enum(["all", "specific_product", "specific"]).optional().describe("Tier-reward scope only: all, specific_product, or specific. These literals differ from program-level reward tools."),
|
|
3630
|
+
order_req: z18.enum(["none", "min_amount", "min_quantity"]).optional(),
|
|
2770
3631
|
order_req_amount: finiteNumber().optional(),
|
|
2771
3632
|
min_spend_point: finiteNumber().optional(),
|
|
2772
3633
|
max_spend_point: finiteNumber().optional(),
|
|
2773
|
-
specific_products:
|
|
2774
|
-
specific_collections:
|
|
2775
|
-
applied_collection_ids:
|
|
2776
|
-
combined_with:
|
|
2777
|
-
has_limit_shipping:
|
|
3634
|
+
specific_products: z18.array(z18.unknown()).optional(),
|
|
3635
|
+
specific_collections: z18.array(z18.unknown()).optional(),
|
|
3636
|
+
applied_collection_ids: z18.array(z18.unknown()).optional(),
|
|
3637
|
+
combined_with: z18.array(z18.enum(["orderDiscounts", "shippingDiscounts", "productDiscounts"])).optional(),
|
|
3638
|
+
has_limit_shipping: z18.boolean().optional(),
|
|
2778
3639
|
limit_shipping: finiteNumber().optional(),
|
|
2779
3640
|
expired_after: finiteNumber().optional(),
|
|
2780
|
-
expired_unit:
|
|
2781
|
-
is_refund_coupon:
|
|
3641
|
+
expired_unit: z18.enum(["day", "month"]).optional(),
|
|
3642
|
+
is_refund_coupon: z18.boolean().optional(),
|
|
2782
3643
|
priority: finiteNumber().optional(),
|
|
2783
|
-
show_loyalty_page:
|
|
2784
|
-
perk_title_language:
|
|
2785
|
-
reward_product_selection_mode:
|
|
3644
|
+
show_loyalty_page: z18.boolean().optional(),
|
|
3645
|
+
perk_title_language: z18.record(z18.unknown()).optional(),
|
|
3646
|
+
reward_product_selection_mode: z18.string().optional(),
|
|
2786
3647
|
reward_product_quantity: finiteNumber().optional()
|
|
2787
3648
|
});
|
|
2788
3649
|
var tierConfigInputSchema = {
|
|
2789
|
-
tier_id:
|
|
2790
|
-
name:
|
|
3650
|
+
tier_id: z18.string().min(1).optional().describe("Existing tier ID. Omit to create a tier."),
|
|
3651
|
+
name: z18.string().min(1).optional(),
|
|
2791
3652
|
target_point: finiteNumber().nonnegative().optional().describe("Tier threshold. On an existing tier this is staged; it does not immediately re-evaluate members."),
|
|
2792
|
-
icon:
|
|
2793
|
-
icon_custom:
|
|
2794
|
-
bg_image:
|
|
2795
|
-
default_avatar:
|
|
2796
|
-
image_block:
|
|
2797
|
-
is_exclusive_tier:
|
|
2798
|
-
hide_default_tier:
|
|
2799
|
-
text_color:
|
|
2800
|
-
progress_bar_color:
|
|
2801
|
-
box_bg_color:
|
|
2802
|
-
box_text_color:
|
|
2803
|
-
bg_card_color:
|
|
3653
|
+
icon: z18.string().optional(),
|
|
3654
|
+
icon_custom: z18.string().optional(),
|
|
3655
|
+
bg_image: z18.string().optional().describe("Membership card background image URL for this ONE tier. Pass bg_type: 'image' alongside bg_image for it to show, or '' to clear. Use a durable URL the merchant controls; joy_upload_image uploads to their Shopify Files CDN and returns one. Widget-wide membershipCard.beforeSignIn.bgImage and membershipCard.afterSignIn.bgImage are different fields, managed by joy_set_widget_fields."),
|
|
3656
|
+
default_avatar: z18.string().optional().describe("Membership card avatar image URL for members in this ONE tier. Pass '' to clear. Use a durable URL the merchant controls; joy_upload_image uploads to their Shopify Files CDN and returns one. Widget-wide membershipCard.beforeSignIn.bgImage and membershipCard.afterSignIn.bgImage are different fields, managed by joy_set_widget_fields."),
|
|
3657
|
+
image_block: z18.string().optional(),
|
|
3658
|
+
is_exclusive_tier: z18.boolean().optional(),
|
|
3659
|
+
hide_default_tier: z18.boolean().optional(),
|
|
3660
|
+
text_color: z18.string().optional(),
|
|
3661
|
+
progress_bar_color: z18.string().optional(),
|
|
3662
|
+
box_bg_color: z18.string().optional(),
|
|
3663
|
+
box_text_color: z18.string().optional(),
|
|
3664
|
+
bg_card_color: z18.string().optional(),
|
|
2804
3665
|
card_opacity: finiteNumber().optional(),
|
|
2805
|
-
icon_color:
|
|
2806
|
-
bg_type:
|
|
2807
|
-
tier_names_language:
|
|
2808
|
-
rewards:
|
|
2809
|
-
reason:
|
|
2810
|
-
apply:
|
|
3666
|
+
icon_color: z18.string().optional(),
|
|
3667
|
+
bg_type: z18.string().optional(),
|
|
3668
|
+
tier_names_language: z18.record(z18.unknown()).optional(),
|
|
3669
|
+
rewards: z18.array(tierRewardInputSchema).optional().describe("Optional rewards to create with a NEW tier in this one configuration write."),
|
|
3670
|
+
reason: z18.string().min(3).describe("Why the merchant wants this tier configuration."),
|
|
3671
|
+
apply: z18.boolean().optional().describe("Set true only after the dry-run preview is approved.")
|
|
2811
3672
|
};
|
|
2812
3673
|
var tierRewardToolInputSchema = {
|
|
2813
|
-
tier_id:
|
|
2814
|
-
reward_id:
|
|
3674
|
+
tier_id: z18.string().min(1).describe("Tier ID from joy_list_tiers."),
|
|
3675
|
+
reward_id: z18.string().min(1).optional().describe("Existing reward ID. Omit to create."),
|
|
2815
3676
|
...tierRewardInputSchema.shape,
|
|
2816
|
-
product_url:
|
|
2817
|
-
reason:
|
|
2818
|
-
apply:
|
|
3677
|
+
product_url: z18.array(z18.string()).optional().describe("free_gift with applied_to specific_product only \u2014 Shopify product URLs to resolve into product IDs. Accepts /products/<handle> or /collections/<collection>/products/<handle>; do not invent IDs."),
|
|
3678
|
+
reason: z18.string().min(3).describe("Why the merchant wants this reward configuration."),
|
|
3679
|
+
apply: z18.boolean().optional().describe("Set true only after the dry-run preview is approved.")
|
|
2819
3680
|
};
|
|
2820
3681
|
var tierSettingsInputSchema = {
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
3682
|
+
entry_method: z18.enum(["pointEarned", "moneySpent", "numberOfOrders"]).optional().describe("Tier calculation / level-up rule: how members climb the ladder. pointEarned = points earned, moneySpent = amount spent, numberOfOrders = number of orders. Saved immediately \u2014 NEW activity counts in the new unit at once. Existing members keep their current tierPoint until the merchant relaunches in Joy admin; this tool cannot trigger that. After changing, review each tier's target_point: the same 0/150/350 numbers mean points, currency, or orders depending on this rule."),
|
|
3683
|
+
start_date: z18.string().min(1).optional().describe('VIP program start date \u2014 the calculation window in the admin "Tier calculation" card (ISO date, YYYY-MM-DD). Distinct from reset_start_date, which is the custom reset-cycle anchor. Saved immediately; members keep their current tierPoint until the merchant relaunches in Joy admin.'),
|
|
3684
|
+
prefix: z18.string().optional().describe("Custom discount-code prefix for tier rewards. Supports {{tier_name}}. Pair with use_prefix_discount_code: true, or the custom prefix is stored but unused."),
|
|
3685
|
+
use_prefix_discount_code: z18.boolean().optional().describe("When true, tier reward codes use prefix instead of the shop default."),
|
|
3686
|
+
show_tier_point_settings: z18.boolean().optional().describe("Show tier-point editing on the customer admin page."),
|
|
3687
|
+
allow_give_entry_reward_again: z18.boolean().optional().describe("When true (Joy default), a member who is demoted and later re-enters a tier can receive that tier entry reward again. False grants it only once."),
|
|
3688
|
+
use_demotion_tier: z18.boolean().optional(),
|
|
3689
|
+
type_demotion: z18.enum(["downgrade_tier", "reset_tier"]).optional(),
|
|
3690
|
+
downgrade_after: z18.number().int().positive().optional(),
|
|
3691
|
+
downgrade_after_unit: z18.enum(["day", "month", "year"]).optional(),
|
|
3692
|
+
use_reset_tier: z18.boolean().optional(),
|
|
3693
|
+
reset_after: z18.number().int().positive().optional(),
|
|
3694
|
+
reset_after_unit: z18.enum(["day", "month", "year"]).optional(),
|
|
3695
|
+
reset_frequency: z18.enum(["monthly", "quarterly", "half_yearly", "yearly", "custom"]).optional(),
|
|
3696
|
+
reset_interval_value: z18.number().int().positive().optional(),
|
|
3697
|
+
reset_interval_unit: z18.enum(["day", "month", "year"]).optional(),
|
|
3698
|
+
reset_start_date: z18.string().min(1).optional(),
|
|
3699
|
+
month_reset_tier: z18.number().int().min(1).max(12).optional(),
|
|
3700
|
+
day_reset_tier: z18.number().int().min(1).max(31).optional(),
|
|
3701
|
+
is_sync_tags_customer_tier: z18.boolean().optional(),
|
|
3702
|
+
customer_tier_tag_prefix: z18.string().optional(),
|
|
3703
|
+
notification_downgrade_before: z18.number().int().positive().optional(),
|
|
3704
|
+
notification_downgrade_before_unit: z18.enum(["day", "month"]).optional(),
|
|
3705
|
+
reason: z18.string().min(3).describe("Why the merchant wants these tier settings."),
|
|
3706
|
+
apply: z18.boolean().optional().describe("Set true only after the dry-run preview is approved.")
|
|
2840
3707
|
};
|
|
2841
3708
|
var tierPerksInputSchema = {
|
|
2842
|
-
tier_id:
|
|
2843
|
-
perks:
|
|
2844
|
-
reason:
|
|
2845
|
-
apply:
|
|
3709
|
+
tier_id: z18.string().min(1).describe("Tier ID from joy_list_tiers."),
|
|
3710
|
+
perks: z18.array(z18.string().min(1)).describe("The COMPLETE replacement list of display-only perks. Include every existing perk that should remain; omitted entries are removed, and [] clears the list. Each entry is perk text or an http(s) URL rendered as a tick/cross icon in the storefront benefits table."),
|
|
3711
|
+
reason: z18.string().min(3).describe("Why the merchant wants to replace this tier perk list."),
|
|
3712
|
+
apply: z18.boolean().optional().describe("Set true only after the dry-run preview is approved.")
|
|
2846
3713
|
};
|
|
2847
3714
|
function tierFreeGiftSelectionError(args, requireAppliedTo = false) {
|
|
2848
3715
|
if (args.event !== "free_gift")
|
|
@@ -2941,7 +3808,7 @@ function buildTierConfigPreview(params) {
|
|
|
2941
3808
|
}
|
|
2942
3809
|
function rethrowTierCreateConflict(error) {
|
|
2943
3810
|
if (error instanceof JoyApiError && error.status === 409) {
|
|
2944
|
-
throw new JoyApiError("conflict", "Cannot create this tier because another tier already has this name or target_point. Call joy_list_tiers, then update the existing tier with tier_id instead of retrying the create.", error.status);
|
|
3811
|
+
throw new JoyApiError("conflict", "Cannot create this tier because another tier already has this name or target_point. Call joy_list_tiers, then update the existing tier with tier_id instead of retrying the create.", error.status, error.serverCode);
|
|
2945
3812
|
}
|
|
2946
3813
|
throw error;
|
|
2947
3814
|
}
|
|
@@ -3138,6 +4005,12 @@ async function configureTierPerks(ctx, args) {
|
|
|
3138
4005
|
}
|
|
3139
4006
|
function toTierSettingsBody(args) {
|
|
3140
4007
|
return omitUndefined({
|
|
4008
|
+
entryMethod: args.entry_method,
|
|
4009
|
+
startDate: args.start_date,
|
|
4010
|
+
prefix: args.prefix,
|
|
4011
|
+
isUsePrefixDiscountCode: args.use_prefix_discount_code,
|
|
4012
|
+
showTierPointSettings: args.show_tier_point_settings,
|
|
4013
|
+
allowGiveEntryRewardAgain: args.allow_give_entry_reward_again,
|
|
3141
4014
|
useDemotionTier: args.use_demotion_tier,
|
|
3142
4015
|
typeDemotion: args.type_demotion,
|
|
3143
4016
|
downgradeAfter: args.downgrade_after === void 0 ? void 0 : String(args.downgrade_after),
|
|
@@ -3163,6 +4036,11 @@ function touchesResetSchedule(body) {
|
|
|
3163
4036
|
function buildTierSettingsPreview(current, args, now = /* @__PURE__ */ new Date()) {
|
|
3164
4037
|
const body = toTierSettingsBody(args);
|
|
3165
4038
|
const changes = diffFields(current, body);
|
|
4039
|
+
const calculationWarnings = [
|
|
4040
|
+
...changes.some((change) => change.field === "entryMethod") ? [ENTRY_METHOD_WARNING] : [],
|
|
4041
|
+
...changes.some((change) => change.field === "startDate") ? [START_DATE_WARNING] : []
|
|
4042
|
+
];
|
|
4043
|
+
const entryMethodWarning = calculationWarnings.length > 0 ? calculationWarnings : void 0;
|
|
3166
4044
|
if (!touchesResetSchedule(body)) {
|
|
3167
4045
|
return {
|
|
3168
4046
|
kind: "preview",
|
|
@@ -3172,7 +4050,8 @@ function buildTierSettingsPreview(current, args, now = /* @__PURE__ */ new Date(
|
|
|
3172
4050
|
target: "tier settings",
|
|
3173
4051
|
reason: args.reason,
|
|
3174
4052
|
changes,
|
|
3175
|
-
dropped: droppedFields(body, TIER_SETTINGS_FIELDS)
|
|
4053
|
+
dropped: droppedFields(body, TIER_SETTINGS_FIELDS),
|
|
4054
|
+
warnings: entryMethodWarning
|
|
3176
4055
|
})
|
|
3177
4056
|
};
|
|
3178
4057
|
}
|
|
@@ -3202,7 +4081,7 @@ function buildTierSettingsPreview(current, args, now = /* @__PURE__ */ new Date(
|
|
|
3202
4081
|
reason: args.reason,
|
|
3203
4082
|
changes,
|
|
3204
4083
|
dropped: droppedFields(body, TIER_SETTINGS_FIELDS),
|
|
3205
|
-
warnings: [scheduleWarning]
|
|
4084
|
+
warnings: entryMethodWarning ? [scheduleWarning, ...entryMethodWarning] : [scheduleWarning]
|
|
3206
4085
|
})
|
|
3207
4086
|
};
|
|
3208
4087
|
}
|
|
@@ -3220,8 +4099,19 @@ async function configureTierSettings(ctx, args) {
|
|
|
3220
4099
|
timeoutMs: 15e3,
|
|
3221
4100
|
body: decision.body
|
|
3222
4101
|
});
|
|
4102
|
+
const unconfirmed = unconfirmedSettingsFields(decision.body, updated ?? {});
|
|
4103
|
+
if (unconfirmed.length > 0) {
|
|
4104
|
+
throw new JoyApiError("server", `The tier-settings write was not confirmed for: ${unconfirmed.join(", ")}. Do not report those fields as changed; verify them in Joy admin.`);
|
|
4105
|
+
}
|
|
3223
4106
|
const schedule = touchesResetSchedule(decision.body) ? ` Projected scheduleResetTierAt: ${formatSchedule(decision.projectedScheduleResetTierAt)}.` : "";
|
|
3224
|
-
|
|
4107
|
+
const calculationChanged = decision.body.entryMethod !== void 0 && !sameSettingValue("entryMethod", current.entryMethod, decision.body.entryMethod) || decision.body.startDate !== void 0 && !sameSettingValue("startDate", current.startDate, decision.body.startDate);
|
|
4108
|
+
if (calculationChanged)
|
|
4109
|
+
ctx.cache?.delete("tiers");
|
|
4110
|
+
const calculationNote = [
|
|
4111
|
+
decision.body.entryMethod !== void 0 && !sameSettingValue("entryMethod", current.entryMethod, decision.body.entryMethod) ? ENTRY_METHOD_WARNING : "",
|
|
4112
|
+
decision.body.startDate !== void 0 && !sameSettingValue("startDate", current.startDate, decision.body.startDate) ? START_DATE_WARNING : ""
|
|
4113
|
+
].filter(Boolean).map((warning) => ` ${warning}`).join("");
|
|
4114
|
+
return summaryWithJson(`Updated tier settings.${schedule}${calculationNote}`, compact(updated, [...TIER_SETTINGS_FIELDS, "id"]));
|
|
3225
4115
|
} catch (error) {
|
|
3226
4116
|
rethrowTierSettingsNotLaunched(error);
|
|
3227
4117
|
}
|
|
@@ -3238,7 +4128,7 @@ async function readTierSettings(ctx) {
|
|
|
3238
4128
|
}
|
|
3239
4129
|
function rethrowTierSettingsNotLaunched(error) {
|
|
3240
4130
|
if (error instanceof JoyApiError && error.kind === "not_found") {
|
|
3241
|
-
throw new JoyApiError("not_found", "This shop has never launched tiers, so tier settings do not exist yet. Launch the tier program in Joy admin first; this MCP tool cannot launch it.", error.status);
|
|
4131
|
+
throw new JoyApiError("not_found", "This shop has never launched tiers, so tier settings do not exist yet. Launch the tier program in Joy admin first; this MCP tool cannot launch it.", error.status, error.serverCode);
|
|
3242
4132
|
}
|
|
3243
4133
|
throw error;
|
|
3244
4134
|
}
|
|
@@ -3308,8 +4198,8 @@ function registerTierConfigTools(server, ctx) {
|
|
|
3308
4198
|
});
|
|
3309
4199
|
registerConfigWriteTool(server, ctx, {
|
|
3310
4200
|
name: "joy_configure_tier_settings",
|
|
3311
|
-
title: "Configure VIP tier demotion and reset settings",
|
|
3312
|
-
description:
|
|
4201
|
+
title: "Configure VIP tier calculation, demotion and reset settings",
|
|
4202
|
+
description: 'CONFIGURATION WRITE \u2014 the admin "Tier calculation" card plus demotion/reset. Covers entry_method (pointEarned / moneySpent / numberOfOrders), start_date (program calculation window \u2014 not reset_start_date), prefix / use_prefix_discount_code, show_tier_point_settings, allow_give_entry_reward_again, demotion, reset, customer-tag, and downgrade-notification settings. Changing entry_method or start_date saves immediately and NEW activity uses the new rule/window; existing members keep their current tierPoint until the merchant relaunches in Joy admin \u2014 this tool cannot trigger that. Any reset-schedule change previews the current and projected scheduleResetTierAt before it may apply, because that daily-cron anchor affects every tier member.',
|
|
3313
4203
|
inputSchema: tierSettingsInputSchema,
|
|
3314
4204
|
destructiveHint: true,
|
|
3315
4205
|
handler: (args) => configureTierSettings(ctx, args)
|
|
@@ -3324,6 +4214,15 @@ function isData(value) {
|
|
|
3324
4214
|
function sameValue(a, b) {
|
|
3325
4215
|
return a === b || String(a) === String(b);
|
|
3326
4216
|
}
|
|
4217
|
+
function sameSettingValue(field, a, b) {
|
|
4218
|
+
if (field === "startDate" || field === "resetStartDate") {
|
|
4219
|
+
return String(a ?? "").slice(0, 10) === String(b ?? "").slice(0, 10);
|
|
4220
|
+
}
|
|
4221
|
+
return sameValue(a, b);
|
|
4222
|
+
}
|
|
4223
|
+
function unconfirmedSettingsFields(body, updated) {
|
|
4224
|
+
return Object.keys(body).filter((field) => !sameSettingValue(field, body[field], updated[field]));
|
|
4225
|
+
}
|
|
3327
4226
|
function formatSchedule(value) {
|
|
3328
4227
|
if (value === void 0)
|
|
3329
4228
|
return "(not exposed by REST v2)";
|
|
@@ -3419,7 +4318,7 @@ function isAfterOrEqual(candidate, cursor, inclusive) {
|
|
|
3419
4318
|
}
|
|
3420
4319
|
|
|
3421
4320
|
// ../mcp-core/build/tools/shopConfig.js
|
|
3422
|
-
import { z as
|
|
4321
|
+
import { z as z19 } from "zod";
|
|
3423
4322
|
var SHOP_SETTINGS_FIELDS = [
|
|
3424
4323
|
"primaryLanguageInApp",
|
|
3425
4324
|
"dateFormat",
|
|
@@ -3517,8 +4416,8 @@ function toShopSettingsBody(args) {
|
|
|
3517
4416
|
function getDroppedShopSettingsFields(args) {
|
|
3518
4417
|
return getUnknownArguments(args, SHOP_SETTINGS_VALID_ARGUMENTS);
|
|
3519
4418
|
}
|
|
3520
|
-
function selectedFields(body,
|
|
3521
|
-
const selected =
|
|
4419
|
+
function selectedFields(body, fields2) {
|
|
4420
|
+
const selected = fields2.filter((field) => body[field] !== void 0);
|
|
3522
4421
|
return selected.length > 0 ? selected.join(", ") : "none selected";
|
|
3523
4422
|
}
|
|
3524
4423
|
function buildShopSettingsPreview(current, args) {
|
|
@@ -3562,20 +4461,20 @@ function registerShopConfigTools(server, ctx) {
|
|
|
3562
4461
|
title: "Configure safe shop-level loyalty settings",
|
|
3563
4462
|
description: "WRITE \u2014 updates a deliberately limited set of shop-level loyalty settings. The backend splits this request between the shops collection and a separate settings document without atomic rollback, so a partial failure is possible. SSO controls, tier-specific earning limits, and weekly-report BCC recipients are intentionally unavailable through this tool.",
|
|
3564
4463
|
inputSchema: {
|
|
3565
|
-
primary_language_in_app:
|
|
3566
|
-
date_format:
|
|
3567
|
-
date_format_for_activity:
|
|
3568
|
-
point_position:
|
|
3569
|
-
customer_apply_discount_behavior:
|
|
3570
|
-
customer_redeem_coupon_behavior:
|
|
3571
|
-
customer_free_gift_behavior:
|
|
3572
|
-
status_for_review:
|
|
3573
|
-
earn_by_order_status:
|
|
3574
|
-
enable_global_earning_limit:
|
|
3575
|
-
global_earning_limit_timeframe:
|
|
3576
|
-
global_earning_limit_rolling_days:
|
|
3577
|
-
reason:
|
|
3578
|
-
apply:
|
|
4464
|
+
primary_language_in_app: z19.enum(["en", "en-US", "fr", "es", "de", "it", "ja", "id", "zh-CN"]).optional(),
|
|
4465
|
+
date_format: z19.enum(["MM/DD/YYYY", "DD/MM/YYYY", "YYYY/MM/DD", "YYYY-MM-DD"]).optional(),
|
|
4466
|
+
date_format_for_activity: z19.enum(["HH:mm", "hh:mm A"]).optional(),
|
|
4467
|
+
point_position: z19.enum(["before", "after"]).optional(),
|
|
4468
|
+
customer_apply_discount_behavior: z19.enum(["stay_current", "go_to_cart", "open_cart_drawer"]).optional(),
|
|
4469
|
+
customer_redeem_coupon_behavior: z19.enum(["auto_apply", "save_for_later"]).optional(),
|
|
4470
|
+
customer_free_gift_behavior: z19.enum(["manual_add_to_cart", "auto_add_to_cart"]).optional(),
|
|
4471
|
+
status_for_review: z19.enum(["pending", "approveAuto"]).optional(),
|
|
4472
|
+
earn_by_order_status: z19.enum(["financial_status", "fulfillment_status"]).optional(),
|
|
4473
|
+
enable_global_earning_limit: z19.boolean().optional(),
|
|
4474
|
+
global_earning_limit_timeframe: z19.enum(["yearly", "monthly", "rolling"]).optional(),
|
|
4475
|
+
global_earning_limit_rolling_days: z19.number().int().min(1).max(365).optional(),
|
|
4476
|
+
reason: z19.string().min(3).describe("Why the merchant wants these shop-wide settings changed."),
|
|
4477
|
+
apply: z19.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
|
|
3579
4478
|
},
|
|
3580
4479
|
destructiveHint: false,
|
|
3581
4480
|
strictArguments: true,
|
|
@@ -3597,10 +4496,10 @@ function registerShopConfigTools(server, ctx) {
|
|
|
3597
4496
|
title: "Add a storefront translation language",
|
|
3598
4497
|
description: "WRITE \u2014 adds one storefront language by machine-translating the shop's entire existing primary-language content. This can be expensive and is hard to undo, so it always requires a dry-run preview and explicit approval before applying.",
|
|
3599
4498
|
inputSchema: {
|
|
3600
|
-
primary_language:
|
|
3601
|
-
additional_language:
|
|
3602
|
-
reason:
|
|
3603
|
-
apply:
|
|
4499
|
+
primary_language: z19.enum(TRANSLATION_LOCALES),
|
|
4500
|
+
additional_language: z19.enum(TRANSLATION_LOCALES),
|
|
4501
|
+
reason: z19.string().min(3).describe("Why the merchant wants this storefront language added."),
|
|
4502
|
+
apply: z19.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
|
|
3604
4503
|
},
|
|
3605
4504
|
destructiveHint: false,
|
|
3606
4505
|
strictArguments: true,
|
|
@@ -3619,7 +4518,7 @@ function registerShopConfigTools(server, ctx) {
|
|
|
3619
4518
|
}
|
|
3620
4519
|
|
|
3621
4520
|
// ../mcp-core/build/tools/brandingWrite.js
|
|
3622
|
-
import { z as
|
|
4521
|
+
import { z as z20 } from "zod";
|
|
3623
4522
|
var WIDGET_DRAFT_SAVED_MESSAGE = "Saved to the V4 draft. The storefront is unchanged until you publish it in Joy admin.";
|
|
3624
4523
|
var HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
3625
4524
|
var COLOR_ROLE_PATHS = {
|
|
@@ -3662,26 +4561,26 @@ var BUTTON_STYLE_VALUES = ["store", "preset", "custom"];
|
|
|
3662
4561
|
var CONTENT_STYLE_VALUES = ["icons_and_text", "text_only"];
|
|
3663
4562
|
var DISPLAY_TYPE_VALUES = ["widget", "drawer", "floating-drawer"];
|
|
3664
4563
|
var widgetDraftInputSchema = {
|
|
3665
|
-
version:
|
|
3666
|
-
target:
|
|
3667
|
-
apply_scan:
|
|
3668
|
-
scan_url:
|
|
3669
|
-
storefront_password:
|
|
3670
|
-
apply_images:
|
|
3671
|
-
color_overrides:
|
|
3672
|
-
design_overrides:
|
|
3673
|
-
corner_radius:
|
|
3674
|
-
button_style:
|
|
3675
|
-
content_style:
|
|
3676
|
-
display_type:
|
|
4564
|
+
version: z20.enum(["v3", "v4"]).describe("Widget version to change. A live target is accepted only when this is the shop\u2019s currently live version."),
|
|
4565
|
+
target: z20.enum(["draft", "live"]).default("draft").describe("Draft by default. Set live only after the merchant approves a dry-run preview of the immediate storefront change."),
|
|
4566
|
+
apply_scan: z20.boolean().optional().describe("Apply a storefront scan through Joy\u2019s shared backend design merge. Omit scan_url to scan this shop\u2019s own storefront."),
|
|
4567
|
+
scan_url: z20.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL to scan when apply_scan is true."),
|
|
4568
|
+
storefront_password: z20.string().min(1).optional().describe("Optional storefront password used only when apply_scan is true. It is sent only in the PUT body and is never returned."),
|
|
4569
|
+
apply_images: z20.boolean().optional().describe("When apply_scan is true, also replace detected logo and card imagery. Defaults to false."),
|
|
4570
|
+
color_overrides: z20.record(z20.string()).optional().describe(`Optional hex overrides for supported V4 color roles: ${COLOR_ROLES.join(", ")}.`),
|
|
4571
|
+
design_overrides: z20.object({
|
|
4572
|
+
corner_radius: z20.enum(CORNER_RADIUS_VALUES).optional().describe("Corner style. radiusNone is square/not rounded; radiusExtra is the most rounded."),
|
|
4573
|
+
button_style: z20.enum(BUTTON_STYLE_VALUES).optional().describe("store matches the storefront, custom uses the widget\u2019s own button colours."),
|
|
4574
|
+
content_style: z20.enum(CONTENT_STYLE_VALUES).optional().describe("text_only removes the icons beside earn/redeem rows; icons_and_text shows them."),
|
|
4575
|
+
display_type: z20.enum(DISPLAY_TYPE_VALUES).optional().describe("widget is the inline panel; drawer slides in from the edge; floating-drawer is the floating panel with a launcher button.")
|
|
3677
4576
|
}).optional().describe("Optional non-colour design settings. Values are enums, not numbers or CSS lengths."),
|
|
3678
|
-
font_overrides:
|
|
3679
|
-
reason:
|
|
3680
|
-
apply:
|
|
4577
|
+
font_overrides: z20.object({ heading: z20.string().min(1).optional(), body: z20.string().min(1).optional() }).optional().describe("Optional heading and body font overrides."),
|
|
4578
|
+
reason: z20.string().min(3).describe("Why the merchant wants this widget draft."),
|
|
4579
|
+
apply: z20.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
|
|
3681
4580
|
};
|
|
3682
4581
|
var brandScanInputSchema = {
|
|
3683
|
-
url:
|
|
3684
|
-
storefront_password:
|
|
4582
|
+
url: z20.string().url().startsWith("https://").optional().describe("Optional public HTTPS storefront URL. Omit it to scan this shop\u2019s own storefront."),
|
|
4583
|
+
storefront_password: z20.string().min(1).optional().describe("Optional storefront password for a gated shop. It is sent only in the POST body and is never returned.")
|
|
3685
4584
|
};
|
|
3686
4585
|
function isHexColor(value) {
|
|
3687
4586
|
return typeof value === "string" && HEX_COLOR.test(value);
|
|
@@ -3706,19 +4605,19 @@ function brandingErrorGuidance(error, operation, target = "draft") {
|
|
|
3706
4605
|
const passwordIncorrect = errorCode === "PASSWORD_INCORRECT" || /PASSWORD_INCORRECT|storefront password (?:did not unlock|was not accepted)|incorrect storefront password/i.test(error.modelMessage);
|
|
3707
4606
|
const passwordRequired = errorCode === "PASSWORD_REQUIRED" || error.status === 409 || /PASSWORD_REQUIRED|storefront is password-protected|storefront password is required/i.test(error.modelMessage);
|
|
3708
4607
|
if (operation === "scan" && passwordIncorrect) {
|
|
3709
|
-
return new JoyApiError("validation", "The storefront password was not accepted. Ask the merchant to verify the storefront password, then pass the corrected value as storefront_password. Do not guess it.", error.status);
|
|
4608
|
+
return new JoyApiError("validation", "The storefront password was not accepted. Ask the merchant to verify the storefront password, then pass the corrected value as storefront_password. Do not guess it.", error.status, error.serverCode);
|
|
3710
4609
|
}
|
|
3711
4610
|
if (operation === "scan" && passwordRequired) {
|
|
3712
|
-
return new JoyApiError("conflict", "The storefront is password-protected. Ask the merchant for the storefront password, then pass it as storefront_password to joy_scan_brand_design. Do not guess the password, and do not invent colors or fonts.", error.status);
|
|
4611
|
+
return new JoyApiError("conflict", "The storefront is password-protected. Ask the merchant for the storefront password, then pass it as storefront_password to joy_scan_brand_design. Do not guess the password, and do not invent colors or fonts.", error.status, error.serverCode);
|
|
3713
4612
|
}
|
|
3714
4613
|
if (operation === "scan" && error.status === 503) {
|
|
3715
|
-
return new JoyApiError("server", "Joy could not complete the storefront design scan. No design has been returned, so do not invent colors, fonts, or button styling. Ask the merchant to retry later or configure the draft manually.", error.status);
|
|
4614
|
+
return new JoyApiError("server", "Joy could not complete the storefront design scan. No design has been returned, so do not invent colors, fonts, or button styling. Ask the merchant to retry later or configure the draft manually.", error.status, error.serverCode);
|
|
3716
4615
|
}
|
|
3717
4616
|
if (operation === "draft" && target === "live" && error.status === 409) {
|
|
3718
|
-
return new JoyApiError("conflict", "The live widget document exists only for the version the shop is currently using. This target: 'live' request used a non-live version and was refused. Check which widget version is currently live before retrying; do not blindly retry the other version.", error.status);
|
|
4617
|
+
return new JoyApiError("conflict", "The live widget document exists only for the version the shop is currently using. This target: 'live' request used a non-live version and was refused. Check which widget version is currently live before retrying; do not blindly retry the other version.", error.status, error.serverCode);
|
|
3719
4618
|
}
|
|
3720
4619
|
if (operation === "draft" && error.status === 409) {
|
|
3721
|
-
return new JoyApiError("conflict", "The requested widget version is currently live. This tool only writes the non-live draft so it cannot alter the storefront. Choose the other version or edit the live widget manually in Joy admin.", error.status);
|
|
4620
|
+
return new JoyApiError("conflict", "The requested widget version is currently live. This tool only writes the non-live draft so it cannot alter the storefront. Choose the other version or edit the live widget manually in Joy admin.", error.status, error.serverCode);
|
|
3722
4621
|
}
|
|
3723
4622
|
return void 0;
|
|
3724
4623
|
}
|
|
@@ -3781,7 +4680,7 @@ function errorWithoutCredential(error, credential) {
|
|
|
3781
4680
|
if (!credential)
|
|
3782
4681
|
return error;
|
|
3783
4682
|
if (error instanceof JoyApiError) {
|
|
3784
|
-
return new JoyApiError(error.kind, redactCredential(error.modelMessage, credential), error.status);
|
|
4683
|
+
return new JoyApiError(error.kind, redactCredential(error.modelMessage, credential), error.status, error.serverCode);
|
|
3785
4684
|
}
|
|
3786
4685
|
if (error instanceof Error)
|
|
3787
4686
|
return new Error(redactCredential(error.message, credential));
|
|
@@ -3934,7 +4833,7 @@ async function putWidgetScan(ctx, args) {
|
|
|
3934
4833
|
}
|
|
3935
4834
|
}
|
|
3936
4835
|
function registerBrandingReadTools(server, ctx) {
|
|
3937
|
-
registerReadTool(server, {
|
|
4836
|
+
registerReadTool(server, ctx, {
|
|
3938
4837
|
name: "joy_scan_brand_design",
|
|
3939
4838
|
title: "Scan a storefront\u2019s design",
|
|
3940
4839
|
description: "READ \u2014 scans an optional public HTTPS storefront URL, or this shop\u2019s storefront when omitted, and returns detected colors, heading/body fonts, button style, and whether the result was cached. For a gated storefront, ask the merchant for its password and pass storefront_password; never guess it. The result is a design input, not visual proof of how the widget will look.",
|
|
@@ -4000,13 +4899,13 @@ No write was sent because this would be a no-op.`, args.storefront_password);
|
|
|
4000
4899
|
throw new JoyApiError("server", `${failureGuidance} These fields were unchanged after the required read-back: ${missing.join(", ")}.`);
|
|
4001
4900
|
}
|
|
4002
4901
|
}
|
|
4003
|
-
const
|
|
4902
|
+
const confirmed2 = [
|
|
4004
4903
|
...scanApplied ? ["the backend storefront-design merge"] : [],
|
|
4005
4904
|
...explicit.changes.length > 0 ? [
|
|
4006
4905
|
`${explicit.changes.length} explicit field${explicit.changes.length === 1 ? "" : "s"}`
|
|
4007
4906
|
] : []
|
|
4008
4907
|
].join(" and ");
|
|
4009
|
-
const savedSummary = target === "live" ? `Updated the live ${args.version.toUpperCase()} widget configuration and confirmed ${
|
|
4908
|
+
const savedSummary = target === "live" ? `Updated the live ${args.version.toUpperCase()} widget configuration and confirmed ${confirmed2} by reading it back. The storefront changed as soon as it saved; there is no publish step and no undo.` : `Updated ${args.version.toUpperCase()} widget draft and confirmed ${confirmed2} by reading it back. The agent cannot see the rendered result; review it in Joy admin.`;
|
|
4010
4909
|
const result = `${summaryWithJson(savedSummary, {
|
|
4011
4910
|
...scanApplied ? { appliedScan: { colors: true, images: args.apply_images === true } } : {},
|
|
4012
4911
|
...explicit.changes.length > 0 ? { applied: explicit.changes.map((change) => ({ path: change.path, value: change.to })) } : {}
|
|
@@ -4022,22 +4921,40 @@ ${WIDGET_DRAFT_SAVED_MESSAGE}` : ""}`;
|
|
|
4022
4921
|
|
|
4023
4922
|
// ../mcp-core/build/tools/widgetFields.js
|
|
4024
4923
|
import { isDeepStrictEqual } from "node:util";
|
|
4025
|
-
import { z as
|
|
4924
|
+
import { z as z21 } from "zod";
|
|
4026
4925
|
var fieldsPath = "/shop/branding/widget/fields";
|
|
4027
|
-
var
|
|
4926
|
+
var HIDE_MASTER = "display.hideWidgetButton";
|
|
4927
|
+
var HIDE_ALL = "launcher.hideCompletely";
|
|
4928
|
+
var HIDE_MOBILE = "launcher.hideOnMobile";
|
|
4929
|
+
function expandLauncherHideFields(fields2, current) {
|
|
4930
|
+
const byPath = new Map(fields2.map(({ path, value }) => [path, value]));
|
|
4931
|
+
const currentOf = (path) => current.find((field) => field.path === path)?.current;
|
|
4932
|
+
const get = (path) => byPath.has(path) ? byPath.get(path) : currentOf(path);
|
|
4933
|
+
const hideMaster = get(HIDE_MASTER);
|
|
4934
|
+
const hideAll = get(HIDE_ALL);
|
|
4935
|
+
const hideMobile = get(HIDE_MOBILE);
|
|
4936
|
+
if ((hideAll === true || hideMobile === true) && hideMaster !== true) {
|
|
4937
|
+
byPath.set(HIDE_MASTER, true);
|
|
4938
|
+
}
|
|
4939
|
+
if (hideMaster === true && hideAll !== true && hideMobile !== true) {
|
|
4940
|
+
byPath.set(HIDE_ALL, true);
|
|
4941
|
+
}
|
|
4942
|
+
return [...byPath.entries()].map(([path, value]) => ({ path, value }));
|
|
4943
|
+
}
|
|
4944
|
+
var versionSchema = z21.literal("v4").default("v4").describe("Widget version. Field discovery currently supports V4.");
|
|
4028
4945
|
var widgetFieldsInputSchema = {
|
|
4029
4946
|
version: versionSchema,
|
|
4030
|
-
target:
|
|
4031
|
-
fields:
|
|
4032
|
-
path:
|
|
4033
|
-
value:
|
|
4947
|
+
target: z21.enum(["draft", "live"]).default("draft").describe("Draft by default. Use live only when the merchant explicitly requests and approves the immediate storefront change; there is no undo."),
|
|
4948
|
+
fields: z21.array(z21.object({
|
|
4949
|
+
path: z21.string().min(1),
|
|
4950
|
+
value: z21.unknown().refine((value) => value !== void 0, "Each field must include a value; use null to clear it.")
|
|
4034
4951
|
})).min(1).describe("Paths and values to set. Discover paths and allowed values with joy_get_widget_fields. The server validates every value."),
|
|
4035
|
-
reason:
|
|
4036
|
-
apply:
|
|
4952
|
+
reason: z21.string().min(3).describe("Why the merchant wants these widget changes."),
|
|
4953
|
+
apply: z21.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
|
|
4037
4954
|
};
|
|
4038
4955
|
var MAX_FIELD_RESPONSE_CHARS = 12e3;
|
|
4039
|
-
function estimateSize(
|
|
4040
|
-
return
|
|
4956
|
+
function estimateSize(fields2) {
|
|
4957
|
+
return fields2.reduce((total, field) => total + field.path.length + field.label.length + 60 + (field.values?.join("").length ?? 0), 0);
|
|
4041
4958
|
}
|
|
4042
4959
|
async function getWidgetFields(ctx, version2) {
|
|
4043
4960
|
const { data } = await ctx.api.getData(fieldsPath, { query: { version: version2 } });
|
|
@@ -4047,30 +4964,30 @@ async function getWidgetFields(ctx, version2) {
|
|
|
4047
4964
|
return data.fields;
|
|
4048
4965
|
}
|
|
4049
4966
|
function registerWidgetFieldReadTools(server, ctx) {
|
|
4050
|
-
registerReadTool(server, {
|
|
4967
|
+
registerReadTool(server, ctx, {
|
|
4051
4968
|
name: "joy_get_widget_fields",
|
|
4052
4969
|
title: "Get settable widget fields",
|
|
4053
4970
|
description: "READ \u2014 discovers settable V4 widget fields. With no arguments it returns the group index (names, counts, example paths); pass group or search to get those fields with merchant-facing labels, paths, types, current values and allowed enum values. Call before joy_set_widget_fields; never guess a field path or enum value.",
|
|
4054
4971
|
inputSchema: {
|
|
4055
4972
|
version: versionSchema,
|
|
4056
|
-
group:
|
|
4057
|
-
search:
|
|
4973
|
+
group: z21.string().optional().describe("Return only this group. Omit to get the group index first."),
|
|
4974
|
+
search: z21.string().optional().describe('Return only fields whose label or path contains this text, e.g. "corner" or "launcher".')
|
|
4058
4975
|
},
|
|
4059
4976
|
handler: async (args) => {
|
|
4060
|
-
const
|
|
4977
|
+
const fields2 = await getWidgetFields(ctx, args.version);
|
|
4061
4978
|
const term = args.search?.trim().toLocaleLowerCase();
|
|
4062
|
-
const matched =
|
|
4979
|
+
const matched = fields2.filter((field) => (args.group === void 0 || field.group === args.group) && (term === void 0 || term === "" || field.label.toLocaleLowerCase().includes(term) || field.path.toLocaleLowerCase().includes(term)));
|
|
4063
4980
|
const wouldNotFit = estimateSize(matched) > MAX_FIELD_RESPONSE_CHARS;
|
|
4064
4981
|
if (args.group === void 0 && !term && wouldNotFit) {
|
|
4065
4982
|
const index = {};
|
|
4066
|
-
for (const field of
|
|
4983
|
+
for (const field of fields2) {
|
|
4067
4984
|
const entry = index[field.group] ?? { fields: 0, examples: [] };
|
|
4068
4985
|
entry.fields += 1;
|
|
4069
4986
|
if (entry.examples.length < 2)
|
|
4070
4987
|
entry.examples.push(field.path);
|
|
4071
4988
|
index[field.group] = entry;
|
|
4072
4989
|
}
|
|
4073
|
-
return summaryWithJson(`${
|
|
4990
|
+
return summaryWithJson(`${fields2.length} settable ${args.version.toUpperCase()} widget fields across ${Object.keys(index).length} groups. Call again with group: "<name>" or search: "<text>" to see fields and their allowed values.`, { groups: index });
|
|
4074
4991
|
}
|
|
4075
4992
|
if (matched.length === 0) {
|
|
4076
4993
|
return `No ${args.version.toUpperCase()} widget field matches ${args.group === void 0 ? `search "${args.search}"` : `group "${args.group}"`}. Call joy_get_widget_fields with no arguments to list the groups.`;
|
|
@@ -4081,7 +4998,7 @@ function registerWidgetFieldReadTools(server, ctx) {
|
|
|
4081
4998
|
entries.push({ label, path, type, current, ...values === void 0 ? {} : { values } });
|
|
4082
4999
|
groups.set(group, entries);
|
|
4083
5000
|
}
|
|
4084
|
-
return summaryWithJson(`${matched.length} of ${
|
|
5001
|
+
return summaryWithJson(`${matched.length} of ${fields2.length} settable ${args.version.toUpperCase()} widget fields. Use the server-provided paths and values when preparing changes.`, { groups: Object.fromEntries(groups) });
|
|
4085
5002
|
}
|
|
4086
5003
|
});
|
|
4087
5004
|
}
|
|
@@ -4089,13 +5006,14 @@ function registerWidgetFieldWriteTools(server, ctx) {
|
|
|
4089
5006
|
registerConfigWriteTool(server, ctx, {
|
|
4090
5007
|
name: "joy_set_widget_fields",
|
|
4091
5008
|
title: "Set widget fields",
|
|
4092
|
-
description: "CONFIG WRITE \u2014 set paths discovered by joy_get_widget_fields; the server validates fields and values. Previews each before \u2192 after by default, surfaces every refusal, and reads back after writing. Draft by default; never switch to live unless the merchant explicitly requests target: 'live'. A live save changes the storefront immediately with no publish step and no undo. Set apply: true only after preview approval; do not claim visual verification.",
|
|
5009
|
+
description: "CONFIG WRITE \u2014 set paths discovered by joy_get_widget_fields; the server validates fields and values. Previews each before \u2192 after by default, surfaces every refusal, and reads back after writing. Hiding the floating button needs display.hideWidgetButton AND launcher.hideCompletely (or hideOnMobile); setting one expands the other to match the admin Display panel. Account icon opening the widget is links.replaceAccountLink, not links.openWidgetAfterLogin. Draft by default; never switch to live unless the merchant explicitly requests target: 'live'. A live save changes the storefront immediately with no publish step and no undo. Set apply: true only after preview approval; do not claim visual verification.",
|
|
4093
5010
|
inputSchema: widgetFieldsInputSchema,
|
|
4094
5011
|
strictArguments: true,
|
|
4095
5012
|
destructiveHint: true,
|
|
4096
5013
|
handler: async (args) => {
|
|
4097
5014
|
const current = await getWidgetFields(ctx, args.version);
|
|
4098
|
-
const
|
|
5015
|
+
const fields2 = expandLauncherHideFields(args.fields, current);
|
|
5016
|
+
const changes = fields2.map(({ path, value }) => ({
|
|
4099
5017
|
path,
|
|
4100
5018
|
from: current.find((field) => field.path === path)?.current,
|
|
4101
5019
|
to: value
|
|
@@ -4110,7 +5028,7 @@ The server will validate every requested path and value when applied; this previ
|
|
|
4110
5028
|
result = await ctx.api.mutateData(fieldsPath, {
|
|
4111
5029
|
method: "PUT",
|
|
4112
5030
|
query: { version: args.version, target: args.target },
|
|
4113
|
-
body: { fields:
|
|
5031
|
+
body: { fields: fields2 }
|
|
4114
5032
|
});
|
|
4115
5033
|
} catch (error) {
|
|
4116
5034
|
throw brandingErrorGuidance(error, "draft", args.target) ?? error;
|
|
@@ -4123,23 +5041,23 @@ The server will validate every requested path and value when applied; this previ
|
|
|
4123
5041
|
readBackFailure = error instanceof Error ? error.message : String(error);
|
|
4124
5042
|
}
|
|
4125
5043
|
const refusedPaths = new Set(result.refused.map((field) => field.path));
|
|
4126
|
-
const
|
|
5044
|
+
const applied2 = [];
|
|
4127
5045
|
const unconfirmed = [];
|
|
4128
|
-
for (const field of
|
|
5046
|
+
for (const field of fields2) {
|
|
4129
5047
|
if (refusedPaths.has(field.path))
|
|
4130
5048
|
continue;
|
|
4131
5049
|
const accepted = result.applied.find((entry) => entry.path === field.path);
|
|
4132
5050
|
const observed = readBack.find((entry) => entry.path === field.path);
|
|
4133
5051
|
if (!readBackFailure && accepted && observed && isDeepStrictEqual(accepted.value, field.value) && isDeepStrictEqual(observed.current, field.value)) {
|
|
4134
|
-
|
|
5052
|
+
applied2.push({ path: field.path, value: field.value });
|
|
4135
5053
|
continue;
|
|
4136
5054
|
}
|
|
4137
5055
|
unconfirmed.push({ path: field.path, requested: field.value, current: observed?.current });
|
|
4138
5056
|
}
|
|
4139
|
-
const summary = `Widget field write: ${
|
|
5057
|
+
const summary = `Widget field write: ${applied2.length} confirmed by read-back, ${result.refused.length} refused, ${unconfirmed.length} unconfirmed.`;
|
|
4140
5058
|
const details = `${summary}
|
|
4141
|
-
${JSON.stringify({ refused: result.refused, unconfirmed, applied }, null, 1)}`;
|
|
4142
|
-
const consequence = args.target === "live" ? "WARNING \u2014 saving this changes the storefront immediately. There is NO publish step and NO undo." :
|
|
5059
|
+
${JSON.stringify({ refused: result.refused, unconfirmed, applied: applied2 }, null, 1)}`;
|
|
5060
|
+
const consequence = args.target === "live" ? "WARNING \u2014 saving this changes the storefront immediately. There is NO publish step and NO undo." : applied2.length > 0 ? WIDGET_DRAFT_SAVED_MESSAGE : "Review the V4 draft in Joy admin.";
|
|
4143
5061
|
const report = `${details}
|
|
4144
5062
|
${consequence}${readBackFailure ? `
|
|
4145
5063
|
Read-back failed: ${readBackFailure}` : ""}`;
|
|
@@ -4153,7 +5071,7 @@ Do not report refused or unconfirmed fields as saved. Inspect the current widget
|
|
|
4153
5071
|
}
|
|
4154
5072
|
|
|
4155
5073
|
// ../mcp-core/build/tools/referralBrandingWrite.js
|
|
4156
|
-
import { z as
|
|
5074
|
+
import { z as z22 } from "zod";
|
|
4157
5075
|
var POPUP_DISPLAY_TYPES = ["auto_show_popup", "show_bubble_teaser", "do_not_show"];
|
|
4158
5076
|
var REFERRAL_WIDGET_ARGUMENTS = {
|
|
4159
5077
|
brand_color: "brandColor",
|
|
@@ -4169,24 +5087,24 @@ var REFERRAL_WIDGET_ARGUMENTS = {
|
|
|
4169
5087
|
frequency_show_popup: "frequencyShowPopup",
|
|
4170
5088
|
is_show_subscribe_newsletter: "isShowSubscribeNewsletter"
|
|
4171
5089
|
};
|
|
4172
|
-
var hexColor = (description) =>
|
|
5090
|
+
var hexColor = (description) => z22.string().refine(isHexColor, { message: "Must be a 3- or 6-digit hex color such as #123 or #112233." }).optional().describe(description);
|
|
4173
5091
|
var referralWidgetInputShape = {
|
|
4174
5092
|
brand_color: hexColor("Primary accent color for the referral widget."),
|
|
4175
5093
|
text_color: hexColor("Main referral-widget text color."),
|
|
4176
5094
|
action_bg_color: hexColor("Referral-widget action button background color."),
|
|
4177
5095
|
action_text_color: hexColor("Referral-widget action button text color."),
|
|
4178
|
-
color_type:
|
|
5096
|
+
color_type: z22.enum(["template", "custom-solid", "custom-gradient"]).optional().describe("Color mode. This endpoint can select an existing template or gradient, but cannot define gradient stops."),
|
|
4179
5097
|
custom_solid_color: hexColor("Custom solid background color used when color_type is custom-solid."),
|
|
4180
|
-
theme_text_font:
|
|
4181
|
-
type_show_popup:
|
|
4182
|
-
is_auto_close_pop_up:
|
|
4183
|
-
closing_pop_up_time:
|
|
4184
|
-
frequency_show_popup:
|
|
4185
|
-
is_show_subscribe_newsletter:
|
|
4186
|
-
reason:
|
|
4187
|
-
apply:
|
|
5098
|
+
theme_text_font: z22.string().trim().min(1).optional().describe("Referral-widget font family, for example Open+Sans."),
|
|
5099
|
+
type_show_popup: z22.enum(POPUP_DISPLAY_TYPES).optional().describe("How the referral popup appears: automatically, as a bubble teaser, or not at all."),
|
|
5100
|
+
is_auto_close_pop_up: z22.boolean().optional().describe("Whether an automatically shown referral popup closes itself."),
|
|
5101
|
+
closing_pop_up_time: z22.number().int().min(1).optional().describe("Auto-close delay in seconds; must be a positive integer."),
|
|
5102
|
+
frequency_show_popup: z22.number().int().min(1).max(10).optional().describe("Number of automatic popup displays, from 1 through 10. The admin UI stores this as an integer."),
|
|
5103
|
+
is_show_subscribe_newsletter: z22.boolean().optional().describe("Show the newsletter subscription option in the referral popup."),
|
|
5104
|
+
reason: z22.string().min(3).describe("Why the merchant wants the live referral widget changed."),
|
|
5105
|
+
apply: z22.boolean().optional().describe("Set true only after the merchant has approved the dry-run preview.")
|
|
4188
5106
|
};
|
|
4189
|
-
var referralWidgetArgsSchema =
|
|
5107
|
+
var referralWidgetArgsSchema = z22.object(referralWidgetInputShape).strict();
|
|
4190
5108
|
function parseReferralWidgetArgs(input) {
|
|
4191
5109
|
const parsed = referralWidgetArgsSchema.safeParse(input);
|
|
4192
5110
|
if (parsed.success)
|
|
@@ -4252,6 +5170,369 @@ function registerReferralBrandingWriteTools(server, ctx) {
|
|
|
4252
5170
|
});
|
|
4253
5171
|
}
|
|
4254
5172
|
|
|
5173
|
+
// ../mcp-core/build/tools/redeemInCart.js
|
|
5174
|
+
import { z as z23 } from "zod";
|
|
5175
|
+
var PATH = "/shop/branding/redeem-inline";
|
|
5176
|
+
var styles = [
|
|
5177
|
+
"minimal",
|
|
5178
|
+
"editorial",
|
|
5179
|
+
"luxury",
|
|
5180
|
+
"bold",
|
|
5181
|
+
"brutalism",
|
|
5182
|
+
"playful",
|
|
5183
|
+
"tech",
|
|
5184
|
+
"botanic"
|
|
5185
|
+
];
|
|
5186
|
+
var fills = ["solid", "gradient-br", "gradient-r", "gradient-b", "radial"];
|
|
5187
|
+
var gradients = ["gradient-br", "gradient-r", "gradient-b", "radial"];
|
|
5188
|
+
var hex = z23.string().refine((value) => value === "" || isHexColor(value), "Use a hex color or an empty string to clear.");
|
|
5189
|
+
var https = z23.string().refine((value) => value === "" || (() => {
|
|
5190
|
+
try {
|
|
5191
|
+
return new URL(value).protocol === "https:";
|
|
5192
|
+
} catch {
|
|
5193
|
+
return false;
|
|
5194
|
+
}
|
|
5195
|
+
})(), "Use an HTTPS URL or an empty string to clear.");
|
|
5196
|
+
var fill = z23.object({
|
|
5197
|
+
fillType: z23.enum(fills).optional(),
|
|
5198
|
+
colors: z23.array(z23.string()).min(1).max(2).optional(),
|
|
5199
|
+
lastGradient: z23.enum(gradients).optional()
|
|
5200
|
+
}).strict();
|
|
5201
|
+
var colorFields = [
|
|
5202
|
+
"text",
|
|
5203
|
+
"dropdown",
|
|
5204
|
+
"primary",
|
|
5205
|
+
"buttonText",
|
|
5206
|
+
"buttonHover",
|
|
5207
|
+
"buttonHoverText",
|
|
5208
|
+
"savings",
|
|
5209
|
+
"sliderPrimary",
|
|
5210
|
+
"sliderTrack",
|
|
5211
|
+
"icon",
|
|
5212
|
+
"dropdownAccent"
|
|
5213
|
+
];
|
|
5214
|
+
var colorsSchema = z23.object({
|
|
5215
|
+
background: fill.optional(),
|
|
5216
|
+
button: fill.optional(),
|
|
5217
|
+
...Object.fromEntries(colorFields.map((key) => [key, hex.optional()])),
|
|
5218
|
+
buttonBorder: z23.string().optional(),
|
|
5219
|
+
buttonHoverBorder: z23.string().optional()
|
|
5220
|
+
}).strict();
|
|
5221
|
+
var cssShapeFields = [
|
|
5222
|
+
"buttonHoverOpacity",
|
|
5223
|
+
"buttonHoverTransform",
|
|
5224
|
+
"buttonHoverShadow",
|
|
5225
|
+
"buttonShadow",
|
|
5226
|
+
"buttonBorderWidth",
|
|
5227
|
+
"buttonBorderStyle",
|
|
5228
|
+
"buttonFontWeight",
|
|
5229
|
+
"buttonLetterSpacing",
|
|
5230
|
+
"buttonTextCase",
|
|
5231
|
+
"buttonTransition",
|
|
5232
|
+
"buttonHeight",
|
|
5233
|
+
"buttonFontSize",
|
|
5234
|
+
"buttonPaddingInline",
|
|
5235
|
+
"buttonSweepColor",
|
|
5236
|
+
"buttonSweepFrom",
|
|
5237
|
+
"buttonSweepTo",
|
|
5238
|
+
"buttonSweepDuration",
|
|
5239
|
+
"buttonSweepEasing"
|
|
5240
|
+
];
|
|
5241
|
+
var fonts = ["default", "inter", "space-grotesk", "playfair", "pt-serif", "nunito"];
|
|
5242
|
+
var shapeSchema = z23.object({
|
|
5243
|
+
...Object.fromEntries(cssShapeFields.map((key) => [key, z23.string().optional()])),
|
|
5244
|
+
borderColor: hex.optional(),
|
|
5245
|
+
patternColor: hex.optional(),
|
|
5246
|
+
dividerColor: hex.optional(),
|
|
5247
|
+
borderRadius: z23.enum(["none", "sharp", "soft", "rounded", "extra"]).optional(),
|
|
5248
|
+
backgroundPattern: z23.enum(["none", "diagonal", "crosshatch", "grid", "column"]).optional(),
|
|
5249
|
+
headingFont: z23.enum(fonts).optional(),
|
|
5250
|
+
bodyFont: z23.enum(["", ...fonts]).optional(),
|
|
5251
|
+
buttonFont: z23.enum(["", ...fonts]).optional(),
|
|
5252
|
+
applyLoadingStyle: z23.enum(["spinner", "spinner-label", "dots"]).optional(),
|
|
5253
|
+
buttonHoverEffect: z23.enum(["auto", "color", "fade", "lift", "none", "sweep"]).optional(),
|
|
5254
|
+
boxShadow: z23.enum(["none", "small", "medium", "large", "hard"]).optional(),
|
|
5255
|
+
iconName: z23.enum([
|
|
5256
|
+
"gift",
|
|
5257
|
+
"crown",
|
|
5258
|
+
"shopping-bag",
|
|
5259
|
+
"star",
|
|
5260
|
+
"heart",
|
|
5261
|
+
"trophy",
|
|
5262
|
+
"sparkle",
|
|
5263
|
+
"ticket",
|
|
5264
|
+
"none"
|
|
5265
|
+
]).optional(),
|
|
5266
|
+
iconImage: https.optional(),
|
|
5267
|
+
backgroundImage: https.optional(),
|
|
5268
|
+
backgroundImageOverlay: z23.number().min(0).max(100).optional(),
|
|
5269
|
+
dividerWidth: z23.enum(["thin", "medium", "thick"]).optional(),
|
|
5270
|
+
dividerStyle: z23.enum(["solid", "dashed", "dotted"]).optional()
|
|
5271
|
+
}).strict();
|
|
5272
|
+
var behaviorFields = {
|
|
5273
|
+
hide_for_guest: "hideForGuest",
|
|
5274
|
+
hide_not_enough_point: "hideNotEnoughPoint",
|
|
5275
|
+
detail_view_layout: "detailViewLayout",
|
|
5276
|
+
has_enable_cancel: "hasEnableCancel",
|
|
5277
|
+
enable_revoke_discount_apply_failed: "enableRevokeDiscountApplyFailed",
|
|
5278
|
+
show_points_input: "showPointsInput",
|
|
5279
|
+
prefer_higher_value_reward: "preferHigherValueReward",
|
|
5280
|
+
show_earn_back: "showEarnBack",
|
|
5281
|
+
has_reload_page: "hasReloadPage"
|
|
5282
|
+
};
|
|
5283
|
+
var behaviorInput = Object.fromEntries(Object.keys(behaviorFields).map((key) => [key, z23.boolean().optional()]));
|
|
5284
|
+
var common = { reason: z23.string().min(3), apply: z23.boolean().optional() };
|
|
5285
|
+
var behaviorArgs = z23.object({
|
|
5286
|
+
...behaviorInput,
|
|
5287
|
+
position_mode: z23.enum(["above_cart_summary", "custom"]).optional(),
|
|
5288
|
+
custom_type: z23.enum(["snippet", "selector"]).optional(),
|
|
5289
|
+
css_selector: z23.string().optional(),
|
|
5290
|
+
...common
|
|
5291
|
+
}).strict();
|
|
5292
|
+
var designArgs = z23.object({
|
|
5293
|
+
style: z23.enum(styles).optional(),
|
|
5294
|
+
set_active: z23.boolean().optional(),
|
|
5295
|
+
colors: colorsSchema.optional(),
|
|
5296
|
+
shape: shapeSchema.optional(),
|
|
5297
|
+
...common
|
|
5298
|
+
}).strict();
|
|
5299
|
+
function parse(schema, input) {
|
|
5300
|
+
const result = schema.safeParse(input);
|
|
5301
|
+
if (result.success)
|
|
5302
|
+
return result.data;
|
|
5303
|
+
const issue = result.error.issues[0];
|
|
5304
|
+
throw new JoyApiError("validation", `${issue.path.join(".")}: ${issue.message}`);
|
|
5305
|
+
}
|
|
5306
|
+
function valueAt(data, path) {
|
|
5307
|
+
return path.split(".").reduce((value, key) => value?.[key], data);
|
|
5308
|
+
}
|
|
5309
|
+
function changesFor(data, body) {
|
|
5310
|
+
const changes = [];
|
|
5311
|
+
function visit(value, prefix) {
|
|
5312
|
+
for (const [key, to] of Object.entries(value)) {
|
|
5313
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
5314
|
+
if (to && typeof to === "object" && !Array.isArray(to))
|
|
5315
|
+
visit(to, path);
|
|
5316
|
+
else
|
|
5317
|
+
changes.push(...diffFields({ [path]: valueAt(data, path) }, { [path]: to }));
|
|
5318
|
+
}
|
|
5319
|
+
}
|
|
5320
|
+
visit(body, "");
|
|
5321
|
+
return changes;
|
|
5322
|
+
}
|
|
5323
|
+
function warnings(data) {
|
|
5324
|
+
return [
|
|
5325
|
+
...data.redeemCartV4Ready === false ? [
|
|
5326
|
+
"V4 is disabled for this shop; settings are stored but do not show until enabled and published."
|
|
5327
|
+
] : [],
|
|
5328
|
+
"Storefront updates after background metafield publish and requires the app embed."
|
|
5329
|
+
];
|
|
5330
|
+
}
|
|
5331
|
+
function confirmed(data, body) {
|
|
5332
|
+
const missing = changesFor(data, body).map((change) => change.field);
|
|
5333
|
+
if (missing.length)
|
|
5334
|
+
throw new JoyApiError("server", `Redeem-in-cart write was not confirmed by read-back: ${missing.join(", ")}. Verify in Joy admin; do not retry blindly.`);
|
|
5335
|
+
}
|
|
5336
|
+
function applied(changes, data) {
|
|
5337
|
+
return summaryWithJson("Confirmed the Redeem-in-Cart settings in Firestore. Storefront updates after background metafield publish; the app embed is required. Prior values are included for manual restoration.", {
|
|
5338
|
+
changes: changes.map(({ field, from, to }) => ({ field, from: from ?? "(unset)", to })),
|
|
5339
|
+
current: Object.fromEntries(changes.map(({ field }) => [field, valueAt(data, field)]))
|
|
5340
|
+
});
|
|
5341
|
+
}
|
|
5342
|
+
async function getRedeemInCart(ctx, input) {
|
|
5343
|
+
const args = parse(z23.object({ style: z23.enum(styles).optional() }).strict(), input);
|
|
5344
|
+
const { data: raw } = await ctx.api.getData(PATH);
|
|
5345
|
+
const data = raw ?? {};
|
|
5346
|
+
const active = data.design?.activeStyle ?? "minimal";
|
|
5347
|
+
const legacy = data.design?.styles?.[active]?.earnBack?.show;
|
|
5348
|
+
const behavior = { ...data.behavior };
|
|
5349
|
+
for (const [field, fallback] of Object.entries({
|
|
5350
|
+
hideForGuest: false,
|
|
5351
|
+
hideNotEnoughPoint: false,
|
|
5352
|
+
detailViewLayout: false,
|
|
5353
|
+
hasEnableCancel: true,
|
|
5354
|
+
enableRevokeDiscountApplyFailed: true,
|
|
5355
|
+
showPointsInput: true,
|
|
5356
|
+
preferHigherValueReward: false
|
|
5357
|
+
}))
|
|
5358
|
+
behavior[field] ??= fallback;
|
|
5359
|
+
behavior.showEarnBackEffective = behavior.showEarnBack === void 0 ? legacy !== false : behavior.showEarnBack;
|
|
5360
|
+
if (behavior.showEarnBack === void 0) {
|
|
5361
|
+
behavior.showEarnBack = legacy === false ? "(unset \u2192 hidden by legacy preset)" : "(unset \u2192 shown)";
|
|
5362
|
+
}
|
|
5363
|
+
behavior.hasReloadPage = data.behavior?.hasReloadPage ?? "(unset)";
|
|
5364
|
+
behavior.hasReloadPageNote = "V3 theme setting, no effect on V4";
|
|
5365
|
+
const position = { ...data.position };
|
|
5366
|
+
for (const [field, fallback] of Object.entries({
|
|
5367
|
+
mode: "above_cart_summary",
|
|
5368
|
+
customType: "snippet",
|
|
5369
|
+
cssSelector: ""
|
|
5370
|
+
}))
|
|
5371
|
+
position[field] ??= fallback;
|
|
5372
|
+
const status = data.redeemCartV4Ready === void 0 ? "Redeem in cart V2 (internal V4, redeemCartV4Ready) status unknown (older backend)." : data.redeemCartV4Ready ? "Redeem in cart V2 (internal V4, redeemCartV4Ready) enabled for this shop (storefront follows after metafield publish; requires the app embed)." : "Redeem in cart V2 (internal V4, redeemCartV4Ready) disabled for this shop; stored settings do not currently apply.";
|
|
5373
|
+
const resolved = data.activeStyleResolved === void 0 ? "unknown (older backend)" : data.activeStyleResolved;
|
|
5374
|
+
return summaryWithJson(status, {
|
|
5375
|
+
behavior,
|
|
5376
|
+
position,
|
|
5377
|
+
activeStyle: active,
|
|
5378
|
+
activeStyleResolved: resolved,
|
|
5379
|
+
customisedStyles: Object.keys(data.design?.styles ?? {}),
|
|
5380
|
+
...args.style ? {
|
|
5381
|
+
style: args.style,
|
|
5382
|
+
styleResolved: data.stylesResolved?.[args.style] ?? (data.activeStyleResolved?.id === args.style ? data.activeStyleResolved : "unknown (older backend)"),
|
|
5383
|
+
storedOverrides: data.design?.styles?.[args.style] ?? {},
|
|
5384
|
+
overriddenKeys: {
|
|
5385
|
+
colors: Object.keys(data.design?.styles?.[args.style]?.colors ?? {}),
|
|
5386
|
+
shape: Object.keys(data.design?.styles?.[args.style]?.shape ?? {})
|
|
5387
|
+
},
|
|
5388
|
+
legacyEarnBackShow: data.design?.styles?.[args.style]?.earnBack?.show ?? "(unset)"
|
|
5389
|
+
} : {}
|
|
5390
|
+
});
|
|
5391
|
+
}
|
|
5392
|
+
async function configureRedeemInCart(ctx, input) {
|
|
5393
|
+
const args = parse(behaviorArgs, input);
|
|
5394
|
+
if (args.position_mode === "custom" && args.custom_type === "selector" && !args.css_selector?.trim()) {
|
|
5395
|
+
throw new JoyApiError("validation", "custom selector position requires a non-empty css_selector.");
|
|
5396
|
+
}
|
|
5397
|
+
const body = {};
|
|
5398
|
+
for (const [argument, field] of Object.entries(behaviorFields)) {
|
|
5399
|
+
const value = args[argument];
|
|
5400
|
+
if (value !== void 0)
|
|
5401
|
+
(body.behavior ??= {})[field] = value;
|
|
5402
|
+
}
|
|
5403
|
+
for (const [argument, field] of Object.entries({
|
|
5404
|
+
position_mode: "mode",
|
|
5405
|
+
custom_type: "customType",
|
|
5406
|
+
css_selector: "cssSelector"
|
|
5407
|
+
})) {
|
|
5408
|
+
const value = args[argument];
|
|
5409
|
+
if (value !== void 0)
|
|
5410
|
+
(body.position ??= {})[field] = value;
|
|
5411
|
+
}
|
|
5412
|
+
if (!Object.keys(body).length)
|
|
5413
|
+
throw new JoyApiError("validation", "At least one behavior or position field is required.");
|
|
5414
|
+
const { data: raw } = await ctx.api.getData(PATH);
|
|
5415
|
+
const current = raw ?? {};
|
|
5416
|
+
const position = { ...current.position, ...body.position };
|
|
5417
|
+
if (position.mode === "custom" && position.customType === "selector" && !position.cssSelector?.trim())
|
|
5418
|
+
throw new JoyApiError("validation", "custom selector position requires a non-empty css_selector.");
|
|
5419
|
+
const changes = changesFor(current, body);
|
|
5420
|
+
const preview = buildChangePreview({
|
|
5421
|
+
action: "UPDATE",
|
|
5422
|
+
target: "Redeem-in-Cart behavior and position",
|
|
5423
|
+
reason: args.reason,
|
|
5424
|
+
changes,
|
|
5425
|
+
warnings: [
|
|
5426
|
+
...warnings(current),
|
|
5427
|
+
...position.mode === "custom" && position.customType === "snippet" ? ["Custom snippet position requires the snippet in the theme."] : [],
|
|
5428
|
+
"A bad CSS selector silently falls back to the default position."
|
|
5429
|
+
]
|
|
5430
|
+
});
|
|
5431
|
+
if (!changes.length)
|
|
5432
|
+
return preview;
|
|
5433
|
+
const blocked = requireApply(args.apply, preview);
|
|
5434
|
+
if (blocked)
|
|
5435
|
+
return blocked;
|
|
5436
|
+
await ctx.api.mutateData(PATH, { method: "PUT", body });
|
|
5437
|
+
const { data } = await ctx.api.getData(PATH);
|
|
5438
|
+
confirmed(data ?? {}, body);
|
|
5439
|
+
return applied(changes, data ?? {});
|
|
5440
|
+
}
|
|
5441
|
+
async function configureRedeemInCartDesign(ctx, input) {
|
|
5442
|
+
const args = parse(designArgs, input);
|
|
5443
|
+
const { data: raw } = await ctx.api.getData(PATH);
|
|
5444
|
+
const current = raw ?? {};
|
|
5445
|
+
const style = args.style ?? current.design?.activeStyle ?? "minimal";
|
|
5446
|
+
const body = { design: {} };
|
|
5447
|
+
if (args.set_active === true)
|
|
5448
|
+
body.design.activeStyle = style;
|
|
5449
|
+
if (args.colors || args.shape) {
|
|
5450
|
+
const override = {};
|
|
5451
|
+
if (args.colors && Object.keys(args.colors).length) {
|
|
5452
|
+
override.colors = { ...args.colors };
|
|
5453
|
+
for (const key of ["background", "button"]) {
|
|
5454
|
+
const requested = override.colors[key];
|
|
5455
|
+
if (!requested)
|
|
5456
|
+
continue;
|
|
5457
|
+
const existing = current.design?.styles?.[style]?.colors?.[key] ?? current.stylesResolved?.[style]?.colors?.[key] ?? (current.activeStyleResolved?.id === style ? current.activeStyleResolved.colors?.[key] : void 0);
|
|
5458
|
+
if (existing) {
|
|
5459
|
+
if (requested.fillType === void 0 && existing.fillType)
|
|
5460
|
+
requested.fillType = existing.fillType;
|
|
5461
|
+
if (requested.colors?.length === 1 && existing.colors?.[1])
|
|
5462
|
+
requested.colors = [requested.colors[0], existing.colors[1]];
|
|
5463
|
+
if (requested.lastGradient === void 0 && existing.lastGradient)
|
|
5464
|
+
requested.lastGradient = existing.lastGradient;
|
|
5465
|
+
}
|
|
5466
|
+
}
|
|
5467
|
+
}
|
|
5468
|
+
if (args.shape && Object.keys(args.shape).length)
|
|
5469
|
+
override.shape = args.shape;
|
|
5470
|
+
if (Object.keys(override).length)
|
|
5471
|
+
body.design.styles = { [style]: override };
|
|
5472
|
+
}
|
|
5473
|
+
if (!Object.keys(body.design).length)
|
|
5474
|
+
throw new JoyApiError("validation", "Set set_active, colors, or shape to change a design.");
|
|
5475
|
+
const changes = changesFor(current, body);
|
|
5476
|
+
const previewChanges = changes.map((change) => {
|
|
5477
|
+
const prefix = `design.styles.${style}.`;
|
|
5478
|
+
if (!change.field.startsWith(prefix))
|
|
5479
|
+
return change;
|
|
5480
|
+
const resolved = current.stylesResolved?.[style] ?? (current.activeStyleResolved?.id === style ? current.activeStyleResolved : void 0);
|
|
5481
|
+
return {
|
|
5482
|
+
...change,
|
|
5483
|
+
from: valueAt(resolved, change.field.slice(prefix.length)) ?? change.from ?? "(preset default)"
|
|
5484
|
+
};
|
|
5485
|
+
});
|
|
5486
|
+
const preview = buildChangePreview({
|
|
5487
|
+
action: "UPDATE",
|
|
5488
|
+
target: `Redeem-in-Cart preset ${style}`,
|
|
5489
|
+
reason: args.reason,
|
|
5490
|
+
changes: previewChanges,
|
|
5491
|
+
warnings: [
|
|
5492
|
+
...warnings(current),
|
|
5493
|
+
"No visual preview exists; a wrong color can appear for every customer."
|
|
5494
|
+
]
|
|
5495
|
+
});
|
|
5496
|
+
if (!changes.length)
|
|
5497
|
+
return preview;
|
|
5498
|
+
const blocked = requireApply(args.apply, preview);
|
|
5499
|
+
if (blocked)
|
|
5500
|
+
return blocked;
|
|
5501
|
+
await ctx.api.mutateData(PATH, { method: "PUT", body });
|
|
5502
|
+
const { data } = await ctx.api.getData(PATH);
|
|
5503
|
+
confirmed(data ?? {}, body);
|
|
5504
|
+
return applied(previewChanges, data ?? {});
|
|
5505
|
+
}
|
|
5506
|
+
function registerRedeemInCartReadTools(server, ctx) {
|
|
5507
|
+
registerReadTool(server, ctx, {
|
|
5508
|
+
name: "joy_get_redeem_in_cart",
|
|
5509
|
+
title: "Read Redeem-in-Cart settings",
|
|
5510
|
+
description: 'Read all effective behavior flags, position, active preset, its resolved colors and shape, and stored per-preset overrides. Admin "Redeem in cart V2" uses the internal V4 engine (redeemCartV4Ready); availability requires metafield publish and the app embed. Pass style to inspect one preset, including read-only legacy earnBack.show.',
|
|
5511
|
+
inputSchema: { style: z23.enum(styles).optional() },
|
|
5512
|
+
handler: (args) => getRedeemInCart(ctx, args)
|
|
5513
|
+
});
|
|
5514
|
+
}
|
|
5515
|
+
function registerRedeemInCartWriteTools(server, ctx) {
|
|
5516
|
+
registerConfigWriteTool(server, ctx, {
|
|
5517
|
+
name: "joy_configure_redeem_in_cart",
|
|
5518
|
+
title: "Configure Redeem-in-Cart behavior and position",
|
|
5519
|
+
description: "CONFIGURATION WRITE. Preview first, then apply explicitly. Includes every behavior flag, including hidden show_earn_back and V3-only has_reload_page. A bad CSS selector silently falls back to default position. Confirms by read-back.",
|
|
5520
|
+
inputSchema: behaviorArgs.shape,
|
|
5521
|
+
destructiveHint: true,
|
|
5522
|
+
strictArguments: true,
|
|
5523
|
+
handler: (args) => configureRedeemInCart(ctx, args)
|
|
5524
|
+
});
|
|
5525
|
+
registerConfigWriteTool(server, ctx, {
|
|
5526
|
+
name: "joy_configure_redeem_in_cart_design",
|
|
5527
|
+
title: "Configure Redeem-in-Cart design",
|
|
5528
|
+
description: "CONFIGURATION WRITE. Set the active preset or update its colors and shape. Preview first, then apply explicitly. No visual preview exists; storefront updates after metafield publish. Confirms every requested field by read-back.",
|
|
5529
|
+
inputSchema: designArgs.shape,
|
|
5530
|
+
destructiveHint: true,
|
|
5531
|
+
strictArguments: true,
|
|
5532
|
+
handler: (args) => configureRedeemInCartDesign(ctx, args)
|
|
5533
|
+
});
|
|
5534
|
+
}
|
|
5535
|
+
|
|
4255
5536
|
// ../mcp-core/build/tools/themeSetup.js
|
|
4256
5537
|
function loyaltyWidget(embeds) {
|
|
4257
5538
|
return embeds.find((embed) => embed.name === "Joy Loyalty Widget" || embed.handle === "app-embed");
|
|
@@ -4279,6 +5560,8 @@ function formatThemeSetup(themeSetup) {
|
|
|
4279
5560
|
});
|
|
4280
5561
|
}
|
|
4281
5562
|
for (const block of blocks) {
|
|
5563
|
+
if (block.added === true)
|
|
5564
|
+
continue;
|
|
4282
5565
|
actions.push({
|
|
4283
5566
|
item: block.name,
|
|
4284
5567
|
instruction: "The merchant can add this missing block from the theme editor and save.",
|
|
@@ -4296,7 +5579,7 @@ function formatThemeSetup(themeSetup) {
|
|
|
4296
5579
|
return summaryWithJson(`Loyalty widget is ${widgetStatus}.${passwordWarning} This read-only tool cannot switch anything on; only the merchant can use the server-provided links and save the change.`, { next_steps: actions });
|
|
4297
5580
|
}
|
|
4298
5581
|
function registerThemeSetupTools(server, ctx) {
|
|
4299
|
-
registerReadTool(server, {
|
|
5582
|
+
registerReadTool(server, ctx, {
|
|
4300
5583
|
name: "joy_check_theme_setup",
|
|
4301
5584
|
title: "Check storefront theme setup",
|
|
4302
5585
|
description: "Read-only diagnosis for the Joy loyalty widget, related theme embeds, and missing blocks. It cannot switch anything on or add anything: only the merchant can open the server-provided Shopify editor link, make the change, and save. Use it to answer why the widget is not showing and to get the exact setup links; never claim this tool enabled anything.",
|
|
@@ -4309,26 +5592,26 @@ function registerThemeSetupTools(server, ctx) {
|
|
|
4309
5592
|
}
|
|
4310
5593
|
|
|
4311
5594
|
// ../mcp-core/build/tools/imageUpload.js
|
|
4312
|
-
import { z as
|
|
5595
|
+
import { z as z24 } from "zod";
|
|
4313
5596
|
var imageUploadInputSchema = {
|
|
4314
|
-
source_url:
|
|
4315
|
-
data_url:
|
|
4316
|
-
filename:
|
|
4317
|
-
alt:
|
|
4318
|
-
reason:
|
|
4319
|
-
apply:
|
|
5597
|
+
source_url: z24.string().url().optional().describe("Public URL of an already hosted image. Provide exactly one of source_url or data_url."),
|
|
5598
|
+
data_url: z24.string().optional().describe("Image bytes as a data URL, for example an image the agent generated. Provide exactly one of data_url or source_url."),
|
|
5599
|
+
filename: z24.string().min(1).max(200).optional().describe("Filename to use for the uploaded image."),
|
|
5600
|
+
alt: z24.string().max(512).optional().describe("Merchant-provided alternative text for the image."),
|
|
5601
|
+
reason: z24.string().min(3).describe("Why the merchant wants this image uploaded."),
|
|
5602
|
+
apply: z24.boolean().optional().describe("Omit or set false for a dry run. Set true only after the merchant approves the preview.")
|
|
4320
5603
|
};
|
|
4321
5604
|
function imageUploadErrorGuidance(error) {
|
|
4322
5605
|
if (!(error instanceof JoyApiError))
|
|
4323
5606
|
return void 0;
|
|
4324
5607
|
if (error.status === 400) {
|
|
4325
|
-
return new JoyApiError("validation", "The image source was rejected (not an image, unsupported type, or a blocked URL). Do not retry the same input.", error.status);
|
|
5608
|
+
return new JoyApiError("validation", "The image source was rejected (not an image, unsupported type, or a blocked URL). Do not retry the same input.", error.status, error.serverCode);
|
|
4326
5609
|
}
|
|
4327
5610
|
if (error.status === 413) {
|
|
4328
|
-
return new JoyApiError("validation", "The image is too large; the upload cap is 20 MB. Resize or compress it before trying again.", error.status);
|
|
5611
|
+
return new JoyApiError("validation", "The image is too large; the upload cap is 20 MB. Resize or compress it before trying again.", error.status, error.serverCode);
|
|
4329
5612
|
}
|
|
4330
5613
|
if (error.status === 502) {
|
|
4331
|
-
return new JoyApiError("server", "Shopify rejected the upload or the file never went live. Safe to retry ONCE; if it fails again, stop and report the failure to the merchant.", error.status);
|
|
5614
|
+
return new JoyApiError("server", "Shopify rejected the upload or the file never went live. Safe to retry ONCE; if it fails again, stop and report the failure to the merchant.", error.status, error.serverCode);
|
|
4332
5615
|
}
|
|
4333
5616
|
return void 0;
|
|
4334
5617
|
}
|
|
@@ -4336,7 +5619,7 @@ function registerImageUploadTools(server, ctx) {
|
|
|
4336
5619
|
registerConfigWriteTool(server, ctx, {
|
|
4337
5620
|
name: "joy_upload_image",
|
|
4338
5621
|
title: "Upload image to Shopify Files",
|
|
4339
|
-
description: "CONFIG WRITE \u2014 uploads an image to the merchant's Shopify Files CDN and returns a permanent URL. Two-step flow: first call joy_upload_image, then pass its returned url to joy_set_widget_fields for any image field (sections.<ID>.bannerImage, membershipCard.*.bgImage, header.bannerImage, program.logo). Use data_url for image bytes the agent generated, or source_url for an image already hosted somewhere public; provide exactly one. The upload cap is 20 MB; the server validates the image type and source. This tool cannot see the image: do not describe, judge, or vouch for what was uploaded. The merchant reviews it in Joy admin. This writes into the merchant's store and is not undone by discarding a widget draft: the file stays in Shopify Files until the merchant deletes it. Dry run by default; set apply: true only after the merchant approves the preview.",
|
|
5622
|
+
description: "CONFIG WRITE \u2014 uploads an image to the merchant's Shopify Files CDN and returns a permanent URL. Two-step flow: first call joy_upload_image, then pass its returned url to joy_set_widget_fields for any image field (sections.<ID>.bannerImage, membershipCard.*.bgImage, header.bannerImage, program.logo), or to joy_update_loyalty_page_design for `hero.backgroundImage`, `hero.mobileBackgroundImage`, `inlineBanner.backgroundImage`, `inlineBanner.mobileBackgroundImage`, `earn.bannerImage`, `referral.image`, or `icons`; that loyalty-page tool also accepts separate video URLs at `hero.backgroundVideo` and `hero.mobileBackgroundVideo`. Use data_url for image bytes the agent generated, or source_url for an image already hosted somewhere public; provide exactly one. The upload cap is 20 MB; the server validates the image type and source. This tool cannot see the image: do not describe, judge, or vouch for what was uploaded. The merchant reviews it in Joy admin. This writes into the merchant's store and is not undone by discarding a widget draft: the file stays in Shopify Files until the merchant deletes it. Dry run by default; set apply: true only after the merchant approves the preview.",
|
|
4340
5623
|
inputSchema: imageUploadInputSchema,
|
|
4341
5624
|
strictArguments: true,
|
|
4342
5625
|
destructiveHint: false,
|
|
@@ -4361,14 +5644,14 @@ function registerImageUploadTools(server, ctx) {
|
|
|
4361
5644
|
}
|
|
4362
5645
|
|
|
4363
5646
|
// ../mcp-core/build/prompts.js
|
|
4364
|
-
import { z as
|
|
5647
|
+
import { z as z25 } from "zod";
|
|
4365
5648
|
function registerAllPrompts(server) {
|
|
4366
5649
|
server.registerPrompt("support_missing_points", {
|
|
4367
5650
|
title: "Investigate missing points for an order",
|
|
4368
5651
|
description: "Support workflow: verify whether a customer really is missing points for an order, explain why, and prepare a compensation proposal with a proper audit reason.",
|
|
4369
5652
|
argsSchema: {
|
|
4370
|
-
email:
|
|
4371
|
-
order_number:
|
|
5653
|
+
email: z25.string().describe("Customer's email address"),
|
|
5654
|
+
order_number: z25.string().optional().describe("Order name/number the customer says earned nothing, e.g. #1234")
|
|
4372
5655
|
}
|
|
4373
5656
|
}, ({ email, order_number }) => ({
|
|
4374
5657
|
messages: [
|
|
@@ -4395,7 +5678,7 @@ function registerAllPrompts(server) {
|
|
|
4395
5678
|
title: "Loyalty program review (health + revenue + tiers + referral)",
|
|
4396
5679
|
description: "Periodic business review: program health check, revenue impact, tier performance, and referral results \u2014 with data-source rules so numbers from different systems are never mixed.",
|
|
4397
5680
|
argsSchema: {
|
|
4398
|
-
period:
|
|
5681
|
+
period: z25.string().optional().describe("Reporting period, e.g. 'last 30 days', 'last month', 'this quarter'. Defaults to last 30 days.")
|
|
4399
5682
|
}
|
|
4400
5683
|
}, ({ period }) => ({
|
|
4401
5684
|
messages: [
|
|
@@ -4426,7 +5709,7 @@ function registerAllPrompts(server) {
|
|
|
4426
5709
|
title: "Investigate a member for abuse risk",
|
|
4427
5710
|
description: "Fraud/abuse triage for one member: risk flags, point activity, and referral history \u2014 with the mandatory caveat about signal limitations and no punitive action.",
|
|
4428
5711
|
argsSchema: {
|
|
4429
|
-
email:
|
|
5712
|
+
email: z25.string().describe("Email of the member to investigate")
|
|
4430
5713
|
}
|
|
4431
5714
|
}, ({ email }) => ({
|
|
4432
5715
|
messages: [
|
|
@@ -4453,8 +5736,8 @@ function registerAllPrompts(server) {
|
|
|
4453
5736
|
title: "Set up a loyalty program safely",
|
|
4454
5737
|
description: "Guided loyalty-program setup: inspect the current configuration, propose a minimal program, and make each approved configuration change one at a time.",
|
|
4455
5738
|
argsSchema: {
|
|
4456
|
-
business_type:
|
|
4457
|
-
goal:
|
|
5739
|
+
business_type: z25.string().optional().describe("Business type, e.g. 'fashion' or 'coffee shop'."),
|
|
5740
|
+
goal: z25.string().optional().describe("Primary loyalty goal, e.g. 'increase repeat purchases'.")
|
|
4458
5741
|
}
|
|
4459
5742
|
}, ({ business_type, goal }) => ({
|
|
4460
5743
|
messages: [
|
|
@@ -4479,7 +5762,7 @@ function registerAllPrompts(server) {
|
|
|
4479
5762
|
title: "Apply storefront branding to a widget draft",
|
|
4480
5763
|
description: "Guided widget-branding workflow: scan the storefront, explain the detected design, get approval, and save the approved changes to the V4 draft for the merchant to review and publish manually.",
|
|
4481
5764
|
argsSchema: {
|
|
4482
|
-
preferences:
|
|
5765
|
+
preferences: z25.string().optional().describe("Optional brand preferences or constraints, e.g. 'keep the current heading font' or 'use a softer button color'.")
|
|
4483
5766
|
}
|
|
4484
5767
|
}, ({ preferences }) => ({
|
|
4485
5768
|
messages: [
|
|
@@ -4506,7 +5789,7 @@ function registerAllPrompts(server) {
|
|
|
4506
5789
|
title: "Set up the storefront loyalty widget",
|
|
4507
5790
|
description: "Guided theme-setup workflow: audit the live theme, explain each missing Joy embed or block, and hand the merchant server-provided deep links to finish setup manually.",
|
|
4508
5791
|
argsSchema: {
|
|
4509
|
-
focus:
|
|
5792
|
+
focus: z25.string().optional().describe("Optional setup focus, e.g. 'loyalty widget', 'points calculator', or 'loyalty page'.")
|
|
4510
5793
|
}
|
|
4511
5794
|
}, ({ focus }) => ({
|
|
4512
5795
|
messages: [
|
|
@@ -4544,16 +5827,25 @@ function registerAllTools(server, ctx) {
|
|
|
4544
5827
|
registerOrderExplainTools(server, ctx);
|
|
4545
5828
|
registerActivityAuditTools(server, ctx);
|
|
4546
5829
|
registerTierUpgradeTools(server, ctx);
|
|
5830
|
+
registerLoyaltyPageTools(server, ctx);
|
|
5831
|
+
registerLoyaltyPageTextTools(server, ctx);
|
|
5832
|
+
registerLoyaltyHubTools(server, ctx);
|
|
5833
|
+
registerLoyaltyHubTextTools(server, ctx);
|
|
4547
5834
|
registerThemeSetupTools(server, ctx);
|
|
4548
5835
|
registerBrandingReadTools(server, ctx);
|
|
4549
5836
|
registerWidgetFieldReadTools(server, ctx);
|
|
5837
|
+
registerRedeemInCartReadTools(server, ctx);
|
|
4550
5838
|
if (ctx.config.writesEnabled) {
|
|
4551
5839
|
registerPointWriteTools(server, ctx);
|
|
4552
5840
|
registerTierWriteTools(server, ctx);
|
|
4553
5841
|
registerRewardWriteTools(server, ctx);
|
|
4554
5842
|
registerReferralWriteTools(server, ctx);
|
|
4555
5843
|
registerAnalyticsWriteTools(server, ctx);
|
|
4556
|
-
|
|
5844
|
+
registerLoyaltyPageWriteTools(server, ctx);
|
|
5845
|
+
registerLoyaltyPageTextWriteTools(server, ctx);
|
|
5846
|
+
registerLoyaltyHubWriteTools(server, ctx);
|
|
5847
|
+
registerLoyaltyHubTextWriteTools(server, ctx);
|
|
5848
|
+
log.info("write tools ENABLED (award/deduct/adjust/redeem/set-tier/refund-coupon/create-referral-invite/enable-analytics-v2/loyalty-hub-design/loyalty-hub-text). Requires the deployed REST v2 write-path (reason/source/Idempotency-Key support) \u2014 against an older backend, reason/source are silently dropped and idempotency is not enforced.");
|
|
4557
5849
|
}
|
|
4558
5850
|
if (ctx.config.configWritesEnabled) {
|
|
4559
5851
|
registerProgramConfigTools(server, ctx);
|
|
@@ -4563,24 +5855,492 @@ function registerAllTools(server, ctx) {
|
|
|
4563
5855
|
registerBrandingWriteTools(server, ctx);
|
|
4564
5856
|
registerWidgetFieldWriteTools(server, ctx);
|
|
4565
5857
|
registerReferralBrandingWriteTools(server, ctx);
|
|
5858
|
+
registerRedeemInCartWriteTools(server, ctx);
|
|
4566
5859
|
registerImageUploadTools(server, ctx);
|
|
4567
5860
|
log.info("CONFIG write tools ENABLED (configure earning/redemption/referral program, tier, tier reward, tier settings, shop settings, referral widget, add translation language). These change behaviour for every customer at once and every one of them previews by default \u2014 an apply requires a second call. No backend audit trail records who changed configuration or why, and the endpoints accept no Idempotency-Key, so a repeated create makes a second live record.");
|
|
4568
5861
|
}
|
|
4569
5862
|
}
|
|
4570
5863
|
|
|
5864
|
+
// ../mcp-core/build/telemetry/knownTools.js
|
|
5865
|
+
var KNOWN_TOOLS = Object.freeze({
|
|
5866
|
+
joy_add_translation_language: "config_write",
|
|
5867
|
+
joy_adjust_points: "write",
|
|
5868
|
+
joy_award_points: "write",
|
|
5869
|
+
joy_check_theme_setup: "read",
|
|
5870
|
+
joy_configure_earning_program: "config_write",
|
|
5871
|
+
joy_configure_redeem_in_cart: "config_write",
|
|
5872
|
+
joy_configure_redeem_in_cart_design: "config_write",
|
|
5873
|
+
joy_configure_redemption_program: "config_write",
|
|
5874
|
+
joy_configure_referral_program: "config_write",
|
|
5875
|
+
joy_configure_referral_widget: "config_write",
|
|
5876
|
+
joy_configure_shop_settings: "config_write",
|
|
5877
|
+
joy_configure_tier: "config_write",
|
|
5878
|
+
joy_configure_tier_perks: "config_write",
|
|
5879
|
+
joy_configure_tier_reward: "config_write",
|
|
5880
|
+
joy_configure_tier_settings: "config_write",
|
|
5881
|
+
joy_configure_widget_draft: "config_write",
|
|
5882
|
+
joy_create_referral_invite: "write",
|
|
5883
|
+
joy_deduct_points: "write",
|
|
5884
|
+
joy_enable_analytics_v2: "write",
|
|
5885
|
+
joy_explain_order_points: "read",
|
|
5886
|
+
joy_find_member: "read",
|
|
5887
|
+
joy_get_analytics_report: "read",
|
|
5888
|
+
joy_get_loyalty_health_check: "read",
|
|
5889
|
+
joy_get_loyalty_hub_design: "read",
|
|
5890
|
+
joy_get_loyalty_hub_text: "read",
|
|
5891
|
+
joy_get_loyalty_overview: "read",
|
|
5892
|
+
joy_get_loyalty_page_design: "read",
|
|
5893
|
+
joy_get_loyalty_page_text: "read",
|
|
5894
|
+
joy_get_loyalty_revenue_report: "read",
|
|
5895
|
+
joy_get_member_activity: "read",
|
|
5896
|
+
joy_get_member_referrals: "read",
|
|
5897
|
+
joy_get_member_rewards: "read",
|
|
5898
|
+
joy_get_member_store_credit: "read",
|
|
5899
|
+
joy_get_redeem_in_cart: "read",
|
|
5900
|
+
joy_get_referral_performance: "read",
|
|
5901
|
+
joy_get_referral_program: "read",
|
|
5902
|
+
joy_get_retention_risk_members: "read",
|
|
5903
|
+
joy_get_tier_performance_breakdown: "read",
|
|
5904
|
+
joy_get_tier_upgrade_candidates: "read",
|
|
5905
|
+
joy_get_widget_fields: "read",
|
|
5906
|
+
joy_investigate_member_risk: "read",
|
|
5907
|
+
joy_list_activities: "read",
|
|
5908
|
+
joy_list_earning_rules: "read",
|
|
5909
|
+
joy_list_members: "read",
|
|
5910
|
+
joy_list_redemption_rules: "read",
|
|
5911
|
+
joy_list_tiers: "read",
|
|
5912
|
+
joy_redeem_reward: "write",
|
|
5913
|
+
joy_refund_coupon: "write",
|
|
5914
|
+
joy_revert_loyalty_hub_design: "write",
|
|
5915
|
+
joy_revert_loyalty_hub_text: "write",
|
|
5916
|
+
joy_revert_loyalty_page_design: "write",
|
|
5917
|
+
joy_scan_brand_design: "read",
|
|
5918
|
+
joy_set_member_tier: "write",
|
|
5919
|
+
joy_set_widget_fields: "config_write",
|
|
5920
|
+
joy_update_loyalty_hub_design: "write",
|
|
5921
|
+
joy_update_loyalty_hub_text: "write",
|
|
5922
|
+
joy_update_loyalty_page_design: "write",
|
|
5923
|
+
joy_update_loyalty_page_text: "write",
|
|
5924
|
+
joy_upload_image: "config_write"
|
|
5925
|
+
});
|
|
5926
|
+
|
|
5927
|
+
// ../mcp-core/build/telemetry/recorder.js
|
|
5928
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
5929
|
+
var fields = TELEMETRY_SCHEMA.events["mcp.tool_call"].fields;
|
|
5930
|
+
var toMillis = (value) => Math.min(36e5, Math.max(0, Math.round(value)));
|
|
5931
|
+
function classify(record, response, { exposed, known = KNOWN_TOOLS }) {
|
|
5932
|
+
let outcome = record.outcome ?? "internal_error";
|
|
5933
|
+
let error_kind = record.error_kind;
|
|
5934
|
+
if ("error" in response) {
|
|
5935
|
+
outcome = "internal_error";
|
|
5936
|
+
error_kind = "jsonrpc_error";
|
|
5937
|
+
} else if (!record.entered && "result" in response && response.result.isError) {
|
|
5938
|
+
outcome = "rejected";
|
|
5939
|
+
error_kind = exposed.has(record.tool) ? "invalid_input" : Object.hasOwn(known, record.tool) ? "tool_not_exposed" : "unknown_tool";
|
|
5940
|
+
} else if (!record.outcome) {
|
|
5941
|
+
error_kind = "unclassified";
|
|
5942
|
+
}
|
|
5943
|
+
const result = "result" in response ? response.result : void 0;
|
|
5944
|
+
const content = Array.isArray(result?.content) ? result.content : [];
|
|
5945
|
+
const result_bytes = content.reduce((sum, item) => sum + (typeof item?.text === "string" ? Buffer.byteLength(item.text, "utf8") : 0), 0);
|
|
5946
|
+
return {
|
|
5947
|
+
tool_kind: Object.hasOwn(known, record.tool) ? known[record.tool] : "unknown",
|
|
5948
|
+
outcome,
|
|
5949
|
+
...outcome !== "success" && error_kind ? { error_kind } : {},
|
|
5950
|
+
...outcome === "tool_error" && record.error_code ? { error_code: record.error_code } : {},
|
|
5951
|
+
...outcome === "tool_error" && record.http_status ? { http_status: record.http_status } : {},
|
|
5952
|
+
result_bytes: Math.min(1e9, result_bytes)
|
|
5953
|
+
};
|
|
5954
|
+
}
|
|
5955
|
+
function createRecorder({ sink, base: inputBase, receivedAt, sweepTtlMs, now = performance.now.bind(performance) }) {
|
|
5956
|
+
const base = {};
|
|
5957
|
+
telemetrySafe("base", () => {
|
|
5958
|
+
for (const [key, value] of Object.entries(inputBase)) {
|
|
5959
|
+
const clean = sanitizeString(TELEMETRY_SCHEMA, "mcp.session_start", key, value);
|
|
5960
|
+
if (clean !== void 0)
|
|
5961
|
+
base[key] = clean;
|
|
5962
|
+
}
|
|
5963
|
+
});
|
|
5964
|
+
const pending = /* @__PURE__ */ new Map();
|
|
5965
|
+
const exposed = /* @__PURE__ */ new Set();
|
|
5966
|
+
const instrumented = /* @__PURE__ */ new WeakSet();
|
|
5967
|
+
let closed = false;
|
|
5968
|
+
let initialized = false;
|
|
5969
|
+
function emit(event) {
|
|
5970
|
+
const transport = inputBase.transport;
|
|
5971
|
+
const checked = validateEvent(TELEMETRY_SCHEMA, event, { mode: transport === "stdio" ? "client" : "full", transport });
|
|
5972
|
+
if (!checked.ok) {
|
|
5973
|
+
reportOnce(`invalid ${checked.errors[0]}`);
|
|
5974
|
+
return;
|
|
5975
|
+
}
|
|
5976
|
+
sink.emit(checked.event);
|
|
5977
|
+
}
|
|
5978
|
+
function finish(id, record, response) {
|
|
5979
|
+
if (record.closed || pending.get(id) !== record)
|
|
5980
|
+
return;
|
|
5981
|
+
record.closed = true;
|
|
5982
|
+
pending.delete(id);
|
|
5983
|
+
const elapsed = now();
|
|
5984
|
+
const duration_ms = toMillis(elapsed - record.t0);
|
|
5985
|
+
const busy = record.api_inflight ? elapsed - (record.api_busy_since ?? elapsed) : 0;
|
|
5986
|
+
emit({
|
|
5987
|
+
...base,
|
|
5988
|
+
event: "mcp.tool_call",
|
|
5989
|
+
schema_version: 1,
|
|
5990
|
+
event_id: randomUUID2(),
|
|
5991
|
+
call_id: record.call_id,
|
|
5992
|
+
tool: record.tool,
|
|
5993
|
+
...classify(record, response, { exposed }),
|
|
5994
|
+
duration_ms,
|
|
5995
|
+
...base.transport === "remote" && receivedAt !== void 0 ? { pre_ms: toMillis(record.t0 - receivedAt) } : {},
|
|
5996
|
+
api_calls: Math.min(1e4, record.api_calls),
|
|
5997
|
+
api_ms: Math.min(duration_ms, toMillis(record.api_ms_acc + busy))
|
|
5998
|
+
});
|
|
5999
|
+
}
|
|
6000
|
+
function cancel(id, record, outcome, kind) {
|
|
6001
|
+
record.outcome = outcome;
|
|
6002
|
+
record.error_kind = kind;
|
|
6003
|
+
finish(id, record, { jsonrpc: "2.0", id, result: {} });
|
|
6004
|
+
}
|
|
6005
|
+
function buildHeaders(call_id, tool) {
|
|
6006
|
+
const values = { call_id, tool, ...base };
|
|
6007
|
+
const mapping = {
|
|
6008
|
+
call_id: "Call-Id",
|
|
6009
|
+
tool: "Tool",
|
|
6010
|
+
transport: "Transport",
|
|
6011
|
+
server_version: "Version",
|
|
6012
|
+
...base.transport === "stdio" ? { mcp_client_name: "Client", session_id: "Session" } : {}
|
|
6013
|
+
};
|
|
6014
|
+
const result = {};
|
|
6015
|
+
for (const [field, suffix] of Object.entries(mapping)) {
|
|
6016
|
+
const value = sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", field, values[field]);
|
|
6017
|
+
if (value !== void 0)
|
|
6018
|
+
result[`X-Joy-Mcp-${suffix}`] = value;
|
|
6019
|
+
}
|
|
6020
|
+
return Object.freeze(result);
|
|
6021
|
+
}
|
|
6022
|
+
const timer = sweepTtlMs === void 0 ? void 0 : setInterval(() => {
|
|
6023
|
+
for (const [id, record] of pending) {
|
|
6024
|
+
telemetrySafe("sweep", () => {
|
|
6025
|
+
if (now() - record.t0 >= sweepTtlMs)
|
|
6026
|
+
cancel(id, record, "cancelled", "no_response");
|
|
6027
|
+
});
|
|
6028
|
+
}
|
|
6029
|
+
}, 6e4);
|
|
6030
|
+
timer?.unref();
|
|
6031
|
+
return {
|
|
6032
|
+
expose(name) {
|
|
6033
|
+
telemetrySafe("expose", () => {
|
|
6034
|
+
if (sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", "tool", name))
|
|
6035
|
+
exposed.add(name);
|
|
6036
|
+
});
|
|
6037
|
+
},
|
|
6038
|
+
instrumentTransport(transport) {
|
|
6039
|
+
telemetrySafe("instrument", () => {
|
|
6040
|
+
if (instrumented.has(transport))
|
|
6041
|
+
return;
|
|
6042
|
+
instrumented.add(transport);
|
|
6043
|
+
if (!transport.onmessage)
|
|
6044
|
+
reportOnce("instrumentTransport called before connect");
|
|
6045
|
+
const onmessage = transport.onmessage;
|
|
6046
|
+
const send = transport.send;
|
|
6047
|
+
transport.onmessage = function(message, extra) {
|
|
6048
|
+
const record = telemetrySafe("receive", () => {
|
|
6049
|
+
if (closed || !("method" in message))
|
|
6050
|
+
return;
|
|
6051
|
+
const params = message.params;
|
|
6052
|
+
if (message.method === "initialize" && !(base.transport === "stdio" && initialized)) {
|
|
6053
|
+
initialized = true;
|
|
6054
|
+
const info = params?.clientInfo;
|
|
6055
|
+
const session = {
|
|
6056
|
+
...base,
|
|
6057
|
+
event: "mcp.session_start",
|
|
6058
|
+
schema_version: 1,
|
|
6059
|
+
event_id: randomUUID2(),
|
|
6060
|
+
tools_exposed: exposed.size
|
|
6061
|
+
};
|
|
6062
|
+
const capabilities = Object.keys(params?.capabilities ?? {}).filter((key) => /^[a-z][a-zA-Z0-9_]{0,31}$/.test(key)).sort().slice(0, 10).join(",");
|
|
6063
|
+
for (const [key, value] of Object.entries({
|
|
6064
|
+
mcp_client_name: info?.name,
|
|
6065
|
+
mcp_client_version: info?.version,
|
|
6066
|
+
protocol_version: params?.protocolVersion,
|
|
6067
|
+
client_capabilities: capabilities
|
|
6068
|
+
})) {
|
|
6069
|
+
const clean = sanitizeString(TELEMETRY_SCHEMA, "mcp.session_start", key, value);
|
|
6070
|
+
if (clean === void 0)
|
|
6071
|
+
continue;
|
|
6072
|
+
session[key] = clean;
|
|
6073
|
+
if (base.transport === "stdio" && fields[key])
|
|
6074
|
+
base[key] = clean;
|
|
6075
|
+
}
|
|
6076
|
+
emit(session);
|
|
6077
|
+
}
|
|
6078
|
+
if (message.method === "notifications/cancelled" && base.transport === "stdio") {
|
|
6079
|
+
const id = params?.requestId;
|
|
6080
|
+
const previous2 = pending.get(id);
|
|
6081
|
+
if (previous2)
|
|
6082
|
+
cancel(id, previous2, "cancelled", "client_cancelled");
|
|
6083
|
+
}
|
|
6084
|
+
if (message.method !== "tools/call" || !("id" in message))
|
|
6085
|
+
return;
|
|
6086
|
+
const previous = pending.get(message.id);
|
|
6087
|
+
if (previous)
|
|
6088
|
+
telemetrySafe("duplicate", () => cancel(message.id, previous, "internal_error", "duplicate_request_id"));
|
|
6089
|
+
const tool = sanitizeString(TELEMETRY_SCHEMA, "mcp.tool_call", "tool", params?.name) ?? "invalid_name";
|
|
6090
|
+
const call_id = randomUUID2();
|
|
6091
|
+
const record2 = {
|
|
6092
|
+
call_id,
|
|
6093
|
+
tool,
|
|
6094
|
+
t0: now(),
|
|
6095
|
+
entered: false,
|
|
6096
|
+
closed: false,
|
|
6097
|
+
api_calls: 0,
|
|
6098
|
+
api_inflight: 0,
|
|
6099
|
+
api_ms_acc: 0,
|
|
6100
|
+
headers: buildHeaders(call_id, tool),
|
|
6101
|
+
now
|
|
6102
|
+
};
|
|
6103
|
+
pending.set(message.id, record2);
|
|
6104
|
+
return record2;
|
|
6105
|
+
});
|
|
6106
|
+
if (record)
|
|
6107
|
+
return als.run(record, () => onmessage?.call(this, message, extra));
|
|
6108
|
+
return onmessage?.call(this, message, extra);
|
|
6109
|
+
};
|
|
6110
|
+
transport.send = function(...args) {
|
|
6111
|
+
telemetrySafe("send", () => {
|
|
6112
|
+
const message = args[0];
|
|
6113
|
+
if (!("id" in message) || message.id === void 0 || !("result" in message || "error" in message))
|
|
6114
|
+
return;
|
|
6115
|
+
const record = currentCall() ?? pending.get(message.id);
|
|
6116
|
+
if (record)
|
|
6117
|
+
finish(message.id, record, message);
|
|
6118
|
+
});
|
|
6119
|
+
return send.apply(this, args);
|
|
6120
|
+
};
|
|
6121
|
+
});
|
|
6122
|
+
},
|
|
6123
|
+
close() {
|
|
6124
|
+
closed = true;
|
|
6125
|
+
if (timer)
|
|
6126
|
+
clearInterval(timer);
|
|
6127
|
+
for (const [id, record] of pending) {
|
|
6128
|
+
telemetrySafe("close", () => cancel(id, record, "cancelled", "no_response"));
|
|
6129
|
+
}
|
|
6130
|
+
}
|
|
6131
|
+
};
|
|
6132
|
+
}
|
|
6133
|
+
|
|
6134
|
+
// ../mcp-core/build/telemetry/sinks.js
|
|
6135
|
+
var noopSink = { emit() {
|
|
6136
|
+
} };
|
|
6137
|
+
function createRelaySink({ apiBaseUrl, authHeaders: authHeaders2, fetchImpl = fetch, now = Date.now, timers = globalThis }) {
|
|
6138
|
+
const queue = [];
|
|
6139
|
+
let disabled = false;
|
|
6140
|
+
let dueAt = 0;
|
|
6141
|
+
let timer;
|
|
6142
|
+
let active;
|
|
6143
|
+
let flushing;
|
|
6144
|
+
const headers = { "Content-Type": "application/json" };
|
|
6145
|
+
telemetrySafe("relay_headers", () => {
|
|
6146
|
+
for (const [key, value] of Object.entries(authHeaders2)) {
|
|
6147
|
+
if (["x-joy-loyalty-app-key", "x-joy-loyalty-secret-key", "x-joy-private-bot-key"].includes(key.toLowerCase()))
|
|
6148
|
+
headers[key] = value;
|
|
6149
|
+
}
|
|
6150
|
+
});
|
|
6151
|
+
function clearTimer() {
|
|
6152
|
+
if (timer)
|
|
6153
|
+
timers.clearTimeout(timer);
|
|
6154
|
+
timer = void 0;
|
|
6155
|
+
}
|
|
6156
|
+
function schedule() {
|
|
6157
|
+
if (disabled || flushing || queue.length === 0)
|
|
6158
|
+
return;
|
|
6159
|
+
if (!active && queue.length >= 20) {
|
|
6160
|
+
startBatch();
|
|
6161
|
+
return;
|
|
6162
|
+
}
|
|
6163
|
+
if (timer)
|
|
6164
|
+
return;
|
|
6165
|
+
timer = timers.setTimeout(() => {
|
|
6166
|
+
timer = void 0;
|
|
6167
|
+
telemetrySafe("relay_timer", () => {
|
|
6168
|
+
if (!active)
|
|
6169
|
+
startBatch();
|
|
6170
|
+
});
|
|
6171
|
+
}, Math.max(0, dueAt - now()));
|
|
6172
|
+
timer.unref();
|
|
6173
|
+
}
|
|
6174
|
+
function startBatch() {
|
|
6175
|
+
if (active)
|
|
6176
|
+
return active.done;
|
|
6177
|
+
if (disabled || queue.length === 0)
|
|
6178
|
+
return Promise.resolve();
|
|
6179
|
+
clearTimer();
|
|
6180
|
+
const events = queue.splice(0, 50);
|
|
6181
|
+
const controller = new AbortController();
|
|
6182
|
+
let release;
|
|
6183
|
+
const cancelled = new Promise((resolve) => {
|
|
6184
|
+
release = resolve;
|
|
6185
|
+
});
|
|
6186
|
+
const abort = () => {
|
|
6187
|
+
controller.abort();
|
|
6188
|
+
release();
|
|
6189
|
+
};
|
|
6190
|
+
const timeout = timers.setTimeout(abort, 15e3);
|
|
6191
|
+
timeout.unref();
|
|
6192
|
+
const request = Promise.resolve().then(async () => {
|
|
6193
|
+
if (controller.signal.aborted)
|
|
6194
|
+
return;
|
|
6195
|
+
const response = await fetchImpl(`${apiBaseUrl.replace(/\/$/, "")}/rest_api/v2/internal/mcp-telemetry`, {
|
|
6196
|
+
method: "POST",
|
|
6197
|
+
redirect: "error",
|
|
6198
|
+
headers,
|
|
6199
|
+
body: JSON.stringify({ schema_version: 1, events }),
|
|
6200
|
+
signal: controller.signal
|
|
6201
|
+
});
|
|
6202
|
+
let dailyQuotaExceeded = false;
|
|
6203
|
+
if (response.status === 429) {
|
|
6204
|
+
const body = await response.json().catch(() => void 0);
|
|
6205
|
+
dailyQuotaExceeded = body !== null && typeof body === "object" && "error" in body && body.error === "daily_quota_exceeded";
|
|
6206
|
+
}
|
|
6207
|
+
if ([401, 403, 404, 410].includes(response.status) || dailyQuotaExceeded) {
|
|
6208
|
+
disabled = true;
|
|
6209
|
+
queue.length = 0;
|
|
6210
|
+
clearTimer();
|
|
6211
|
+
}
|
|
6212
|
+
if (!response.ok)
|
|
6213
|
+
throw new Error("relay rejected");
|
|
6214
|
+
await response.body?.cancel();
|
|
6215
|
+
}).catch(() => {
|
|
6216
|
+
telemetrySafe("relay_request", () => {
|
|
6217
|
+
throw new Error("relay request failed");
|
|
6218
|
+
});
|
|
6219
|
+
});
|
|
6220
|
+
const done = Promise.race([request, cancelled]).then(() => {
|
|
6221
|
+
timers.clearTimeout(timeout);
|
|
6222
|
+
active = void 0;
|
|
6223
|
+
telemetrySafe("relay_schedule", schedule);
|
|
6224
|
+
});
|
|
6225
|
+
active = { done, abort };
|
|
6226
|
+
return done;
|
|
6227
|
+
}
|
|
6228
|
+
async function flush(deadlineMs) {
|
|
6229
|
+
clearTimer();
|
|
6230
|
+
const deadline = now() + Math.max(0, deadlineMs);
|
|
6231
|
+
let expired = false;
|
|
6232
|
+
let release;
|
|
6233
|
+
const timeout = new Promise((resolve) => {
|
|
6234
|
+
release = resolve;
|
|
6235
|
+
});
|
|
6236
|
+
const end = timers.setTimeout(() => {
|
|
6237
|
+
expired = true;
|
|
6238
|
+
queue.length = 0;
|
|
6239
|
+
active?.abort();
|
|
6240
|
+
release();
|
|
6241
|
+
}, Math.max(0, deadlineMs));
|
|
6242
|
+
try {
|
|
6243
|
+
while (!expired && !disabled && (active || queue.length)) {
|
|
6244
|
+
if (now() >= deadline) {
|
|
6245
|
+
queue.length = 0;
|
|
6246
|
+
active?.abort();
|
|
6247
|
+
break;
|
|
6248
|
+
}
|
|
6249
|
+
await Promise.race([active?.done ?? startBatch(), timeout]);
|
|
6250
|
+
}
|
|
6251
|
+
} finally {
|
|
6252
|
+
timers.clearTimeout(end);
|
|
6253
|
+
}
|
|
6254
|
+
}
|
|
6255
|
+
return {
|
|
6256
|
+
get disabled() {
|
|
6257
|
+
return disabled;
|
|
6258
|
+
},
|
|
6259
|
+
emit(event) {
|
|
6260
|
+
telemetrySafe("relay_emit", () => {
|
|
6261
|
+
if (disabled)
|
|
6262
|
+
return;
|
|
6263
|
+
if (!TELEMETRY_SCHEMA.ingest_events.includes(event.event))
|
|
6264
|
+
return;
|
|
6265
|
+
const fields2 = TELEMETRY_SCHEMA.events[event.event].fields;
|
|
6266
|
+
const client = Object.fromEntries(Object.entries(event).filter(([key]) => !fields2[key]?.server_stamped));
|
|
6267
|
+
const checked = validateEvent(TELEMETRY_SCHEMA, client, { mode: "client", transport: "stdio" });
|
|
6268
|
+
if (!checked.ok)
|
|
6269
|
+
return;
|
|
6270
|
+
if (queue.length >= 200)
|
|
6271
|
+
queue.shift();
|
|
6272
|
+
if (queue.length === 0)
|
|
6273
|
+
dueAt = now() + 1e4;
|
|
6274
|
+
queue.push(checked.event);
|
|
6275
|
+
schedule();
|
|
6276
|
+
});
|
|
6277
|
+
},
|
|
6278
|
+
flushNow(deadlineMs) {
|
|
6279
|
+
if (flushing)
|
|
6280
|
+
return flushing;
|
|
6281
|
+
flushing = Promise.resolve().then(() => flush(deadlineMs)).catch(() => {
|
|
6282
|
+
telemetrySafe("relay_flush", () => {
|
|
6283
|
+
throw new Error("relay flush failed");
|
|
6284
|
+
});
|
|
6285
|
+
}).finally(() => {
|
|
6286
|
+
flushing = void 0;
|
|
6287
|
+
});
|
|
6288
|
+
return flushing;
|
|
6289
|
+
}
|
|
6290
|
+
};
|
|
6291
|
+
}
|
|
6292
|
+
|
|
4571
6293
|
// src/index.ts
|
|
4572
6294
|
var require2 = createRequire(import.meta.url);
|
|
4573
6295
|
var { version } = require2("../package.json");
|
|
4574
6296
|
async function main() {
|
|
4575
6297
|
const config = loadConfig();
|
|
4576
6298
|
const api = new JoyApi(config);
|
|
6299
|
+
const mode = telemetryMode();
|
|
6300
|
+
const relay = mode === "full" ? createRelaySink({ apiBaseUrl: config.apiBaseUrl, authHeaders: authHeaders(config) }) : void 0;
|
|
6301
|
+
const telemetry = mode === "off" ? void 0 : createRecorder({
|
|
6302
|
+
sink: relay ?? noopSink,
|
|
6303
|
+
base: { transport: "stdio", server_version: version, session_id: randomUUID3() },
|
|
6304
|
+
sweepTtlMs: 6e5
|
|
6305
|
+
});
|
|
6306
|
+
if (relay) {
|
|
6307
|
+
let exiting = false;
|
|
6308
|
+
let exitWork;
|
|
6309
|
+
const shutdown = async (signal = false) => {
|
|
6310
|
+
if (!exiting) {
|
|
6311
|
+
exiting = true;
|
|
6312
|
+
telemetry?.close();
|
|
6313
|
+
exitWork = relay.flushNow(2e3);
|
|
6314
|
+
}
|
|
6315
|
+
if (signal) {
|
|
6316
|
+
await exitWork;
|
|
6317
|
+
process.exit(0);
|
|
6318
|
+
}
|
|
6319
|
+
};
|
|
6320
|
+
process.stdin.on("end", () => {
|
|
6321
|
+
void shutdown();
|
|
6322
|
+
});
|
|
6323
|
+
process.stdin.on("close", () => {
|
|
6324
|
+
void shutdown();
|
|
6325
|
+
});
|
|
6326
|
+
process.on("beforeExit", () => {
|
|
6327
|
+
void shutdown();
|
|
6328
|
+
});
|
|
6329
|
+
process.on("SIGINT", () => {
|
|
6330
|
+
void shutdown(true);
|
|
6331
|
+
});
|
|
6332
|
+
process.on("SIGTERM", () => {
|
|
6333
|
+
void shutdown(true);
|
|
6334
|
+
});
|
|
6335
|
+
}
|
|
4577
6336
|
const server = new McpServer({ name: "joy-loyalty", version });
|
|
4578
|
-
registerAllTools(server, { api, config, cache: new TtlCache() });
|
|
6337
|
+
registerAllTools(server, { api, config, cache: new TtlCache(), telemetry });
|
|
4579
6338
|
registerAllPrompts(server);
|
|
4580
6339
|
const transport = new StdioServerTransport();
|
|
4581
6340
|
await server.connect(transport);
|
|
6341
|
+
telemetry?.instrumentTransport(transport);
|
|
4582
6342
|
log.info(
|
|
4583
|
-
`joy-mcp v${version} ready \u2014 mode=${config.mode}, tools=${config.writesEnabled ? "read+write" : "read-only"}, api=${config.apiBaseUrl}`
|
|
6343
|
+
`joy-mcp v${version} ready \u2014 mode=${config.mode}, tools=${config.writesEnabled ? "read+write" : "read-only"}, api=${config.apiBaseUrl}, telemetry=${mode}`
|
|
4584
6344
|
);
|
|
4585
6345
|
}
|
|
4586
6346
|
main().catch((error) => {
|