@solumflow-app/crm-mcp-server 0.4.0 → 0.6.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 +36 -2
- package/dist/index.js +869 -222
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -6,10 +6,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
6
6
|
import { createClient } from "@solumflow-app/crm-client";
|
|
7
7
|
|
|
8
8
|
// package.json
|
|
9
|
-
var version = "0.
|
|
9
|
+
var version = "0.6.0";
|
|
10
10
|
|
|
11
|
-
// src/catalog-tools.ts
|
|
12
|
-
import { z as
|
|
11
|
+
// src/catalog-migration-tools.ts
|
|
12
|
+
import { z as z4 } from "zod";
|
|
13
13
|
|
|
14
14
|
// ../features/crm-invoicing/src/schema/catalog-api.schema.ts
|
|
15
15
|
import * as z from "zod";
|
|
@@ -43,6 +43,12 @@ var CatalogPriceSchema = z.object({
|
|
|
43
43
|
/** The price's own id at the source, so a renamed price stays itself. */
|
|
44
44
|
externalRef: CatalogExternalRefSchema.optional(),
|
|
45
45
|
label: z.string().trim().min(1).max(120),
|
|
46
|
+
/**
|
|
47
|
+
* ISO 4217. Left out, the price is in the product's own currency. A
|
|
48
|
+
* price in another currency is the fixed price for that currency: it
|
|
49
|
+
* wins over a converted one and is never the default.
|
|
50
|
+
*/
|
|
51
|
+
currency: z.string().regex(/^[A-Z]{3}$/).optional(),
|
|
46
52
|
amountCents: cents,
|
|
47
53
|
compareAtCents: cents.nullable().optional(),
|
|
48
54
|
kind: z.enum(["one_off", "recurring"]).optional(),
|
|
@@ -78,21 +84,64 @@ var pricesSchema = z.array(CatalogPriceSchema).min(1).max(50).superRefine((price
|
|
|
78
84
|
message: "Two prices carry the same externalRef."
|
|
79
85
|
});
|
|
80
86
|
}
|
|
81
|
-
const labels = prices.map(
|
|
87
|
+
const labels = prices.map(
|
|
88
|
+
(price) => `${price.currency ?? ""} ${price.label.toLowerCase()}`
|
|
89
|
+
);
|
|
82
90
|
if (new Set(labels).size !== labels.length) {
|
|
83
91
|
ctx.addIssue({
|
|
84
92
|
code: "custom",
|
|
85
93
|
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
86
|
-
message: "Two prices carry the same label."
|
|
94
|
+
message: "Two prices carry the same label in the same currency."
|
|
87
95
|
});
|
|
88
96
|
}
|
|
89
97
|
});
|
|
98
|
+
var HttpsUrlSchema = z.string().max(2048).url().refine((url) => url.startsWith("https://"), {
|
|
99
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
100
|
+
message: "A file is fetched over https only."
|
|
101
|
+
});
|
|
102
|
+
var UploadPathSchema = z.string().min(1).max(200);
|
|
103
|
+
var CatalogFileRefSchema = z.union([
|
|
104
|
+
z.object({ url: HttpsUrlSchema }).strict(),
|
|
105
|
+
z.object({ upload: UploadPathSchema }).strict()
|
|
106
|
+
]);
|
|
107
|
+
var IMAGE_ROLE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
108
|
+
var CatalogImageDetailsSchema = z.record(
|
|
109
|
+
z.string().min(1).max(64),
|
|
110
|
+
z.union([z.string().max(500), z.number().finite(), z.boolean()])
|
|
111
|
+
).refine((details) => JSON.stringify(details).length <= 1800, {
|
|
112
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
113
|
+
message: "Details are at most about 2 KB."
|
|
114
|
+
});
|
|
90
115
|
var CatalogImageSchema = z.object({
|
|
91
|
-
url:
|
|
116
|
+
url: HttpsUrlSchema.optional(),
|
|
117
|
+
upload: UploadPathSchema.optional(),
|
|
118
|
+
alt: text(300),
|
|
119
|
+
/**
|
|
120
|
+
* What the picture is of, in the account's own words: `work`, `detail`,
|
|
121
|
+
* `insitu`. Left out keeps the role a stored picture has; `null` clears it.
|
|
122
|
+
*/
|
|
123
|
+
role: z.string().regex(IMAGE_ROLE, {
|
|
124
|
+
message: (
|
|
125
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
126
|
+
"A role starts with a letter and holds lower-case letters, digits, hyphens and underscores, at most 32."
|
|
127
|
+
)
|
|
128
|
+
}).nullable().optional(),
|
|
129
|
+
details: CatalogImageDetailsSchema.optional()
|
|
130
|
+
}).strict().refine(
|
|
131
|
+
(image) => image.url === void 0 !== (image.upload === void 0),
|
|
132
|
+
{
|
|
92
133
|
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
93
|
-
message: "A picture
|
|
94
|
-
}
|
|
95
|
-
|
|
134
|
+
message: "A picture names either a url or an upload, not both."
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
var CatalogStockSchema = z.object({
|
|
138
|
+
managed: z.boolean(),
|
|
139
|
+
/** Left out, the count stays as it is. */
|
|
140
|
+
quantity: z.number().int().min(0).max(1e9).optional(),
|
|
141
|
+
/** Below this a product counts as running low; `null` warns never. */
|
|
142
|
+
lowThreshold: z.number().int().min(0).max(1e9).nullable().optional(),
|
|
143
|
+
/** Whether it can still be ordered at zero: `deny`, `allow`, or `allow_notify`. */
|
|
144
|
+
backorders: z.enum(["deny", "allow", "allow_notify"]).optional()
|
|
96
145
|
}).strict();
|
|
97
146
|
var CatalogVariantAxisSchema = z.object({
|
|
98
147
|
slug: CatalogFieldSlugSchema,
|
|
@@ -108,6 +157,8 @@ var CatalogVariantSchema = z.object({
|
|
|
108
157
|
sku: text(100),
|
|
109
158
|
gtin: z.string().regex(/^[0-9]{8,14}$/).nullable().optional(),
|
|
110
159
|
prices: pricesSchema.optional(),
|
|
160
|
+
/** The variant's own shelf; a physical product counts per variant. */
|
|
161
|
+
stock: CatalogStockSchema.optional(),
|
|
111
162
|
position: z.number().int().min(0).max(1e4).optional(),
|
|
112
163
|
active: z.boolean().optional()
|
|
113
164
|
}).strict();
|
|
@@ -139,6 +190,15 @@ var CatalogProductUpsertSchema = z.object({
|
|
|
139
190
|
visibility: z.enum(["public", "unlisted"]).optional(),
|
|
140
191
|
metaTitle: text(300),
|
|
141
192
|
metaDescription: text(1e3),
|
|
193
|
+
/**
|
|
194
|
+
* What a shared link to the product shows (the og tags): its own title,
|
|
195
|
+
* description and picture. Left out, a link falls back to the name, the
|
|
196
|
+
* description and the first picture. `socialImage` is fetched after the
|
|
197
|
+
* answer like `images`; `null` takes it away.
|
|
198
|
+
*/
|
|
199
|
+
socialTitle: text(300),
|
|
200
|
+
socialDescription: text(1e3),
|
|
201
|
+
socialImage: CatalogFileRefSchema.nullable().optional(),
|
|
142
202
|
/** Replaces the product's categories, by slug. */
|
|
143
203
|
categories: z.array(CatalogWebSlugSchema).max(50).optional(),
|
|
144
204
|
/**
|
|
@@ -157,6 +217,11 @@ var CatalogProductUpsertSchema = z.object({
|
|
|
157
217
|
addMissingOptions: z.boolean().optional(),
|
|
158
218
|
/** Replaces the product's prices as a whole. */
|
|
159
219
|
prices: pricesSchema.optional(),
|
|
220
|
+
/**
|
|
221
|
+
* The product's own shelf. Physical products only, and only one without
|
|
222
|
+
* variants: a product with variants counts per variant.
|
|
223
|
+
*/
|
|
224
|
+
stock: CatalogStockSchema.optional(),
|
|
160
225
|
/** Replaces the product's pictures, in this order. */
|
|
161
226
|
images: z.array(CatalogImageSchema).max(20).optional(),
|
|
162
227
|
variantAxes: z.array(CatalogVariantAxisSchema).min(1).max(3).optional(),
|
|
@@ -219,6 +284,14 @@ var CatalogProductUpsertSchema = z.object({
|
|
|
219
284
|
});
|
|
220
285
|
}
|
|
221
286
|
}
|
|
287
|
+
if (body.stock !== void 0 && body.variantAxes !== void 0) {
|
|
288
|
+
ctx.addIssue({
|
|
289
|
+
code: "custom",
|
|
290
|
+
path: ["stock"],
|
|
291
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
292
|
+
message: "A product with variants counts its stock per variant."
|
|
293
|
+
});
|
|
294
|
+
}
|
|
222
295
|
if (body.availability === "scheduled" && !body.publishedAt) {
|
|
223
296
|
ctx.addIssue({
|
|
224
297
|
code: "custom",
|
|
@@ -230,6 +303,15 @@ var CatalogProductUpsertSchema = z.object({
|
|
|
230
303
|
});
|
|
231
304
|
var CatalogProductArchiveSchema = z.object({ source: CatalogSourceSchema }).strict();
|
|
232
305
|
var CatalogCategoryUpsertSchema = z.object({
|
|
306
|
+
/**
|
|
307
|
+
* The category's id in the system it came from -- a series in a site's
|
|
308
|
+
* own CMS -- with `source` naming whose id it is. With both, a category
|
|
309
|
+
* that carries this reference is changed, and renamed to the address in
|
|
310
|
+
* the path when somebody gave it another one in the app; without, the
|
|
311
|
+
* address alone finds it, as before.
|
|
312
|
+
*/
|
|
313
|
+
source: CatalogSourceSchema.optional(),
|
|
314
|
+
externalRef: CatalogExternalRefSchema.optional(),
|
|
233
315
|
name: z.string().trim().min(1).max(200).optional(),
|
|
234
316
|
description: text(5e3),
|
|
235
317
|
/** `null` puts the category at the top of the tree. */
|
|
@@ -238,7 +320,14 @@ var CatalogCategoryUpsertSchema = z.object({
|
|
|
238
320
|
publishedAt: z.string().datetime({ offset: true }).nullable().optional(),
|
|
239
321
|
metaTitle: text(300),
|
|
240
322
|
metaDescription: text(1e3)
|
|
241
|
-
}).strict()
|
|
323
|
+
}).strict().refine(
|
|
324
|
+
(body) => body.source === void 0 === (body.externalRef === void 0),
|
|
325
|
+
{
|
|
326
|
+
path: ["externalRef"],
|
|
327
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
328
|
+
message: "A category reference names both source and externalRef."
|
|
329
|
+
}
|
|
330
|
+
);
|
|
242
331
|
var CatalogObjectSlugSchema = z.string().max(64).regex(FIELD_SLUG, {
|
|
243
332
|
message: (
|
|
244
333
|
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
@@ -256,6 +345,7 @@ var CatalogFieldTypeSchema = z.enum([
|
|
|
256
345
|
"number",
|
|
257
346
|
"select",
|
|
258
347
|
"checkbox",
|
|
348
|
+
"color",
|
|
259
349
|
"url",
|
|
260
350
|
"date",
|
|
261
351
|
"content_text",
|
|
@@ -286,7 +376,24 @@ var CatalogAttributeUpsertSchema = z.object({
|
|
|
286
376
|
* a configurator's whole structure. An item field keeps its type once it
|
|
287
377
|
* exists.
|
|
288
378
|
*/
|
|
289
|
-
config: z.record(z.string(), z.unknown()).optional()
|
|
379
|
+
config: z.record(z.string(), z.unknown()).optional(),
|
|
380
|
+
/**
|
|
381
|
+
* Say what this body would change -- a new field, a new label, choices
|
|
382
|
+
* added, choices taken away and how many products hold each -- and
|
|
383
|
+
* write nothing.
|
|
384
|
+
*/
|
|
385
|
+
dryRun: z.boolean().optional(),
|
|
386
|
+
/**
|
|
387
|
+
* Bring the field back from the trash under its own id, with every value
|
|
388
|
+
* the products kept. Nothing else may be sent with it.
|
|
389
|
+
*/
|
|
390
|
+
restore: z.literal(true).optional(),
|
|
391
|
+
/**
|
|
392
|
+
* For a new content field (`content_text`, `repeater`, `cta`): put its
|
|
393
|
+
* block at the end of the product page. On by default; `false` makes the
|
|
394
|
+
* field only. Never moves or restores a block of an existing field.
|
|
395
|
+
*/
|
|
396
|
+
placeOnPage: z.boolean().optional()
|
|
290
397
|
}).strict();
|
|
291
398
|
var CONFIGURATOR_FILE_KINDS = ["model", "texture"];
|
|
292
399
|
var CONFIGURATOR_TEXTURE_CONTENT_TYPES = [
|
|
@@ -312,6 +419,38 @@ var ConfiguratorFileUploadSchema = z.discriminatedUnion("step", [
|
|
|
312
419
|
uploadPath: z.string().max(200)
|
|
313
420
|
}).strict()
|
|
314
421
|
]);
|
|
422
|
+
var CATALOG_FILE_KINDS = ["image", "file"];
|
|
423
|
+
var CATALOG_FILE_CONTENT_TYPES = [
|
|
424
|
+
"image/png",
|
|
425
|
+
"image/jpeg",
|
|
426
|
+
"image/webp",
|
|
427
|
+
"application/pdf",
|
|
428
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
429
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
430
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
431
|
+
"text/plain",
|
|
432
|
+
"text/csv",
|
|
433
|
+
"application/zip"
|
|
434
|
+
];
|
|
435
|
+
var CatalogFileUploadSchema = z.discriminatedUnion("step", [
|
|
436
|
+
z.object({
|
|
437
|
+
step: z.literal("start"),
|
|
438
|
+
/** A picture (2 MB, png/jpeg/webp) or a download for a list item (20 MB). */
|
|
439
|
+
kind: z.enum(CATALOG_FILE_KINDS),
|
|
440
|
+
/** The bytes about to be sent, so a too-large file is refused before it travels. */
|
|
441
|
+
size: z.number().int().min(1),
|
|
442
|
+
contentType: z.enum(CATALOG_FILE_CONTENT_TYPES)
|
|
443
|
+
}).strict(),
|
|
444
|
+
z.object({
|
|
445
|
+
step: z.literal("finish"),
|
|
446
|
+
kind: z.enum(CATALOG_FILE_KINDS),
|
|
447
|
+
/** The `uploadPath` `start` answered with. */
|
|
448
|
+
uploadPath: z.string().max(200)
|
|
449
|
+
}).strict()
|
|
450
|
+
]);
|
|
451
|
+
|
|
452
|
+
// src/catalog-tools.ts
|
|
453
|
+
import { z as z3 } from "zod";
|
|
315
454
|
|
|
316
455
|
// src/tool-answers.ts
|
|
317
456
|
import { CrmApiError } from "@solumflow-app/crm-client";
|
|
@@ -342,7 +481,7 @@ function refusal(error, scope) {
|
|
|
342
481
|
var NEXT_STEP = {
|
|
343
482
|
unauthorized: "The key is not recognised or was revoked. Ask the account owner for a new secret key.",
|
|
344
483
|
feature_unavailable: "The module this needs is switched off for the account. Ask somebody to switch it on in the app.",
|
|
345
|
-
rate_limited: "Too many requests. Wait
|
|
484
|
+
rate_limited: "Too many requests: 120 a minute per key, 600 per account, and 5 MB of bodies a day per key. Wait the seconds the answer names and try again.",
|
|
346
485
|
internal_error: "Something went wrong in the app. Trying again is safe."
|
|
347
486
|
};
|
|
348
487
|
function codeBlock(snippet) {
|
|
@@ -357,8 +496,8 @@ var page = {
|
|
|
357
496
|
function more(nextCursor) {
|
|
358
497
|
return nextCursor ? `More on the next page: cursor ${nextCursor}` : "";
|
|
359
498
|
}
|
|
360
|
-
function money(cents2,
|
|
361
|
-
return `${(cents2 / 100).toFixed(2)} ${
|
|
499
|
+
function money(cents2, currency2) {
|
|
500
|
+
return `${(cents2 / 100).toFixed(2)} ${currency2.toUpperCase()}`;
|
|
362
501
|
}
|
|
363
502
|
|
|
364
503
|
// src/catalog-tools.ts
|
|
@@ -367,7 +506,15 @@ var objectInput = {
|
|
|
367
506
|
"The slug of the catalogue (see list_catalogs). Leave out for the account\u2019s own catalogue."
|
|
368
507
|
)
|
|
369
508
|
};
|
|
370
|
-
var CONTENT_BLOCKS = 'Besides plain values (text, number, choice), a product can carry content blocks, three field types: "content_text" is { html, links? } with already-sanitised html; "cta" is { heading, body, primary: {label,url}, secondary? }, a call to action; "repeater" is { items: [{ id, ...one value per item field }] } and covers lists, accordions (FAQ), downloads and testimonials. A repeater\u2019s field says how to draw it: "display" is "list" or "accordion", "preset" names where it started (list, faq, downloads, testimonials), "itemFields" is the structure of one item (key, label, type: text, richtext, url {label,url}, icon {set,name}, image or file {url,name,size,contentType}) and "titleKey" is the item field that names an item, such as the question of a FAQ. A "configurator" is a 3D product configurator: a listing carries only { configurator: true }, a product\u2019s own page the whole document (see read_configurator). A block that is public but empty is null; a block that is not public is not there at all.';
|
|
509
|
+
var CONTENT_BLOCKS = 'Besides plain values (text, number, choice), a product can carry content blocks, three field types: "content_text" is { html, links? } with already-sanitised html; "cta" is { heading, body, primary: {label,url}, secondary? }, a call to action; "repeater" is { items: [{ id, ...one value per item field }] } and covers lists, accordions (FAQ), downloads and testimonials. A repeater\u2019s field says how to draw it: "display" is "list" or "accordion", "preset" names where it started (list, faq, downloads, testimonials), "itemFields" is the structure of one item (key, label, type: text, richtext, url {label,url}, icon {set,name}, image or file {url,name,size,contentType}) and "titleKey" is the item field that names an item, such as the question of a FAQ. A "configurator" is a 3D product configurator: a listing carries only { configurator: true }, a product\u2019s own page the whole document (see read_configurator). A block that is public but empty is null; a block that is not public is not there at all. A new block field stands at the end of the product page from the moment upsert_field makes it (`placeOnPage: false` skips that).';
|
|
510
|
+
var currency = z3.string().regex(/^[A-Z]{3}$/).optional().describe(
|
|
511
|
+
"Every price in this currency (see list_currencies), each with its origin: base, fixed or converted. Left out, every price in its own currency."
|
|
512
|
+
);
|
|
513
|
+
var READ_STATUS_HINT = 'Left out, only published ones. "draft" is what is not on the website yet (concept, internal or scheduled), "archived" what was taken out, "all" everything; each answers with a `status` and `publishedAt` per item. Anything but published needs a key with catalog:write.';
|
|
514
|
+
var productStatus = z3.enum(["published", "draft", "archived", "all"]).optional().describe(`Which products. ${READ_STATUS_HINT}`);
|
|
515
|
+
var categoryStatus = z3.enum(["published", "draft", "all"]).optional().describe(
|
|
516
|
+
`Which categories. ${READ_STATUS_HINT.replace('"archived" what was taken out, ', "")} A category cannot be archived.`
|
|
517
|
+
);
|
|
371
518
|
var slugOrId = z3.string().min(1).max(200).describe("The address (slug) or the id of the product.");
|
|
372
519
|
function registerCatalogTools(server, client) {
|
|
373
520
|
server.registerTool(
|
|
@@ -412,17 +559,49 @@ function registerCatalogTools(server, client) {
|
|
|
412
559
|
}
|
|
413
560
|
}
|
|
414
561
|
);
|
|
562
|
+
server.registerTool(
|
|
563
|
+
"list_currencies",
|
|
564
|
+
{
|
|
565
|
+
title: "List the currencies the shop sells in",
|
|
566
|
+
description: "The account\u2019s own currency first, then the ones it switched on, with a name and symbol: what a currency menu on a website is built from. Pass one as `currency` to get_product or list_products for prices in it. Which currencies are offered, and at what rate, is set in the app (Settings \u2192 Verkoop \u2192 Valuta), not through this tool.",
|
|
567
|
+
inputSchema: {
|
|
568
|
+
locale: z3.string().min(2).max(35).optional().describe('The language names are written in, such as "nl".')
|
|
569
|
+
},
|
|
570
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
571
|
+
},
|
|
572
|
+
async ({ locale }) => {
|
|
573
|
+
try {
|
|
574
|
+
const currencies = await client.getCurrencies(
|
|
575
|
+
locale ? { locale } : void 0
|
|
576
|
+
);
|
|
577
|
+
return answer(
|
|
578
|
+
currencies.map(
|
|
579
|
+
(option) => `${option.code} (${option.symbol}) ${option.name}${option.isDefault ? ", the account\u2019s own" : ""}`
|
|
580
|
+
).join("\n"),
|
|
581
|
+
currencies
|
|
582
|
+
);
|
|
583
|
+
} catch (error) {
|
|
584
|
+
return refusal(error, "catalog:read");
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
);
|
|
415
588
|
server.registerTool(
|
|
416
589
|
"get_product",
|
|
417
590
|
{
|
|
418
591
|
title: "Read one product",
|
|
419
|
-
description: "One product as its page needs it: name, prices, pictures, categories, variants and `fields`, keyed by field slug.
|
|
420
|
-
inputSchema: { slugOrId },
|
|
592
|
+
description: "One product as its page needs it: name, prices, pictures, categories, variants and `fields`, keyed by field slug. Published products only unless you pass `status` (needs catalog:write), and a published one may be a minute old: to read back exactly what you just wrote, use get_product_by_ref. Answers that it is not found for an unknown product, or one not in the status asked. " + CONTENT_BLOCKS,
|
|
593
|
+
inputSchema: { slugOrId, currency, status: productStatus },
|
|
421
594
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
422
595
|
},
|
|
423
|
-
async ({ slugOrId: wanted }) => {
|
|
596
|
+
async ({ slugOrId: wanted, currency: inCurrency, status }) => {
|
|
424
597
|
try {
|
|
425
|
-
const product = await client.getProduct(
|
|
598
|
+
const product = await client.getProduct(
|
|
599
|
+
wanted,
|
|
600
|
+
inCurrency || status ? {
|
|
601
|
+
...inCurrency ? { currency: inCurrency } : {},
|
|
602
|
+
...status ? { status } : {}
|
|
603
|
+
} : void 0
|
|
604
|
+
);
|
|
426
605
|
if (!product) {
|
|
427
606
|
return answer(`No product "${wanted}" on this account.`, null);
|
|
428
607
|
}
|
|
@@ -439,22 +618,41 @@ function registerCatalogTools(server, client) {
|
|
|
439
618
|
server.registerTool(
|
|
440
619
|
"list_products",
|
|
441
620
|
{
|
|
442
|
-
title: "List the
|
|
443
|
-
description: "One page of the catalogue: name, address (slug), lowest price, whether it is in stock, its first picture and the public fields a listing shows. Narrow it to one category\u2019s whole branch by its id (
|
|
621
|
+
title: "List the products",
|
|
622
|
+
description: "One page of the catalogue: name, address (slug), lowest price, whether it is in stock, its first picture and the public fields a listing shows. Narrow it to one category\u2019s whole branch by its id or address, or by the reference upsert_category recorded (`categorySource` + `categoryRef`), to one catalogue by the slug of its object, or to the product carrying an article number (`sku`). Read get_product for everything a product page needs. Published products only, and may be a minute old; `status` lists concepts, scheduled and archived ones too (needs catalog:write).",
|
|
444
623
|
inputSchema: {
|
|
445
624
|
...page,
|
|
446
|
-
category: z3.string().
|
|
625
|
+
category: z3.string().regex(
|
|
626
|
+
/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[a-z0-9]+(-[a-z0-9]+)*)$/i
|
|
627
|
+
).max(64).optional().describe(
|
|
628
|
+
"A category id or address (slug); includes its subcategories."
|
|
629
|
+
),
|
|
630
|
+
categorySource: z3.string().max(64).optional().describe("With categoryRef: whose id the category reference is."),
|
|
631
|
+
categoryRef: z3.string().max(255).optional().describe("A category by the id it has in your own system."),
|
|
632
|
+
sku: z3.string().min(1).max(100).optional().describe(
|
|
633
|
+
"The product carrying this article number, or whose variant does."
|
|
634
|
+
),
|
|
447
635
|
object: z3.string().min(1).max(63).optional().describe(
|
|
448
636
|
'One catalogue, by the slug of its object, such as "product".'
|
|
449
|
-
)
|
|
637
|
+
),
|
|
638
|
+
currency,
|
|
639
|
+
status: productStatus
|
|
450
640
|
},
|
|
451
641
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
452
642
|
},
|
|
453
|
-
async (input) => {
|
|
643
|
+
async ({ categorySource, categoryRef, ...input }) => {
|
|
454
644
|
try {
|
|
455
|
-
const list = await client.getProducts(
|
|
645
|
+
const list = await client.getProducts({
|
|
646
|
+
...input,
|
|
647
|
+
...categorySource && categoryRef ? {
|
|
648
|
+
categoryRef: {
|
|
649
|
+
source: categorySource,
|
|
650
|
+
externalRef: categoryRef
|
|
651
|
+
}
|
|
652
|
+
} : {}
|
|
653
|
+
});
|
|
456
654
|
return answer(
|
|
457
|
-
list.data.length === 0 ?
|
|
655
|
+
list.data.length === 0 ? `No ${input.status && input.status !== "published" ? input.status : "published"} products match.` : [...list.data.map(describeProduct), more(list.nextCursor)].filter(Boolean).join("\n"),
|
|
458
656
|
list
|
|
459
657
|
);
|
|
460
658
|
} catch (error) {
|
|
@@ -465,11 +663,12 @@ function registerCatalogTools(server, client) {
|
|
|
465
663
|
server.registerTool(
|
|
466
664
|
"list_categories",
|
|
467
665
|
{
|
|
468
|
-
title: "List the
|
|
469
|
-
description: 'The categories of the catalogue, each with its path from the top and how many products are in it. Left without `parent`, every category comes back, which is what building a menu in one go needs; "root" asks for the top level, a category id for its children.',
|
|
666
|
+
title: "List the categories",
|
|
667
|
+
description: 'The categories of the catalogue, each with its path from the top and how many products are in it. Left without `parent`, every category comes back, which is what building a menu in one go needs; "root" asks for the top level, a category id for its children. Published categories only unless you pass `status` (draft or all, needs catalog:write).',
|
|
470
668
|
inputSchema: {
|
|
471
669
|
...page,
|
|
472
|
-
parent: z3.union([z3.literal("root"), z3.string().uuid()]).optional().describe('"root" or the id of a category.')
|
|
670
|
+
parent: z3.union([z3.literal("root"), z3.string().uuid()]).optional().describe('"root" or the id of a category.'),
|
|
671
|
+
status: categoryStatus
|
|
473
672
|
},
|
|
474
673
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
475
674
|
},
|
|
@@ -477,7 +676,7 @@ function registerCatalogTools(server, client) {
|
|
|
477
676
|
try {
|
|
478
677
|
const list = await client.getCategories(input);
|
|
479
678
|
return answer(
|
|
480
|
-
list.data.length === 0 ?
|
|
679
|
+
list.data.length === 0 ? `No ${input.status && input.status !== "published" ? input.status : "published"} categories.` : [...list.data.map(describeCategory), more(list.nextCursor)].filter(Boolean).join("\n"),
|
|
481
680
|
list
|
|
482
681
|
);
|
|
483
682
|
} catch (error) {
|
|
@@ -489,10 +688,11 @@ function registerCatalogTools(server, client) {
|
|
|
489
688
|
"get_category",
|
|
490
689
|
{
|
|
491
690
|
title: "Read one category page",
|
|
492
|
-
description: "One category as its page needs it: name, description, the texts for search engines and social media, and the first page of its products. Pass the nextCursor back to page through the products.",
|
|
691
|
+
description: "One category as its page needs it: name, description, the texts for search engines and social media, and the first page of its products. Pass the nextCursor back to page through the products. A category that is not on the website yet needs `status` (draft or all, catalog:write); its products are the published ones.",
|
|
493
692
|
inputSchema: {
|
|
494
693
|
slugOrId: z3.string().min(1).max(200).describe("The address (slug) or the id of the category."),
|
|
495
|
-
...page
|
|
694
|
+
...page,
|
|
695
|
+
status: categoryStatus
|
|
496
696
|
},
|
|
497
697
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
498
698
|
},
|
|
@@ -541,35 +741,308 @@ function registerCatalogTools(server, client) {
|
|
|
541
741
|
);
|
|
542
742
|
}
|
|
543
743
|
function describeProduct(product) {
|
|
544
|
-
const stock = product.inStock ? "" : ", out of stock"
|
|
545
|
-
|
|
744
|
+
const stock = `${product.inStock ? "" : ", out of stock"}${product.status ? `, ${product.status}` : ""}`;
|
|
745
|
+
const from = product.priceFromCents === null ? `no price in ${product.currency}` : `from ${money(product.priceFromCents, product.currency)}`;
|
|
746
|
+
return `- ${product.name} (${product.slug}): ${from}${stock}`;
|
|
546
747
|
}
|
|
547
748
|
function describeCategory(category) {
|
|
548
749
|
const path = category.path.map((step) => step.name).join(" / ");
|
|
549
|
-
|
|
750
|
+
const state = category.status ? `, ${category.status}` : "";
|
|
751
|
+
return `- ${path || category.name} (${category.slug || "no address yet"}, id ${category.id}${state}): ${category.productCount} products`;
|
|
550
752
|
}
|
|
551
753
|
function describeField(field) {
|
|
552
754
|
if (field.type !== "repeater") {
|
|
553
|
-
return `- ${field.slug}: ${field.label}, ${field.type}`;
|
|
755
|
+
return `- ${field.slug}: ${field.label}, ${field.type}${settingsOf(field)}`;
|
|
554
756
|
}
|
|
555
757
|
const items = (field.itemFields ?? []).map(({ key, type }) => `${key}:${type}`).join(", ");
|
|
556
758
|
const preset = field.preset ? `, ${field.preset}` : "";
|
|
557
759
|
const title = field.titleKey ? `; titled by ${field.titleKey}` : "";
|
|
558
760
|
return `- ${field.slug}: ${field.label}, repeater (${field.display ?? "list"}${preset}; items: ${items}${title})`;
|
|
559
761
|
}
|
|
762
|
+
var SETTING_KEYS = [
|
|
763
|
+
"prefix",
|
|
764
|
+
"suffix",
|
|
765
|
+
"currency",
|
|
766
|
+
"symbolPosition",
|
|
767
|
+
"thousandsSeparator",
|
|
768
|
+
"decimalSeparator",
|
|
769
|
+
"decimals",
|
|
770
|
+
"display",
|
|
771
|
+
"layout",
|
|
772
|
+
"mode",
|
|
773
|
+
"secondary"
|
|
774
|
+
];
|
|
775
|
+
function settingsOf(field) {
|
|
776
|
+
const given = SETTING_KEYS.flatMap(
|
|
777
|
+
(key) => field[key] === void 0 ? [] : [`${key} ${JSON.stringify(field[key])}`]
|
|
778
|
+
);
|
|
779
|
+
if (field.rows) {
|
|
780
|
+
given.push(`${field.rows.length} rows`);
|
|
781
|
+
}
|
|
782
|
+
if (field.columns) {
|
|
783
|
+
given.push(`${field.columns.length} columns`);
|
|
784
|
+
}
|
|
785
|
+
return given.length > 0 ? ` (${given.join(", ")})` : "";
|
|
786
|
+
}
|
|
560
787
|
|
|
561
|
-
// src/catalog-
|
|
788
|
+
// src/catalog-migration-tools.ts
|
|
789
|
+
import { readFile, stat } from "fs/promises";
|
|
790
|
+
import { extname } from "path";
|
|
791
|
+
var BULK_LIMIT = 25;
|
|
792
|
+
var LIMITS = "Limits: 120 requests a minute per key and 600 per account; 5 MB of request bodies a day per key and 20 MB per account (a long HTML description counts). A refusal for speed says how long to wait.";
|
|
793
|
+
var FILE_RULES = {
|
|
794
|
+
image: {
|
|
795
|
+
maxBytes: 2 * 1024 * 1024,
|
|
796
|
+
types: {
|
|
797
|
+
".png": "image/png",
|
|
798
|
+
".jpg": "image/jpeg",
|
|
799
|
+
".jpeg": "image/jpeg",
|
|
800
|
+
".webp": "image/webp"
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
file: {
|
|
804
|
+
maxBytes: 20 * 1024 * 1024,
|
|
805
|
+
types: {
|
|
806
|
+
".pdf": "application/pdf",
|
|
807
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
808
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
809
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
810
|
+
".txt": "text/plain",
|
|
811
|
+
".csv": "text/csv",
|
|
812
|
+
".zip": "application/zip"
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
};
|
|
562
816
|
function target(object2) {
|
|
563
817
|
return object2 ? { object: object2 } : void 0;
|
|
564
818
|
}
|
|
565
|
-
|
|
819
|
+
function notDone(text2) {
|
|
820
|
+
return { isError: true, content: [{ type: "text", text: text2 }] };
|
|
821
|
+
}
|
|
822
|
+
function registerCatalogMigrationTools(server, client) {
|
|
823
|
+
server.registerTool(
|
|
824
|
+
"get_product_by_ref",
|
|
825
|
+
{
|
|
826
|
+
title: "Read back a product you wrote",
|
|
827
|
+
description: "One product by the reference it was written under (`source` + `externalRef`), a draft too. `fields` holds every field the product has a value for, public or not, as stored -- list item ids and file paths included, which a write accepts back; `publicFields` is what a visitor sees of them. `categories` lists every category it is filed under, a concept one too, each with its `status`. Also `status` and `publishedAt`, its sku and shelf per variant, the shared-link texts and picture, and `files` -- for every picture or file of the last write whether it is `pending`, `stored` or `refused`, with a reason code. Live, never cached: read this after upsert_product to see what arrived.",
|
|
828
|
+
inputSchema: {
|
|
829
|
+
externalRef: CatalogExternalRefSchema,
|
|
830
|
+
source: CatalogSourceSchema,
|
|
831
|
+
...objectInput
|
|
832
|
+
},
|
|
833
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
834
|
+
},
|
|
835
|
+
async ({ externalRef, source, object: object2 }) => {
|
|
836
|
+
try {
|
|
837
|
+
const product = await client.getProductByRef(
|
|
838
|
+
externalRef,
|
|
839
|
+
source,
|
|
840
|
+
target(object2)
|
|
841
|
+
);
|
|
842
|
+
if (!product) {
|
|
843
|
+
return answer(
|
|
844
|
+
`No product with reference "${externalRef}" from "${source}".`,
|
|
845
|
+
null
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
const refused = product.files.filter(
|
|
849
|
+
(file) => file.status === "refused"
|
|
850
|
+
);
|
|
851
|
+
const pending = product.files.filter(
|
|
852
|
+
(file) => file.status === "pending"
|
|
853
|
+
);
|
|
854
|
+
return answer(
|
|
855
|
+
`${product.name} (${product.slug || "no address"}), ${product.status} (${product.availability}); ${Object.keys(product.fields).length} fields, ${product.categories.length} categories, ${product.images.length} pictures; files: ${pending.length} pending, ${refused.length} refused${refused.length ? ` (${refused.map((file) => `${file.ref}: ${file.reason}`).join("; ")})` : ""}.`,
|
|
856
|
+
product
|
|
857
|
+
);
|
|
858
|
+
} catch (error) {
|
|
859
|
+
return refusal(error, "catalog:write");
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
);
|
|
863
|
+
server.registerTool(
|
|
864
|
+
"upsert_products",
|
|
865
|
+
{
|
|
866
|
+
title: "Make or change many products",
|
|
867
|
+
description: `Up to ${BULK_LIMIT} products, one after another, each exactly as upsert_product takes it (\`externalRef\` beside its body). A refusal is that product\u2019s outcome and the others go on; a refusal for speed is waited out and the product sent again; a key that is refused stops the run and the rest is \`skipped\`. Answers per product \`created\`, \`changed\`, \`refused\` (with the fields) or \`skipped\`. ` + LIMITS,
|
|
868
|
+
inputSchema: {
|
|
869
|
+
products: z4.array(
|
|
870
|
+
z4.object({
|
|
871
|
+
externalRef: CatalogExternalRefSchema,
|
|
872
|
+
product: CatalogProductUpsertSchema
|
|
873
|
+
})
|
|
874
|
+
).min(1).max(BULK_LIMIT),
|
|
875
|
+
...objectInput
|
|
876
|
+
},
|
|
877
|
+
annotations: { destructiveHint: true, idempotentHint: true }
|
|
878
|
+
},
|
|
879
|
+
async ({ products, object: object2 }) => {
|
|
880
|
+
try {
|
|
881
|
+
const outcomes = await client.upsertProducts(
|
|
882
|
+
products.map((item) => ({
|
|
883
|
+
externalRef: item.externalRef,
|
|
884
|
+
input: item.product
|
|
885
|
+
})),
|
|
886
|
+
target(object2)
|
|
887
|
+
);
|
|
888
|
+
const count = (kind) => outcomes.filter((outcome) => outcome.outcome === kind).length;
|
|
889
|
+
const refusals = outcomes.filter((outcome) => outcome.outcome === "refused").map(
|
|
890
|
+
(outcome) => `- ${outcome.externalRef}: ${outcome.error?.message ?? "refused"}` + (outcome.error?.fields.length ? ` (${outcome.error.fields.map((field) => `${field.path}: ${field.message}`).join("; ")})` : "")
|
|
891
|
+
);
|
|
892
|
+
return answer(
|
|
893
|
+
[
|
|
894
|
+
`${count("created")} made, ${count("changed")} changed, ${count("refused")} refused, ${count("skipped")} skipped.`,
|
|
895
|
+
...refusals
|
|
896
|
+
].join("\n"),
|
|
897
|
+
outcomes
|
|
898
|
+
);
|
|
899
|
+
} catch (error) {
|
|
900
|
+
return refusal(error, "catalog:write");
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
);
|
|
904
|
+
server.registerTool(
|
|
905
|
+
"delete_field",
|
|
906
|
+
{
|
|
907
|
+
title: "Take a catalogue field away",
|
|
908
|
+
description: "Moves a field to the trash, as the app does: for thirty days it can come back with restore_field, and every product keeps its value meanwhile. Use `permanent` only when the user explicitly asks to delete for good, and first without `confirm`: that only counts the products that would lose their value. Show the user that count; send `confirm` (the slug again) only after they agree. A field of the app\u2019s own, or one a product varies along, is refused.",
|
|
909
|
+
inputSchema: {
|
|
910
|
+
slug: CatalogFieldSlugSchema,
|
|
911
|
+
permanent: z4.boolean().optional(),
|
|
912
|
+
confirm: z4.string().max(63).optional(),
|
|
913
|
+
...objectInput
|
|
914
|
+
},
|
|
915
|
+
annotations: { destructiveHint: true, idempotentHint: true }
|
|
916
|
+
},
|
|
917
|
+
async ({ slug, permanent, confirm, object: object2 }) => {
|
|
918
|
+
try {
|
|
919
|
+
const result = await client.removeField(slug, {
|
|
920
|
+
...target(object2),
|
|
921
|
+
...permanent ? { permanent } : {},
|
|
922
|
+
...confirm ? { confirm } : {}
|
|
923
|
+
});
|
|
924
|
+
const text2 = result.state === "trashed" ? `${slug} is in the trash until ${result.restorableUntil ?? "thirty days from now"}; ${result.productsWithValue} products keep their value.` : result.state === "would_delete" ? `Nothing changed. Deleting ${slug} for good would clear its value on ${result.productsWithValue} products. ${result.confirm}` : `${slug} is deleted; its value on ${result.productsWithValue} products is being cleared.`;
|
|
925
|
+
return answer(text2, result);
|
|
926
|
+
} catch (error) {
|
|
927
|
+
return refusal(error, "catalog:write");
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
);
|
|
931
|
+
server.registerTool(
|
|
932
|
+
"restore_field",
|
|
933
|
+
{
|
|
934
|
+
title: "Bring a catalogue field back from the trash",
|
|
935
|
+
description: "A field moved to the trash in the last thirty days comes back under its own id, with every value the products kept.",
|
|
936
|
+
inputSchema: { slug: CatalogFieldSlugSchema, ...objectInput },
|
|
937
|
+
annotations: { destructiveHint: false, idempotentHint: true }
|
|
938
|
+
},
|
|
939
|
+
async ({ slug, object: object2 }) => {
|
|
940
|
+
try {
|
|
941
|
+
const field = await client.restoreField(slug, target(object2));
|
|
942
|
+
return answer(`${field.label} (${field.slug}) is back.`, field);
|
|
943
|
+
} catch (error) {
|
|
944
|
+
return refusal(error, "catalog:write");
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
);
|
|
948
|
+
server.registerTool(
|
|
949
|
+
"get_category_by_ref",
|
|
950
|
+
{
|
|
951
|
+
title: "Find a category by its reference",
|
|
952
|
+
description: 'The category upsert_category recorded with this `source` and `externalRef` -- the id of a series in your own system -- also after its address was changed in the app. A published category unless you pass `status`: "draft" finds a concept or scheduled one, "all" any (needs catalog:write).',
|
|
953
|
+
inputSchema: {
|
|
954
|
+
source: CatalogSourceSchema,
|
|
955
|
+
externalRef: CatalogExternalRefSchema,
|
|
956
|
+
status: z4.enum(["published", "draft", "all"]).optional().describe(
|
|
957
|
+
"Which categories to look in; anything but published needs catalog:write."
|
|
958
|
+
)
|
|
959
|
+
},
|
|
960
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
961
|
+
},
|
|
962
|
+
async ({ source, externalRef, status }) => {
|
|
963
|
+
try {
|
|
964
|
+
const category = await client.getCategoryByRef(
|
|
965
|
+
source,
|
|
966
|
+
externalRef,
|
|
967
|
+
status ? { status } : void 0
|
|
968
|
+
);
|
|
969
|
+
return answer(
|
|
970
|
+
category ? `${category.name} (${category.slug || "no address yet"}), id ${category.id}${category.status ? `, ${category.status}` : ""}.` : `No ${status && status !== "published" ? status : "published"} category with reference "${externalRef}" from "${source}"${status ? "" : ' (a concept category needs status "all")'}.`,
|
|
971
|
+
category
|
|
972
|
+
);
|
|
973
|
+
} catch (error) {
|
|
974
|
+
return refusal(error, "catalog:read");
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
);
|
|
978
|
+
server.registerTool(
|
|
979
|
+
"upload_catalog_file",
|
|
980
|
+
{
|
|
981
|
+
title: "Upload a picture or a file from disk",
|
|
982
|
+
description: 'Reads one file from the user\u2019s disk and uploads it, for a product write to name as { "upload": "<uploadPath>" } instead of a url: a picture (png, jpeg, webp, at most 2 MB) for `images`, `socialImage` or a list item\u2019s image, or a "file" (pdf, Office, txt, csv, zip, at most 20 MB) for a list item\u2019s download. An upload is kept a day.',
|
|
983
|
+
inputSchema: {
|
|
984
|
+
kind: z4.enum(["image", "file"]),
|
|
985
|
+
filePath: z4.string().min(1).max(1e3).describe("The absolute path of the file on this machine.")
|
|
986
|
+
},
|
|
987
|
+
annotations: { destructiveHint: false, idempotentHint: false }
|
|
988
|
+
},
|
|
989
|
+
async ({ kind, filePath }) => {
|
|
990
|
+
const rules = FILE_RULES[kind];
|
|
991
|
+
const extension = extname(filePath).toLowerCase();
|
|
992
|
+
const contentType = rules.types[extension];
|
|
993
|
+
if (!contentType) {
|
|
994
|
+
return notDone(
|
|
995
|
+
`A ${kind} is a ${Object.keys(rules.types).join(", ")} file; this is "${extension || "no extension"}".`
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
const info = await stat(filePath).catch(() => null);
|
|
999
|
+
if (!info?.isFile()) {
|
|
1000
|
+
return notDone(`"${filePath}" is not a file on this machine.`);
|
|
1001
|
+
}
|
|
1002
|
+
if (info.size > rules.maxBytes) {
|
|
1003
|
+
return notDone(
|
|
1004
|
+
`The file is ${info.size} bytes; a ${kind} may be at most ${rules.maxBytes}.`
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
try {
|
|
1008
|
+
const result = await client.uploadCatalogFile({
|
|
1009
|
+
kind,
|
|
1010
|
+
bytes: new Uint8Array(await readFile(filePath)),
|
|
1011
|
+
contentType
|
|
1012
|
+
});
|
|
1013
|
+
return answer(
|
|
1014
|
+
`Uploaded. Name it as { "upload": "${result.uploadPath}" }.`,
|
|
1015
|
+
result
|
|
1016
|
+
);
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
return refusal(error, "catalog:write");
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/catalog-write-tools.ts
|
|
1025
|
+
import { CrmApiError as CrmApiError2 } from "@solumflow-app/crm-client";
|
|
1026
|
+
function usedIn(details) {
|
|
1027
|
+
const blockers = details?.blockers;
|
|
1028
|
+
if (!Array.isArray(blockers) || blockers.length === 0) {
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
return blockers.map(
|
|
1032
|
+
(entry) => `${String(entry.kind).replaceAll("_", " ")} (${String(entry.count)})`
|
|
1033
|
+
).join(", ");
|
|
1034
|
+
}
|
|
1035
|
+
function target2(object2) {
|
|
1036
|
+
return object2 ? { object: object2 } : void 0;
|
|
1037
|
+
}
|
|
1038
|
+
var FIELD_KINDS = 'Kinds: text, number, select, checkbox, color (a value "#rrggbb"; upper case is stored lower case), url, date, and three content blocks for a product page: "content_text" (formatted text, no config), "cta" (a call to action; config { secondary: true } offers a second button) and "repeater" (a list, an accordion such as a FAQ, downloads or testimonials; config { display: "list" | "accordion", preset?: "list" | "faq" | "downloads" | "testimonials", itemFields: [{ key, label, type: text | richtext | url | icon | image | file | number | select }], titleKey?: the item field that names an item }). A number item may name { min?, max?, suffix? }, a select item names { options: [{ value, label }] } (at most 100); its value is one of those values. An image or file item is filled with { url } or { upload } in upsert_product, fetched after the answer. A number may take config { prefix?, suffix? }. A configurator is made with upsert_configurator. The kind is fixed once the field exists, and so is the type of a repeater\u2019s item field.';
|
|
566
1039
|
var IDENTITY = 'A product is known by `externalRef`, its id in the system it comes from, together with `source`, the name of that system (for example "acme-webshop"). The same pair always means the same product: sending it again changes it instead of making a second one, so use references that stay the same, never a name that may change.';
|
|
567
1040
|
function registerCatalogWriteTools(server, client) {
|
|
568
1041
|
server.registerTool(
|
|
569
1042
|
"upsert_product",
|
|
570
1043
|
{
|
|
571
1044
|
title: "Make or change a product",
|
|
572
|
-
description: IDENTITY + ' Every field but `source` may be left out, and left out means "leave it as it is"; null empties a field. A new product needs a `name` and is a draft unless `availability` says otherwise: do not publish without the user asking. `prices`, `categories`, `images` and `variants` each replace what the product had as a whole, and a variant left out is archived. Categories are named by slug and a missing one is refused unless `createMissingCategories` is true; a choice a select field does not offer is refused unless `addMissingOptions` is true. Pictures are https URLs, fetched after the answer (`images: "pending"`). Amounts are in cents. Read list_catalog_fields first for the slugs `fields` takes.',
|
|
1045
|
+
description: IDENTITY + ' Every field but `source` may be left out, and left out means "leave it as it is"; null empties a field. A new product needs a `name` and is a draft unless `availability` says otherwise: do not publish without the user asking. `prices`, `categories`, `images` and `variants` each replace what the product had as a whole, and a variant left out is archived. Categories are named by slug and a missing one is refused unless `createMissingCategories` is true; a choice a select field does not offer is refused unless `addMissingOptions` is true. Pictures are https URLs, fetched after the answer (`images: "pending"`). Amounts are in cents. A price without `currency` is in the product\'s own; one with another `currency` is the fixed price for that currency, may share its label with the euro price, and can never be the default. `stock` (physical only; per variant when there are variants) is a count, not a movement: a second run writes it again over a correction made in the app, so send it only when the source keeps the count. A picture is { url } or { upload } (upload_catalog_file), with `alt`, a `role` in your own words ("insitu") and flat `details` ({ widthCm: 120 }); `socialImage` is the shared-link picture beside `socialTitle` and `socialDescription`. Files arrive after the answer: get_product_by_ref says per file whether it came. `description` is plain text (5,000 characters); `longDescription` is HTML, cleaned to headings, paragraphs, lists, links, bold and italic. A repeater is sent whole: `items` replaces the list. An item may leave out its `id` -- SolumFlow makes one, and `fields` in the answer holds it; send the id back to change that item, or the items are replaced by new ones and their files fetched again. An image or file item key is { url: "https://..." } (fetched after the answer) or { upload }; a wrong one is refused with the path of the item. To write many products use upsert_products. Read list_catalog_fields first for the slugs `fields` takes.',
|
|
573
1046
|
inputSchema: {
|
|
574
1047
|
externalRef: CatalogExternalRefSchema.describe(
|
|
575
1048
|
"The id of the product in the system named by `source`."
|
|
@@ -584,7 +1057,7 @@ function registerCatalogWriteTools(server, client) {
|
|
|
584
1057
|
const product = await client.upsertProduct(
|
|
585
1058
|
externalRef,
|
|
586
1059
|
input,
|
|
587
|
-
|
|
1060
|
+
target2(object2)
|
|
588
1061
|
);
|
|
589
1062
|
const verb = product.created ? "Made" : "Changed";
|
|
590
1063
|
return answer(
|
|
@@ -615,7 +1088,7 @@ function registerCatalogWriteTools(server, client) {
|
|
|
615
1088
|
const result = await client.archiveProduct(
|
|
616
1089
|
externalRef,
|
|
617
1090
|
source,
|
|
618
|
-
|
|
1091
|
+
target2(object2)
|
|
619
1092
|
);
|
|
620
1093
|
return answer(
|
|
621
1094
|
result.alreadyArchived ? "That product was already archived." : "Archived.",
|
|
@@ -626,11 +1099,54 @@ function registerCatalogWriteTools(server, client) {
|
|
|
626
1099
|
}
|
|
627
1100
|
}
|
|
628
1101
|
);
|
|
1102
|
+
server.registerTool(
|
|
1103
|
+
"delete_product",
|
|
1104
|
+
{
|
|
1105
|
+
title: "Delete a product for good",
|
|
1106
|
+
description: IDENTITY + " Deleting removes the product, its variants, prices and pictures for good; it cannot be undone. It only works for a product that nothing uses: one that is on an order, a payment page, a subscription, a booking or a hosting service is refused, and the answer says where. Then archive_product is the way out. Archive when in doubt, and ask the user before deleting anything they did not name.",
|
|
1107
|
+
inputSchema: {
|
|
1108
|
+
externalRef: CatalogExternalRefSchema.describe(
|
|
1109
|
+
"The id of the product in the system named by `source`."
|
|
1110
|
+
),
|
|
1111
|
+
...CatalogProductArchiveSchema.shape,
|
|
1112
|
+
...objectInput
|
|
1113
|
+
},
|
|
1114
|
+
annotations: { destructiveHint: true, idempotentHint: true }
|
|
1115
|
+
},
|
|
1116
|
+
async ({ externalRef, source, object: object2 }) => {
|
|
1117
|
+
try {
|
|
1118
|
+
const result = await client.deleteProduct(
|
|
1119
|
+
externalRef,
|
|
1120
|
+
source,
|
|
1121
|
+
target2(object2)
|
|
1122
|
+
);
|
|
1123
|
+
return answer("Deleted for good.", result);
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
if (error instanceof CrmApiError2 && error.code === "conflict") {
|
|
1126
|
+
const places = usedIn(error.details);
|
|
1127
|
+
if (places) {
|
|
1128
|
+
return {
|
|
1129
|
+
isError: true,
|
|
1130
|
+
content: [
|
|
1131
|
+
{
|
|
1132
|
+
type: "text",
|
|
1133
|
+
text: `conflict: ${error.message}
|
|
1134
|
+
Used in: ${places}.
|
|
1135
|
+
Archive it with archive_product instead.`
|
|
1136
|
+
}
|
|
1137
|
+
]
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
return refusal(error, "catalog:write");
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
);
|
|
629
1145
|
server.registerTool(
|
|
630
1146
|
"upsert_category",
|
|
631
1147
|
{
|
|
632
1148
|
title: "Make or change a category",
|
|
633
|
-
description: "A category, known by its address (slug): sending the same slug again changes it. Left-out fields stay as they are; a new category needs a `name`. `parentSlug` places it under another category, null at the top. A new category is a draft unless `availability` says otherwise. Categories belong to one catalogue: a product names only categories of its own catalogue.",
|
|
1149
|
+
description: "A category, known by its address (slug): sending the same slug again changes it. Left-out fields stay as they are; a new category needs a `name`. `parentSlug` places it under another category, null at the top. A new category is a draft unless `availability` says otherwise. Categories belong to one catalogue: a product names only categories of its own catalogue. Send `source` and `externalRef` -- the id of the series in your own system -- and the category keeps that identity: it is found by it even after its address was changed in the app (and gets the address of this call back), and list_products narrows by it. Content of a series (long texts, galleries) stays in your own CMS.",
|
|
634
1150
|
inputSchema: {
|
|
635
1151
|
slug: CatalogWebSlugSchema.describe(
|
|
636
1152
|
"The address of the category: lower case, digits and dashes."
|
|
@@ -645,7 +1161,7 @@ function registerCatalogWriteTools(server, client) {
|
|
|
645
1161
|
const category = await client.upsertCategory(
|
|
646
1162
|
slug,
|
|
647
1163
|
input,
|
|
648
|
-
|
|
1164
|
+
target2(object2)
|
|
649
1165
|
);
|
|
650
1166
|
return answer(
|
|
651
1167
|
`${category.created ? "Made" : "Changed"} ${category.name} (${category.slug}).`,
|
|
@@ -660,7 +1176,7 @@ function registerCatalogWriteTools(server, client) {
|
|
|
660
1176
|
"upsert_field",
|
|
661
1177
|
{
|
|
662
1178
|
title: "Make or change a catalogue field",
|
|
663
|
-
description: "A custom field on products, known by its slug: sending the same slug again changes it. A new field needs a `label`. " + FIELD_KINDS + " `config` replaces the settings as a whole. `options` makes sure choices exist and never removes one; `removeOptions` takes choices off the list, which empties them on every product that had them. `public` decides whether the website may show the field at all.",
|
|
1179
|
+
description: "A custom field on products, known by its slug: sending the same slug again changes it. A new field needs a `label`. " + FIELD_KINDS + " `config` replaces the settings as a whole. `options` makes sure choices exist and never removes one; `removeOptions` takes choices off the list, which empties them on every product that had them. `public` decides whether the website may show the field at all. A new content field (content_text, repeater, cta) gets its block at the end of the product page in the same save, and the answer says `placedOnPage`; `placeOnPage: false` makes the field only. An existing field is never placed again, so a block somebody took off the page stays off. `dryRun: true` answers what would change -- and how many products hold each choice that would go -- and writes nothing. A slug in the trash is taken until restore_field or a permanent delete_field.",
|
|
664
1180
|
inputSchema: {
|
|
665
1181
|
slug: CatalogFieldSlugSchema.describe(
|
|
666
1182
|
"The slug of the field: a lower-case letter, then lower case, digits and underscores."
|
|
@@ -672,9 +1188,14 @@ function registerCatalogWriteTools(server, client) {
|
|
|
672
1188
|
},
|
|
673
1189
|
async ({ slug, object: object2, ...input }) => {
|
|
674
1190
|
try {
|
|
675
|
-
|
|
1191
|
+
if (input.dryRun) {
|
|
1192
|
+
const { dryRun: _dryRun, ...rest } = input;
|
|
1193
|
+
const preview = await client.previewField(slug, rest, target2(object2));
|
|
1194
|
+
return answer(`Nothing written. ${JSON.stringify(preview)}`, preview);
|
|
1195
|
+
}
|
|
1196
|
+
const field = await client.upsertField(slug, input, target2(object2));
|
|
676
1197
|
return answer(
|
|
677
|
-
`${field.created ? "Made" : "Changed"} ${field.label} (${field.slug}), ${field.type}, ${field.public ? "public" : "not public"}
|
|
1198
|
+
`${field.created ? "Made" : "Changed"} ${field.label} (${field.slug}), ${field.type}, ${field.public ? "public" : "not public"}.` + (field.placedOnPage === void 0 ? "" : field.placedOnPage ? " Its block is at the end of the product page." : ` Its block is not on the product page (${field.notPlacedBecause ?? "not placed"}).`),
|
|
678
1199
|
field
|
|
679
1200
|
);
|
|
680
1201
|
} catch (error) {
|
|
@@ -710,7 +1231,7 @@ function registerCatalogWriteTools(server, client) {
|
|
|
710
1231
|
}
|
|
711
1232
|
|
|
712
1233
|
// src/configurator-tools.ts
|
|
713
|
-
import { z as
|
|
1234
|
+
import { z as z8 } from "zod";
|
|
714
1235
|
|
|
715
1236
|
// ../features/product-configurator/src/shared/configurator-limits.ts
|
|
716
1237
|
var CONFIGURATOR_LIMITS = {
|
|
@@ -1076,16 +1597,16 @@ function evaluateFormulaSource(source, scope) {
|
|
|
1076
1597
|
}
|
|
1077
1598
|
|
|
1078
1599
|
// ../features/product-configurator/src/shared/geometry-document.schema.ts
|
|
1079
|
-
import { z as
|
|
1600
|
+
import { z as z5 } from "zod";
|
|
1080
1601
|
var CONFIGURATOR_NAME_PATTERN = /^[a-z][a-z0-9_]{0,39}$/;
|
|
1081
1602
|
var CONFIGURATOR_CODE_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/;
|
|
1082
|
-
var ConfiguratorNameSchema =
|
|
1083
|
-
var ConfiguratorCodeSchema =
|
|
1084
|
-
var FormulaSchema =
|
|
1085
|
-
|
|
1086
|
-
|
|
1603
|
+
var ConfiguratorNameSchema = z5.string().regex(CONFIGURATOR_NAME_PATTERN, "configurator.invalidName");
|
|
1604
|
+
var ConfiguratorCodeSchema = z5.string().regex(CONFIGURATOR_CODE_PATTERN, "configurator.invalidCode");
|
|
1605
|
+
var FormulaSchema = z5.union([
|
|
1606
|
+
z5.number().finite(),
|
|
1607
|
+
z5.string().trim().min(1).max(CONFIGURATOR_LIMITS.formulaChars, "configurator.formulaTooLong")
|
|
1087
1608
|
]);
|
|
1088
|
-
var Vector3Schema =
|
|
1609
|
+
var Vector3Schema = z5.tuple([FormulaSchema, FormulaSchema, FormulaSchema]);
|
|
1089
1610
|
var PRIMITIVE_TYPES = [
|
|
1090
1611
|
"box",
|
|
1091
1612
|
"rounded_top_box",
|
|
@@ -1128,93 +1649,93 @@ var PrimitiveNodeFields = {
|
|
|
1128
1649
|
size: Vector3Schema,
|
|
1129
1650
|
at: Vector3Schema,
|
|
1130
1651
|
visible: FormulaSchema.optional(),
|
|
1131
|
-
params:
|
|
1652
|
+
params: z5.record(ConfiguratorNameSchema, FormulaSchema).optional()
|
|
1132
1653
|
};
|
|
1133
|
-
var PrimitiveNodeSchema =
|
|
1654
|
+
var PrimitiveNodeSchema = z5.strictObject({
|
|
1134
1655
|
...PrimitiveNodeFields,
|
|
1135
|
-
type:
|
|
1656
|
+
type: z5.enum(PRIMITIVE_TYPES).exclude(["model"])
|
|
1136
1657
|
});
|
|
1137
|
-
var ModelPathSchema =
|
|
1138
|
-
var ServedFileUrlSchema =
|
|
1658
|
+
var ModelPathSchema = z5.string().regex(CONFIGURATOR_MODEL_PATH_PATTERN, "configurator.invalidModelPath");
|
|
1659
|
+
var ServedFileUrlSchema = z5.url({ protocol: /^https?$/ });
|
|
1139
1660
|
function geometryNodeSchema(modelFile) {
|
|
1140
|
-
const ModelNodeSchema =
|
|
1661
|
+
const ModelNodeSchema = z5.strictObject({
|
|
1141
1662
|
...PrimitiveNodeFields,
|
|
1142
|
-
type:
|
|
1663
|
+
type: z5.literal("model"),
|
|
1143
1664
|
model: modelFile
|
|
1144
1665
|
});
|
|
1145
|
-
const ModelPerChoiceNodeSchema =
|
|
1666
|
+
const ModelPerChoiceNodeSchema = z5.strictObject({
|
|
1146
1667
|
...PrimitiveNodeFields,
|
|
1147
|
-
type:
|
|
1668
|
+
type: z5.literal("model"),
|
|
1148
1669
|
follows: ConfiguratorNameSchema,
|
|
1149
|
-
models:
|
|
1670
|
+
models: z5.record(ConfiguratorCodeSchema, modelFile).refine(
|
|
1150
1671
|
(models) => Object.keys(models).length <= CONFIGURATOR_LIMITS.presets,
|
|
1151
1672
|
{ message: "configurator.tooMany" }
|
|
1152
1673
|
)
|
|
1153
1674
|
});
|
|
1154
|
-
const node =
|
|
1155
|
-
() =>
|
|
1675
|
+
const node = z5.lazy(
|
|
1676
|
+
() => z5.union([
|
|
1156
1677
|
PrimitiveNodeSchema,
|
|
1157
1678
|
ModelNodeSchema,
|
|
1158
1679
|
ModelPerChoiceNodeSchema,
|
|
1159
|
-
|
|
1680
|
+
z5.strictObject({
|
|
1160
1681
|
id: ConfiguratorNameSchema,
|
|
1161
|
-
type:
|
|
1682
|
+
type: z5.literal("repeat"),
|
|
1162
1683
|
count: FormulaSchema,
|
|
1163
|
-
index:
|
|
1684
|
+
index: z5.enum(["i", "j"]).optional(),
|
|
1164
1685
|
visible: FormulaSchema.optional(),
|
|
1165
|
-
children:
|
|
1686
|
+
children: z5.array(node).min(1)
|
|
1166
1687
|
})
|
|
1167
1688
|
])
|
|
1168
1689
|
);
|
|
1169
1690
|
return node;
|
|
1170
1691
|
}
|
|
1171
1692
|
var GeometryNodeSchema = geometryNodeSchema(ModelPathSchema);
|
|
1172
|
-
var GeometryBindingSchema =
|
|
1693
|
+
var GeometryBindingSchema = z5.strictObject({
|
|
1173
1694
|
param: ConfiguratorNameSchema,
|
|
1174
|
-
map:
|
|
1695
|
+
map: z5.record(ConfiguratorCodeSchema, z5.number().finite()).optional()
|
|
1175
1696
|
});
|
|
1176
|
-
var GeometryInputSchema =
|
|
1697
|
+
var GeometryInputSchema = z5.strictObject({
|
|
1177
1698
|
name: ConfiguratorNameSchema,
|
|
1178
1699
|
binding: GeometryBindingSchema
|
|
1179
1700
|
});
|
|
1180
|
-
var GeometrySlotSchema =
|
|
1701
|
+
var GeometrySlotSchema = z5.strictObject({
|
|
1181
1702
|
name: ConfiguratorNameSchema,
|
|
1182
1703
|
param: ConfiguratorNameSchema.optional(),
|
|
1183
1704
|
material: ConfiguratorCodeSchema.optional(),
|
|
1184
|
-
color:
|
|
1705
|
+
color: z5.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor").optional(),
|
|
1185
1706
|
/**
|
|
1186
1707
|
* Which way the grain of a picture runs on this part: `horizontal` (the
|
|
1187
1708
|
* default) as the picture is drawn, `vertical` turned a quarter. It belongs
|
|
1188
1709
|
* to the part, not the material: the same oak lies on a cabinet and stands
|
|
1189
1710
|
* on a tall wall panel.
|
|
1190
1711
|
*/
|
|
1191
|
-
grain:
|
|
1712
|
+
grain: z5.enum(["horizontal", "vertical"]).optional()
|
|
1192
1713
|
});
|
|
1193
|
-
var GeometryAnnotationSchema =
|
|
1714
|
+
var GeometryAnnotationSchema = z5.strictObject({
|
|
1194
1715
|
id: ConfiguratorNameSchema,
|
|
1195
|
-
axis:
|
|
1716
|
+
axis: z5.enum(["x", "y"]),
|
|
1196
1717
|
from: FormulaSchema,
|
|
1197
1718
|
to: FormulaSchema,
|
|
1198
1719
|
at: FormulaSchema,
|
|
1199
|
-
label:
|
|
1720
|
+
label: z5.string().trim().min(1).max(80).optional()
|
|
1200
1721
|
});
|
|
1201
1722
|
function geometryDocumentSchema(modelFile) {
|
|
1202
|
-
return
|
|
1723
|
+
return z5.strictObject({
|
|
1203
1724
|
/** Rises with every new upload, so a product page can say which one it shows. */
|
|
1204
|
-
version:
|
|
1725
|
+
version: z5.number().int().min(1),
|
|
1205
1726
|
/** The starter document this one was copied from, for information only. */
|
|
1206
|
-
startedFrom:
|
|
1207
|
-
inputs:
|
|
1208
|
-
constants:
|
|
1209
|
-
slots:
|
|
1210
|
-
root:
|
|
1211
|
-
camera:
|
|
1727
|
+
startedFrom: z5.string().trim().max(80).optional(),
|
|
1728
|
+
inputs: z5.array(GeometryInputSchema).max(CONFIGURATOR_LIMITS.geometryInputs),
|
|
1729
|
+
constants: z5.record(ConfiguratorNameSchema, z5.number().finite()),
|
|
1730
|
+
slots: z5.array(GeometrySlotSchema).min(1).max(CONFIGURATOR_LIMITS.geometrySlots),
|
|
1731
|
+
root: z5.array(geometryNodeSchema(modelFile)).min(1),
|
|
1732
|
+
camera: z5.strictObject({
|
|
1212
1733
|
/** Degrees around the vertical axis, 0 is straight on. */
|
|
1213
|
-
azimuth:
|
|
1734
|
+
azimuth: z5.number().min(-90).max(90),
|
|
1214
1735
|
/** Degrees above the horizon. */
|
|
1215
|
-
elevation:
|
|
1736
|
+
elevation: z5.number().min(-10).max(80)
|
|
1216
1737
|
}).optional(),
|
|
1217
|
-
annotations:
|
|
1738
|
+
annotations: z5.array(GeometryAnnotationSchema).max(CONFIGURATOR_LIMITS.annotations)
|
|
1218
1739
|
});
|
|
1219
1740
|
}
|
|
1220
1741
|
var GeometryDocumentSchema = geometryDocumentSchema(ModelPathSchema);
|
|
@@ -1504,98 +2025,98 @@ function followProblems(follows, path, context) {
|
|
|
1504
2025
|
}
|
|
1505
2026
|
|
|
1506
2027
|
// ../features/product-configurator/src/shared/configurator-structure.schema.ts
|
|
1507
|
-
import { z as
|
|
1508
|
-
var LabelSchema =
|
|
2028
|
+
import { z as z6 } from "zod";
|
|
2029
|
+
var LabelSchema = z6.string().trim().min(1).max(80);
|
|
1509
2030
|
var CONFIGURATOR_TEXTURE_PATH_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(webp|jpg|jpeg)$/;
|
|
1510
|
-
var TexturePathSchema =
|
|
2031
|
+
var TexturePathSchema = z6.string().regex(CONFIGURATOR_TEXTURE_PATH_PATTERN, "configurator.invalidTexturePath");
|
|
1511
2032
|
var ParameterBase = {
|
|
1512
2033
|
key: ConfiguratorNameSchema,
|
|
1513
2034
|
label: LabelSchema,
|
|
1514
2035
|
tab: ConfiguratorNameSchema
|
|
1515
2036
|
};
|
|
1516
|
-
var ConfiguratorParameterSchema =
|
|
1517
|
-
|
|
2037
|
+
var ConfiguratorParameterSchema = z6.discriminatedUnion("kind", [
|
|
2038
|
+
z6.strictObject({
|
|
1518
2039
|
...ParameterBase,
|
|
1519
|
-
kind:
|
|
2040
|
+
kind: z6.literal("number"),
|
|
1520
2041
|
/** `mm` is a measure, `count` a number of things (compartments). */
|
|
1521
|
-
unit:
|
|
1522
|
-
step:
|
|
2042
|
+
unit: z6.enum(["mm", "count"]),
|
|
2043
|
+
step: z6.number().positive().finite(),
|
|
1523
2044
|
/** The widest range any model may offer; a model narrows it in its value. */
|
|
1524
|
-
min:
|
|
1525
|
-
max:
|
|
2045
|
+
min: z6.number().finite(),
|
|
2046
|
+
max: z6.number().finite()
|
|
1526
2047
|
}),
|
|
1527
|
-
|
|
2048
|
+
z6.strictObject({
|
|
1528
2049
|
...ParameterBase,
|
|
1529
|
-
kind:
|
|
2050
|
+
kind: z6.literal("choice"),
|
|
1530
2051
|
list: ConfiguratorNameSchema
|
|
1531
2052
|
}),
|
|
1532
|
-
|
|
1533
|
-
|
|
2053
|
+
z6.strictObject({ ...ParameterBase, kind: z6.literal("material") }),
|
|
2054
|
+
z6.strictObject({ ...ParameterBase, kind: z6.literal("toggle") })
|
|
1534
2055
|
]);
|
|
1535
|
-
var choiceListSchema = (picture) =>
|
|
2056
|
+
var choiceListSchema = (picture) => z6.strictObject({
|
|
1536
2057
|
key: ConfiguratorNameSchema,
|
|
1537
2058
|
label: LabelSchema,
|
|
1538
|
-
options:
|
|
1539
|
-
|
|
2059
|
+
options: z6.array(
|
|
2060
|
+
z6.strictObject({
|
|
1540
2061
|
code: ConfiguratorCodeSchema,
|
|
1541
2062
|
label: LabelSchema,
|
|
1542
2063
|
image: picture.optional()
|
|
1543
2064
|
})
|
|
1544
2065
|
).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
|
|
1545
2066
|
});
|
|
1546
|
-
var materialSchema = (picture) =>
|
|
2067
|
+
var materialSchema = (picture) => z6.strictObject({
|
|
1547
2068
|
code: ConfiguratorCodeSchema,
|
|
1548
2069
|
label: LabelSchema,
|
|
1549
|
-
color:
|
|
2070
|
+
color: z6.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor"),
|
|
1550
2071
|
texture: picture.optional(),
|
|
1551
2072
|
/** Millimetres one repeat of the texture covers; the two maps below share it. */
|
|
1552
|
-
textureSize:
|
|
2073
|
+
textureSize: z6.number().positive().max(1e4).optional(),
|
|
1553
2074
|
/** Relief that moves with the light: a tangent-space normal map. */
|
|
1554
2075
|
normalMap: picture.optional(),
|
|
1555
2076
|
/** How deep the relief reads; 1 is the map as drawn. */
|
|
1556
|
-
normalScale:
|
|
2077
|
+
normalScale: z6.number().min(0).max(3).optional(),
|
|
1557
2078
|
/** Where the surface is matte and where it shines (the green channel), times `roughness`. */
|
|
1558
2079
|
roughnessMap: picture.optional(),
|
|
1559
|
-
roughness:
|
|
1560
|
-
metalness:
|
|
2080
|
+
roughness: z6.number().min(0).max(1).optional(),
|
|
2081
|
+
metalness: z6.number().min(0).max(1).optional(),
|
|
1561
2082
|
/** How much of what is behind it shows through: glass in front of a flame. Absent is solid. */
|
|
1562
|
-
opacity:
|
|
2083
|
+
opacity: z6.number().min(0.05).max(1).optional(),
|
|
1563
2084
|
/** Gives light rather than reflecting it: a flame, a LED. */
|
|
1564
|
-
emissive:
|
|
2085
|
+
emissive: z6.boolean().optional(),
|
|
1565
2086
|
priceGroup: ConfiguratorCodeSchema.optional()
|
|
1566
2087
|
});
|
|
1567
2088
|
var ConfiguratorChoiceListSchema = choiceListSchema(TexturePathSchema);
|
|
1568
2089
|
var ConfiguratorMaterialSchema = materialSchema(TexturePathSchema);
|
|
1569
|
-
var ConditionValueSchema =
|
|
1570
|
-
|
|
1571
|
-
|
|
2090
|
+
var ConditionValueSchema = z6.union([
|
|
2091
|
+
z6.number().finite(),
|
|
2092
|
+
z6.boolean(),
|
|
1572
2093
|
ConfiguratorCodeSchema,
|
|
1573
|
-
|
|
2094
|
+
z6.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
|
|
1574
2095
|
]);
|
|
1575
|
-
var ConfiguratorConditionSchema =
|
|
2096
|
+
var ConfiguratorConditionSchema = z6.strictObject({
|
|
1576
2097
|
param: ConfiguratorNameSchema,
|
|
1577
|
-
op:
|
|
2098
|
+
op: z6.enum(["eq", "neq", "in", "gte", "lte"]),
|
|
1578
2099
|
value: ConditionValueSchema
|
|
1579
2100
|
});
|
|
1580
|
-
var ConfiguratorRuleSchema =
|
|
2101
|
+
var ConfiguratorRuleSchema = z6.discriminatedUnion("kind", [
|
|
1581
2102
|
/** Show `param` only while `when` holds. */
|
|
1582
|
-
|
|
1583
|
-
kind:
|
|
2103
|
+
z6.strictObject({
|
|
2104
|
+
kind: z6.literal("visible"),
|
|
1584
2105
|
param: ConfiguratorNameSchema,
|
|
1585
2106
|
when: ConfiguratorConditionSchema
|
|
1586
2107
|
}),
|
|
1587
2108
|
/** Narrow a number parameter: `max cabinet_height = total_height - 300`. */
|
|
1588
|
-
|
|
1589
|
-
kind:
|
|
2109
|
+
z6.strictObject({
|
|
2110
|
+
kind: z6.literal("limit"),
|
|
1590
2111
|
param: ConfiguratorNameSchema,
|
|
1591
|
-
bound:
|
|
2112
|
+
bound: z6.enum(["min", "max"]),
|
|
1592
2113
|
formula: FormulaSchema
|
|
1593
2114
|
}),
|
|
1594
2115
|
/** While `when` holds, these codes of a choice or material are not offered. */
|
|
1595
|
-
|
|
1596
|
-
kind:
|
|
2116
|
+
z6.strictObject({
|
|
2117
|
+
kind: z6.literal("exclude"),
|
|
1597
2118
|
param: ConfiguratorNameSchema,
|
|
1598
|
-
codes:
|
|
2119
|
+
codes: z6.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList),
|
|
1599
2120
|
when: ConfiguratorConditionSchema
|
|
1600
2121
|
})
|
|
1601
2122
|
]);
|
|
@@ -1605,23 +2126,23 @@ var PriceRuleBase = {
|
|
|
1605
2126
|
/** How many times the amount counts, e.g. `compartments`. Absent is once. */
|
|
1606
2127
|
multiplier: FormulaSchema.optional()
|
|
1607
2128
|
};
|
|
1608
|
-
var ConfiguratorPriceRuleSchema =
|
|
2129
|
+
var ConfiguratorPriceRuleSchema = z6.discriminatedUnion("kind", [
|
|
1609
2130
|
/** The surcharge the model gives the chosen option. */
|
|
1610
|
-
|
|
2131
|
+
z6.strictObject({
|
|
1611
2132
|
...PriceRuleBase,
|
|
1612
|
-
kind:
|
|
2133
|
+
kind: z6.literal("choice"),
|
|
1613
2134
|
param: ConfiguratorNameSchema
|
|
1614
2135
|
}),
|
|
1615
2136
|
/** The surcharge of the chosen material's price group. */
|
|
1616
|
-
|
|
2137
|
+
z6.strictObject({
|
|
1617
2138
|
...PriceRuleBase,
|
|
1618
|
-
kind:
|
|
2139
|
+
kind: z6.literal("material"),
|
|
1619
2140
|
param: ConfiguratorNameSchema
|
|
1620
2141
|
}),
|
|
1621
2142
|
/** An amount, counted while a switch is on. */
|
|
1622
|
-
|
|
2143
|
+
z6.strictObject({
|
|
1623
2144
|
...PriceRuleBase,
|
|
1624
|
-
kind:
|
|
2145
|
+
kind: z6.literal("toggle"),
|
|
1625
2146
|
param: ConfiguratorNameSchema,
|
|
1626
2147
|
amount: ConfiguratorNameSchema
|
|
1627
2148
|
}),
|
|
@@ -1629,10 +2150,10 @@ var ConfiguratorPriceRuleSchema = z5.discriminatedUnion("kind", [
|
|
|
1629
2150
|
* A price on measure: `max(0, total_width - 1800) / 1000 * meter_price`.
|
|
1630
2151
|
* Reads number parameters (in their unit) and amounts (in cents).
|
|
1631
2152
|
*/
|
|
1632
|
-
|
|
2153
|
+
z6.strictObject({
|
|
1633
2154
|
key: ConfiguratorNameSchema,
|
|
1634
2155
|
label: LabelSchema,
|
|
1635
|
-
kind:
|
|
2156
|
+
kind: z6.literal("formula"),
|
|
1636
2157
|
formula: FormulaSchema
|
|
1637
2158
|
})
|
|
1638
2159
|
]);
|
|
@@ -1642,43 +2163,43 @@ var CONFIGURATOR_SCENE_LIGHTINGS = [
|
|
|
1642
2163
|
"daylight"
|
|
1643
2164
|
];
|
|
1644
2165
|
var CONFIGURATOR_SCENE_EDGES = ["soft", "sharp"];
|
|
1645
|
-
var ConfiguratorSceneSchema =
|
|
1646
|
-
lighting:
|
|
1647
|
-
edges:
|
|
2166
|
+
var ConfiguratorSceneSchema = z6.strictObject({
|
|
2167
|
+
lighting: z6.enum(CONFIGURATOR_SCENE_LIGHTINGS).optional(),
|
|
2168
|
+
edges: z6.enum(CONFIGURATOR_SCENE_EDGES).optional()
|
|
1648
2169
|
});
|
|
1649
|
-
var structureObjectSchema = (files) =>
|
|
1650
|
-
schemaVersion:
|
|
2170
|
+
var structureObjectSchema = (files) => z6.strictObject({
|
|
2171
|
+
schemaVersion: z6.literal(1),
|
|
1651
2172
|
geometry: geometryDocumentSchema(files.model),
|
|
1652
2173
|
/**
|
|
1653
2174
|
* How the scene is presented. Part of the structure and not of the shape
|
|
1654
2175
|
* document, so the owner sets it under `crm.manage` like a material.
|
|
1655
2176
|
*/
|
|
1656
2177
|
scene: ConfiguratorSceneSchema.optional(),
|
|
1657
|
-
tabs:
|
|
1658
|
-
|
|
2178
|
+
tabs: z6.array(
|
|
2179
|
+
z6.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
|
|
1659
2180
|
).min(1).max(CONFIGURATOR_LIMITS.tabs),
|
|
1660
|
-
parameters:
|
|
1661
|
-
choiceLists:
|
|
1662
|
-
materials:
|
|
1663
|
-
priceGroups:
|
|
1664
|
-
|
|
2181
|
+
parameters: z6.array(ConfiguratorParameterSchema).max(CONFIGURATOR_LIMITS.parameters),
|
|
2182
|
+
choiceLists: z6.array(choiceListSchema(files.picture)).max(CONFIGURATOR_LIMITS.choiceLists),
|
|
2183
|
+
materials: z6.array(materialSchema(files.picture)).max(CONFIGURATOR_LIMITS.materials),
|
|
2184
|
+
priceGroups: z6.array(
|
|
2185
|
+
z6.strictObject({ code: ConfiguratorCodeSchema, label: LabelSchema })
|
|
1665
2186
|
).max(CONFIGURATOR_LIMITS.priceGroups),
|
|
1666
2187
|
/** Named amounts a model fills in and price rules read (`meter_price`). */
|
|
1667
|
-
amounts:
|
|
1668
|
-
|
|
2188
|
+
amounts: z6.array(
|
|
2189
|
+
z6.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
|
|
1669
2190
|
).max(CONFIGURATOR_LIMITS.amounts),
|
|
1670
2191
|
/**
|
|
1671
2192
|
* A list of fixed combinations a model offers, each setting some number
|
|
1672
2193
|
* parameters at once: a fireplace and the niche it needs.
|
|
1673
2194
|
*/
|
|
1674
|
-
presets:
|
|
2195
|
+
presets: z6.strictObject({
|
|
1675
2196
|
label: LabelSchema,
|
|
1676
2197
|
tab: ConfiguratorNameSchema,
|
|
1677
|
-
sets:
|
|
1678
|
-
required:
|
|
2198
|
+
sets: z6.array(ConfiguratorNameSchema).min(1).max(CONFIGURATOR_LIMITS.parameters),
|
|
2199
|
+
required: z6.boolean()
|
|
1679
2200
|
}).optional(),
|
|
1680
|
-
rules:
|
|
1681
|
-
priceRules:
|
|
2201
|
+
rules: z6.array(ConfiguratorRuleSchema).max(CONFIGURATOR_LIMITS.rules),
|
|
2202
|
+
priceRules: z6.array(ConfiguratorPriceRuleSchema).max(CONFIGURATOR_LIMITS.priceRules)
|
|
1682
2203
|
});
|
|
1683
2204
|
var ConfiguratorStructureObjectSchema = structureObjectSchema({
|
|
1684
2205
|
picture: TexturePathSchema,
|
|
@@ -1837,8 +2358,8 @@ function configuratorStructureProblems(structure) {
|
|
|
1837
2358
|
};
|
|
1838
2359
|
structure.rules.forEach((rule, index) => {
|
|
1839
2360
|
const path = ["rules", index];
|
|
1840
|
-
const
|
|
1841
|
-
if (!
|
|
2361
|
+
const target3 = parameters.get(rule.param);
|
|
2362
|
+
if (!target3) {
|
|
1842
2363
|
problems.push(
|
|
1843
2364
|
issue([...path, "param"], "configurator.unknownParam", {
|
|
1844
2365
|
key: rule.param
|
|
@@ -1847,7 +2368,7 @@ function configuratorStructureProblems(structure) {
|
|
|
1847
2368
|
return;
|
|
1848
2369
|
}
|
|
1849
2370
|
if (rule.kind === "limit") {
|
|
1850
|
-
if (
|
|
2371
|
+
if (target3.kind !== "number") {
|
|
1851
2372
|
problems.push(
|
|
1852
2373
|
issue([...path, "param"], "configurator.notANumberParam", {
|
|
1853
2374
|
key: rule.param
|
|
@@ -1865,7 +2386,7 @@ function configuratorStructureProblems(structure) {
|
|
|
1865
2386
|
return;
|
|
1866
2387
|
}
|
|
1867
2388
|
if (rule.kind === "exclude") {
|
|
1868
|
-
const codes = codesOf(
|
|
2389
|
+
const codes = codesOf(target3, lists, materials);
|
|
1869
2390
|
if (!codes) {
|
|
1870
2391
|
problems.push(
|
|
1871
2392
|
issue([...path, "param"], "configurator.notAChoiceParam", {
|
|
@@ -1944,8 +2465,8 @@ function configuratorStructureProblems(structure) {
|
|
|
1944
2465
|
problems
|
|
1945
2466
|
);
|
|
1946
2467
|
}
|
|
1947
|
-
const
|
|
1948
|
-
if (!
|
|
2468
|
+
const target3 = parameters.get(rule.param);
|
|
2469
|
+
if (!target3 || target3.kind !== rule.kind) {
|
|
1949
2470
|
problems.push(
|
|
1950
2471
|
issue([...path, "param"], "configurator.priceRuleParamKind", {
|
|
1951
2472
|
key: rule.param,
|
|
@@ -2094,55 +2615,55 @@ function parameterResolutionOrder(structure) {
|
|
|
2094
2615
|
}
|
|
2095
2616
|
|
|
2096
2617
|
// ../features/product-configurator/src/shared/configurator-value.schema.ts
|
|
2097
|
-
import { z as
|
|
2098
|
-
var CentsSchema =
|
|
2618
|
+
import { z as z7 } from "zod";
|
|
2619
|
+
var CentsSchema = z7.number().int().min(0).max(1e8);
|
|
2099
2620
|
var PriceSchema = CentsSchema.nullable();
|
|
2100
|
-
var ConfiguratorValueObjectSchema =
|
|
2621
|
+
var ConfiguratorValueObjectSchema = z7.strictObject({
|
|
2101
2622
|
/** Null: the model has no price of its own, every quote is "open". */
|
|
2102
2623
|
basePriceCents: PriceSchema,
|
|
2103
|
-
numbers:
|
|
2624
|
+
numbers: z7.record(
|
|
2104
2625
|
ConfiguratorNameSchema,
|
|
2105
|
-
|
|
2106
|
-
min:
|
|
2107
|
-
max:
|
|
2108
|
-
default:
|
|
2626
|
+
z7.strictObject({
|
|
2627
|
+
min: z7.number().finite(),
|
|
2628
|
+
max: z7.number().finite(),
|
|
2629
|
+
default: z7.number().finite()
|
|
2109
2630
|
})
|
|
2110
2631
|
).default({}),
|
|
2111
2632
|
/** Per choice list: the options this model offers, with their surcharge. */
|
|
2112
|
-
choices:
|
|
2633
|
+
choices: z7.record(
|
|
2113
2634
|
ConfiguratorNameSchema,
|
|
2114
|
-
|
|
2115
|
-
options:
|
|
2635
|
+
z7.strictObject({
|
|
2636
|
+
options: z7.record(
|
|
2116
2637
|
ConfiguratorCodeSchema,
|
|
2117
|
-
|
|
2638
|
+
z7.strictObject({ priceCents: PriceSchema })
|
|
2118
2639
|
),
|
|
2119
2640
|
default: ConfiguratorCodeSchema.optional()
|
|
2120
2641
|
})
|
|
2121
2642
|
).default({}),
|
|
2122
2643
|
/** Per material parameter: the materials this model offers. */
|
|
2123
|
-
materials:
|
|
2644
|
+
materials: z7.record(
|
|
2124
2645
|
ConfiguratorNameSchema,
|
|
2125
|
-
|
|
2126
|
-
offered:
|
|
2646
|
+
z7.strictObject({
|
|
2647
|
+
offered: z7.array(ConfiguratorCodeSchema).max(CONFIGURATOR_LIMITS.materials),
|
|
2127
2648
|
default: ConfiguratorCodeSchema.optional()
|
|
2128
2649
|
})
|
|
2129
2650
|
).default({}),
|
|
2130
2651
|
/** Per switch: whether this model offers it, and how it starts. Absent is offered, off. */
|
|
2131
|
-
toggles:
|
|
2652
|
+
toggles: z7.record(
|
|
2132
2653
|
ConfiguratorNameSchema,
|
|
2133
|
-
|
|
2654
|
+
z7.strictObject({ offered: z7.boolean(), default: z7.boolean() })
|
|
2134
2655
|
).default({}),
|
|
2135
2656
|
/** The surcharge of each material price group. */
|
|
2136
|
-
priceGroups:
|
|
2657
|
+
priceGroups: z7.record(ConfiguratorCodeSchema, PriceSchema).default({}),
|
|
2137
2658
|
/** The amounts the structure names, such as a price per metre. */
|
|
2138
|
-
amounts:
|
|
2139
|
-
presets:
|
|
2140
|
-
|
|
2659
|
+
amounts: z7.record(ConfiguratorNameSchema, PriceSchema).default({}),
|
|
2660
|
+
presets: z7.array(
|
|
2661
|
+
z7.strictObject({
|
|
2141
2662
|
code: ConfiguratorCodeSchema,
|
|
2142
|
-
label:
|
|
2663
|
+
label: z7.string().trim().min(1).max(80),
|
|
2143
2664
|
/** A product of the same account this preset stands for, e.g. the fireplace. */
|
|
2144
|
-
productId:
|
|
2145
|
-
values:
|
|
2665
|
+
productId: z7.string().uuid().nullable(),
|
|
2666
|
+
values: z7.record(ConfiguratorNameSchema, z7.number().finite())
|
|
2146
2667
|
})
|
|
2147
2668
|
).max(CONFIGURATOR_LIMITS.presets).default([])
|
|
2148
2669
|
});
|
|
@@ -2601,9 +3122,9 @@ function modelOf(node, chosen) {
|
|
|
2601
3122
|
}
|
|
2602
3123
|
|
|
2603
3124
|
// src/configurator-tools.ts
|
|
2604
|
-
import { readFile, stat } from "fs/promises";
|
|
2605
|
-
import { extname } from "path";
|
|
2606
|
-
var
|
|
3125
|
+
import { readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
3126
|
+
import { extname as extname2 } from "path";
|
|
3127
|
+
var FILE_RULES2 = {
|
|
2607
3128
|
model: { extensions: [".glb"], maxBytes: 10 * 1024 * 1024 },
|
|
2608
3129
|
texture: {
|
|
2609
3130
|
extensions: [".webp", ".jpg", ".jpeg"],
|
|
@@ -2611,7 +3132,7 @@ var FILE_RULES = {
|
|
|
2611
3132
|
}
|
|
2612
3133
|
};
|
|
2613
3134
|
var CONFIGURATOR = 'A "configurator" field holds a 3D product configurator. Its structure has "geometry" (the shape document: "inputs" bound to parameters, "constants", material "slots", a "root" tree of building blocks and "annotations" for dimension lines), "parameters", "choiceLists", "materials", "priceGroups", "amounts", optional "presets", optional "scene" ({ "lighting": "studio" | "warm" | "daylight" }), "rules" and "priceRules". Building blocks: box, rounded_top_box (params.radius), stadium_prism, arc_panel (params.thickness, params.mirror), led_strip, niche_panel (params.niche_x, niche_y, niche_width, niche_height), model (a "model" path to a .glb uploaded with upload_configurator_file, params.fit 0 contain or 1 stretch, params.paint 1 to use the slot material) and repeat (count, children, index i or j). Every measure is millimetres; "at" is the minimum corner, x left to right, y bottom to top, z from the wall into the room; "size" is width, height, depth. A measure is a number or a formula with + - * / ( ) and min, max, floor, ceil, round, abs over the shape\u2019s inputs, constants and the repeat index. Texture paths in materials come from upload_configurator_file too. Making a configurator, or changing its "geometry", needs a key with the configurator:shape scope; everything around the shape needs only catalog:write.';
|
|
2614
|
-
var documentInput =
|
|
3135
|
+
var documentInput = z8.string().min(2).max(2e5).describe(
|
|
2615
3136
|
'The JSON text of a whole configurator structure (it has "schemaVersion"), or of a shape document alone (it has "root").'
|
|
2616
3137
|
);
|
|
2617
3138
|
function registerConfiguratorTools(server, client) {
|
|
@@ -2643,11 +3164,11 @@ function registerConfiguratorTools(server, client) {
|
|
|
2643
3164
|
slug: CatalogFieldSlugSchema.describe(
|
|
2644
3165
|
"The slug of the configurator field."
|
|
2645
3166
|
),
|
|
2646
|
-
label:
|
|
2647
|
-
structure:
|
|
3167
|
+
label: z8.string().trim().min(1).max(120).optional(),
|
|
3168
|
+
structure: z8.string().min(2).max(2e5).describe(
|
|
2648
3169
|
'The JSON text of the whole configurator structure (it has "schemaVersion").'
|
|
2649
3170
|
),
|
|
2650
|
-
public:
|
|
3171
|
+
public: z8.boolean().optional().describe("Whether product pages may show the configurator."),
|
|
2651
3172
|
...objectInput
|
|
2652
3173
|
},
|
|
2653
3174
|
annotations: { destructiveHint: true, idempotentHint: true }
|
|
@@ -2697,21 +3218,21 @@ function registerConfiguratorTools(server, client) {
|
|
|
2697
3218
|
fieldSlug: CatalogFieldSlugSchema.describe(
|
|
2698
3219
|
"The slug of the configurator field."
|
|
2699
3220
|
),
|
|
2700
|
-
kind:
|
|
2701
|
-
filePath:
|
|
3221
|
+
kind: z8.enum(CONFIGURATOR_FILE_KINDS),
|
|
3222
|
+
filePath: z8.string().min(1).max(1e3).describe("The absolute path of the file on this machine."),
|
|
2702
3223
|
...objectInput
|
|
2703
3224
|
},
|
|
2704
3225
|
annotations: { destructiveHint: false, idempotentHint: false }
|
|
2705
3226
|
},
|
|
2706
3227
|
async ({ fieldSlug, kind, filePath, object: object2 }) => {
|
|
2707
|
-
const rules =
|
|
2708
|
-
const extension =
|
|
3228
|
+
const rules = FILE_RULES2[kind];
|
|
3229
|
+
const extension = extname2(filePath).toLowerCase();
|
|
2709
3230
|
if (!rules.extensions.includes(extension)) {
|
|
2710
3231
|
return notUploaded(
|
|
2711
3232
|
`A ${kind} is a ${rules.extensions.join(" or ")} file; this is "${extension || "no extension"}".`
|
|
2712
3233
|
);
|
|
2713
3234
|
}
|
|
2714
|
-
const info = await
|
|
3235
|
+
const info = await stat2(filePath).catch(() => null);
|
|
2715
3236
|
if (!info?.isFile()) {
|
|
2716
3237
|
return notUploaded(`"${filePath}" is not a file on this machine.`);
|
|
2717
3238
|
}
|
|
@@ -2721,7 +3242,7 @@ function registerConfiguratorTools(server, client) {
|
|
|
2721
3242
|
);
|
|
2722
3243
|
}
|
|
2723
3244
|
try {
|
|
2724
|
-
const bytes = new Uint8Array(await
|
|
3245
|
+
const bytes = new Uint8Array(await readFile2(filePath));
|
|
2725
3246
|
const result = await client.uploadConfiguratorFile(
|
|
2726
3247
|
fieldSlug,
|
|
2727
3248
|
{
|
|
@@ -2751,8 +3272,8 @@ function registerConfiguratorTools(server, client) {
|
|
|
2751
3272
|
title: "Read the configurator of a product",
|
|
2752
3273
|
description: "The configurator field of one product as its page receives it: the structure (texture paths already URLs), the model\u2019s value (ranges, offered options, amounts, presets), the tax a quote uses and the two hashes a request must send back. Useful to draft a new shape from an existing one. " + CONFIGURATOR,
|
|
2753
3274
|
inputSchema: {
|
|
2754
|
-
slugOrId:
|
|
2755
|
-
field:
|
|
3275
|
+
slugOrId: z8.string().min(1).max(200).describe("The address (slug) or the id of the product."),
|
|
3276
|
+
field: z8.string().min(1).max(64).describe("The slug of the configurator field.")
|
|
2756
3277
|
},
|
|
2757
3278
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
2758
3279
|
},
|
|
@@ -2834,8 +3355,8 @@ function isRecord(value) {
|
|
|
2834
3355
|
}
|
|
2835
3356
|
|
|
2836
3357
|
// src/event-tools.ts
|
|
2837
|
-
import { z as
|
|
2838
|
-
var moment =
|
|
3358
|
+
import { z as z9 } from "zod";
|
|
3359
|
+
var moment = z9.string().datetime({ offset: true }).optional().describe("An ISO timestamp with its offset, such as 2026-11-01T00:00:00Z.");
|
|
2839
3360
|
function registerEventTools(server, client) {
|
|
2840
3361
|
server.registerTool(
|
|
2841
3362
|
"list_events",
|
|
@@ -2863,7 +3384,7 @@ function registerEventTools(server, client) {
|
|
|
2863
3384
|
title: "Read one event",
|
|
2864
3385
|
description: "One event as its page needs it: description, doors and sales times, address, ticket types with their prices, and the texts for search engines. May be a minute old; ask get_event_availability for seats that must be current.",
|
|
2865
3386
|
inputSchema: {
|
|
2866
|
-
idOrSlug:
|
|
3387
|
+
idOrSlug: z9.string().min(1).max(200).describe("The id or the address (slug) of the event.")
|
|
2867
3388
|
},
|
|
2868
3389
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
2869
3390
|
},
|
|
@@ -2888,7 +3409,7 @@ function registerEventTools(server, client) {
|
|
|
2888
3409
|
title: "Seats left for an event",
|
|
2889
3410
|
description: "Live, not cached: how many seats are left for the event and for each ticket type, and whether it is sold out. A remaining of null means there is no ceiling.",
|
|
2890
3411
|
inputSchema: {
|
|
2891
|
-
eventId:
|
|
3412
|
+
eventId: z9.string().uuid().describe("The id of the event.")
|
|
2892
3413
|
},
|
|
2893
3414
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
2894
3415
|
},
|
|
@@ -2916,9 +3437,9 @@ function describeEvent(event) {
|
|
|
2916
3437
|
}
|
|
2917
3438
|
|
|
2918
3439
|
// src/placement-tools.ts
|
|
2919
|
-
import { z as
|
|
3440
|
+
import { z as z10 } from "zod";
|
|
2920
3441
|
var REACT_HINT = "On a React or Next.js site, prefer the components in `@solumflow-app/crm-client/react` (CrmForm, CrmBooking, CrmChatWidget, CrmSiteTracking) over pasting HTML.";
|
|
2921
|
-
var origin =
|
|
3442
|
+
var origin = z10.string().regex(/^https?:\/\/[a-zA-Z0-9.-]+(:[0-9]{1,5})?$/).describe("Scheme, host and optional port; no path: https://www.example.com");
|
|
2922
3443
|
function registerPlacementTools(server, client) {
|
|
2923
3444
|
server.registerTool(
|
|
2924
3445
|
"list_forms",
|
|
@@ -2991,13 +3512,13 @@ function registerPlacementTools(server, client) {
|
|
|
2991
3512
|
title: "Make or change a chat widget",
|
|
2992
3513
|
description: "Makes the chat widget at this address when there is none, or changes it. Sets its name, whether it is live, and which sites it may run on. How it looks and what it says is done in the app\u2019s builder. Sites are added and removed, never replaced. Careful: an empty list means ANY site, so adding the first origin narrows the widget to that site only, and removing an origin takes the chat off a site that may be live.",
|
|
2993
3514
|
inputSchema: {
|
|
2994
|
-
slug:
|
|
3515
|
+
slug: z10.string().regex(/^[a-z][a-z0-9-]*$/).max(100).describe(
|
|
2995
3516
|
'The widget\u2019s address on this account, such as "website-chat".'
|
|
2996
3517
|
),
|
|
2997
|
-
name:
|
|
2998
|
-
status:
|
|
2999
|
-
addAllowedOrigins:
|
|
3000
|
-
removeAllowedOrigins:
|
|
3518
|
+
name: z10.string().min(1).max(200).optional().describe("Required when the widget does not exist yet."),
|
|
3519
|
+
status: z10.enum(["draft", "published"]).optional().describe("Only a published widget answers on a site."),
|
|
3520
|
+
addAllowedOrigins: z10.array(origin).max(20).optional(),
|
|
3521
|
+
removeAllowedOrigins: z10.array(origin).max(20).optional()
|
|
3001
3522
|
},
|
|
3002
3523
|
annotations: {
|
|
3003
3524
|
readOnlyHint: false,
|
|
@@ -3045,9 +3566,9 @@ ${describeChatWidget(widget)}`,
|
|
|
3045
3566
|
title: "Set up or change the tracking script",
|
|
3046
3567
|
description: "Sets website tracking up when it is not there, adds or removes the hosts it counts visits from, and changes the consent it waits for. An empty list of hosts counts nothing, so add the host of every site the script is placed on. Hosts are added and removed, never replaced.",
|
|
3047
3568
|
inputSchema: {
|
|
3048
|
-
addAllowedHosts:
|
|
3049
|
-
removeAllowedHosts:
|
|
3050
|
-
consentSignal:
|
|
3569
|
+
addAllowedHosts: z10.array(z10.string()).max(20).optional().describe('Hosts such as "www.example.com" or "*.example.com".'),
|
|
3570
|
+
removeAllowedHosts: z10.array(z10.string()).max(20).optional(),
|
|
3571
|
+
consentSignal: z10.enum([
|
|
3051
3572
|
"ad_storage",
|
|
3052
3573
|
"analytics_storage",
|
|
3053
3574
|
"personalization_storage",
|
|
@@ -3132,7 +3653,119 @@ function readServerConfig(env) {
|
|
|
3132
3653
|
return { ok: true, apiKey, baseUrl };
|
|
3133
3654
|
}
|
|
3134
3655
|
|
|
3656
|
+
// src/server-info-tool.ts
|
|
3657
|
+
function registerServerInfoTool(server, info) {
|
|
3658
|
+
server.registerTool(
|
|
3659
|
+
"server_info",
|
|
3660
|
+
{
|
|
3661
|
+
title: "Which version of this server is running",
|
|
3662
|
+
description: "The version of this MCP server and of the API client it uses, the address of the API it talks to, and `definitionsVersion`, a fingerprint of every tool definition it serves. A write tool names the same fingerprint in its description; when the two differ, your session was started before an update of this server and works from older tool definitions -- restart the session (in Claude Code `/mcp` and reconnect, or a new session). The server cannot do that for you.",
|
|
3663
|
+
inputSchema: {},
|
|
3664
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
3665
|
+
},
|
|
3666
|
+
async () => {
|
|
3667
|
+
const result = {
|
|
3668
|
+
version: info.version,
|
|
3669
|
+
clientVersion: info.clientVersion,
|
|
3670
|
+
baseUrl: info.baseUrl,
|
|
3671
|
+
definitionsVersion: info.definitionsVersion()
|
|
3672
|
+
};
|
|
3673
|
+
return answer(
|
|
3674
|
+
`crm-mcp-server ${result.version} (client ${result.clientVersion}), API ${result.baseUrl}, tool definitions ${result.definitionsVersion}.`,
|
|
3675
|
+
result
|
|
3676
|
+
);
|
|
3677
|
+
}
|
|
3678
|
+
);
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3681
|
+
// src/tool-definitions-version.ts
|
|
3682
|
+
import { z as z11 } from "zod";
|
|
3683
|
+
import { createHash } from "crypto";
|
|
3684
|
+
function toolDefinitionsHash(tools) {
|
|
3685
|
+
const definitions = [...tools].sort((left, right) => left.name.localeCompare(right.name)).map(({ name, config }) => ({
|
|
3686
|
+
name,
|
|
3687
|
+
title: config.title ?? null,
|
|
3688
|
+
description: config.description ?? null,
|
|
3689
|
+
inputSchema: config.inputSchema ? z11.toJSONSchema(z11.object(config.inputSchema), {
|
|
3690
|
+
io: "input",
|
|
3691
|
+
unrepresentable: "any"
|
|
3692
|
+
}) : null,
|
|
3693
|
+
annotations: config.annotations ?? null
|
|
3694
|
+
}));
|
|
3695
|
+
return createHash("sha256").update(JSON.stringify(definitions)).digest("hex").slice(0, 12);
|
|
3696
|
+
}
|
|
3697
|
+
function writesSomething(config) {
|
|
3698
|
+
return config.annotations?.readOnlyHint !== true;
|
|
3699
|
+
}
|
|
3700
|
+
function holdToolRegistrations(real) {
|
|
3701
|
+
const pending = [];
|
|
3702
|
+
let hash;
|
|
3703
|
+
const server = new Proxy(real, {
|
|
3704
|
+
get(target3, property) {
|
|
3705
|
+
if (property === "registerTool") {
|
|
3706
|
+
return (name, config, handler) => {
|
|
3707
|
+
pending.push({ name, config, handler });
|
|
3708
|
+
return void 0;
|
|
3709
|
+
};
|
|
3710
|
+
}
|
|
3711
|
+
const value = Reflect.get(target3, property, target3);
|
|
3712
|
+
return typeof value === "function" ? value.bind(target3) : value;
|
|
3713
|
+
}
|
|
3714
|
+
});
|
|
3715
|
+
function currentHash() {
|
|
3716
|
+
hash ??= toolDefinitionsHash(pending);
|
|
3717
|
+
return hash;
|
|
3718
|
+
}
|
|
3719
|
+
function publish() {
|
|
3720
|
+
const version2 = currentHash();
|
|
3721
|
+
for (const { name, config, handler } of pending) {
|
|
3722
|
+
if (!writesSomething(config)) {
|
|
3723
|
+
real.registerTool(
|
|
3724
|
+
name,
|
|
3725
|
+
config,
|
|
3726
|
+
handler
|
|
3727
|
+
);
|
|
3728
|
+
continue;
|
|
3729
|
+
}
|
|
3730
|
+
real.registerTool(
|
|
3731
|
+
name,
|
|
3732
|
+
{
|
|
3733
|
+
...config,
|
|
3734
|
+
description: `${config.description ?? ""} Definitions version ${version2}: send it as schemaHash and the answer tells you when these tool definitions are older than the server.`,
|
|
3735
|
+
inputSchema: {
|
|
3736
|
+
...config.inputSchema,
|
|
3737
|
+
schemaHash: z11.string().max(64).optional().describe(
|
|
3738
|
+
`The definitions version named in this tool's description (${version2}).`
|
|
3739
|
+
)
|
|
3740
|
+
}
|
|
3741
|
+
},
|
|
3742
|
+
(async ({ schemaHash, ...input }, extra) => {
|
|
3743
|
+
const result = await handler(input, extra);
|
|
3744
|
+
return schemaHash !== void 0 && schemaHash !== version2 ? {
|
|
3745
|
+
...result,
|
|
3746
|
+
content: [
|
|
3747
|
+
...result.content ?? [],
|
|
3748
|
+
{ type: "text", text: staleDefinitions(version2) }
|
|
3749
|
+
]
|
|
3750
|
+
} : result;
|
|
3751
|
+
})
|
|
3752
|
+
);
|
|
3753
|
+
}
|
|
3754
|
+
return version2;
|
|
3755
|
+
}
|
|
3756
|
+
return { server, publish, currentHash };
|
|
3757
|
+
}
|
|
3758
|
+
function staleDefinitions(version2) {
|
|
3759
|
+
return `Note: your tool definitions are older than this server (its version is ${version2}). The call was carried out as given. Restart the session, or reconnect the server with /mcp in Claude Code, to load the current definitions; server_info says which version is running.`;
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3135
3762
|
// src/index.ts
|
|
3763
|
+
import { createRequire } from "module";
|
|
3764
|
+
function installedClientVersion() {
|
|
3765
|
+
return createRequire(import.meta.url)(
|
|
3766
|
+
"@solumflow-app/crm-client/package.json"
|
|
3767
|
+
).version;
|
|
3768
|
+
}
|
|
3136
3769
|
async function main() {
|
|
3137
3770
|
const config = readServerConfig(process.env);
|
|
3138
3771
|
if (!config.ok) {
|
|
@@ -3149,12 +3782,26 @@ async function main() {
|
|
|
3149
3782
|
apiKey: config.apiKey,
|
|
3150
3783
|
baseUrl: config.baseUrl
|
|
3151
3784
|
});
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3785
|
+
const held = holdToolRegistrations(server);
|
|
3786
|
+
registerServerInfoTool(held.server, {
|
|
3787
|
+
version,
|
|
3788
|
+
clientVersion: installedClientVersion(),
|
|
3789
|
+
baseUrl: config.baseUrl,
|
|
3790
|
+
definitionsVersion: held.currentHash
|
|
3791
|
+
});
|
|
3792
|
+
registerPlacementTools(held.server, client);
|
|
3793
|
+
registerCatalogTools(held.server, client);
|
|
3794
|
+
registerCatalogWriteTools(held.server, client);
|
|
3795
|
+
registerCatalogMigrationTools(held.server, client);
|
|
3796
|
+
registerEventTools(held.server, client);
|
|
3797
|
+
registerConfiguratorTools(held.server, client);
|
|
3798
|
+
held.publish();
|
|
3157
3799
|
await server.connect(new StdioServerTransport());
|
|
3800
|
+
try {
|
|
3801
|
+
server.sendToolListChanged();
|
|
3802
|
+
} catch (error) {
|
|
3803
|
+
console.error("crm-mcp-server: could not announce the tool list:", error);
|
|
3804
|
+
}
|
|
3158
3805
|
}
|
|
3159
3806
|
main().catch((error) => {
|
|
3160
3807
|
console.error("crm-mcp-server:", error);
|