@solumflow-app/crm-mcp-server 0.3.1 → 0.4.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/dist/index.js CHANGED
@@ -5,23 +5,329 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { createClient } from "@solumflow-app/crm-client";
7
7
 
8
+ // package.json
9
+ var version = "0.4.0";
10
+
8
11
  // src/catalog-tools.ts
9
- import { z } from "zod";
12
+ import { z as z3 } from "zod";
13
+
14
+ // ../features/crm-invoicing/src/schema/catalog-api.schema.ts
15
+ import * as z from "zod";
16
+
17
+ // ../features/crm-invoicing/src/schema/amount-ceiling.ts
18
+ var MAX_AMOUNT_CENTS = 99999999;
19
+
20
+ // ../features/crm-invoicing/src/schema/catalog-api.schema.ts
21
+ var WEB_SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
22
+ var FIELD_SLUG = /^[a-z][a-z0-9_]*$/;
23
+ var CatalogSourceSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, {
24
+ message: (
25
+ // i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
26
+ 'A source is lower-case letters, digits and hyphens, at most 64 characters (for example "coolflames-payload").'
27
+ )
28
+ });
29
+ var CatalogExternalRefSchema = z.string().trim().min(1).max(255);
30
+ var CatalogWebSlugSchema = z.string().min(2).max(64).regex(WEB_SLUG, {
31
+ // i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
32
+ message: "An address is lower-case letters and digits joined by hyphens."
33
+ });
34
+ var CatalogFieldSlugSchema = z.string().max(63).regex(FIELD_SLUG, {
35
+ message: (
36
+ // i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
37
+ "A field slug starts with a letter and holds lower-case letters, digits and underscores."
38
+ )
39
+ });
40
+ var cents = z.number().int().min(0).max(MAX_AMOUNT_CENTS);
41
+ var text = (max) => z.string().trim().max(max).nullable().optional();
42
+ var CatalogPriceSchema = z.object({
43
+ /** The price's own id at the source, so a renamed price stays itself. */
44
+ externalRef: CatalogExternalRefSchema.optional(),
45
+ label: z.string().trim().min(1).max(120),
46
+ amountCents: cents,
47
+ compareAtCents: cents.nullable().optional(),
48
+ kind: z.enum(["one_off", "recurring"]).optional(),
49
+ recurringInterval: z.enum(["day", "week", "month", "year"]).optional(),
50
+ recurringIntervalCount: z.number().int().min(1).max(365).optional(),
51
+ isDefault: z.boolean().optional(),
52
+ active: z.boolean().optional()
53
+ }).strict().superRefine((price, ctx) => {
54
+ if ((price.kind ?? "one_off") === "recurring" && !price.recurringInterval) {
55
+ ctx.addIssue({
56
+ code: "custom",
57
+ path: ["recurringInterval"],
58
+ // i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
59
+ message: "A recurring price needs an interval."
60
+ });
61
+ }
62
+ });
63
+ var pricesSchema = z.array(CatalogPriceSchema).min(1).max(50).superRefine((prices, ctx) => {
64
+ if (prices.filter((price) => price.isDefault).length > 1) {
65
+ ctx.addIssue({
66
+ code: "custom",
67
+ // i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
68
+ message: "At most one price can be the default."
69
+ });
70
+ }
71
+ const refs = prices.flatMap(
72
+ (price) => price.externalRef ? [price.externalRef] : []
73
+ );
74
+ if (new Set(refs).size !== refs.length) {
75
+ ctx.addIssue({
76
+ code: "custom",
77
+ // i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
78
+ message: "Two prices carry the same externalRef."
79
+ });
80
+ }
81
+ const labels = prices.map((price) => price.label.toLowerCase());
82
+ if (new Set(labels).size !== labels.length) {
83
+ ctx.addIssue({
84
+ code: "custom",
85
+ // 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."
87
+ });
88
+ }
89
+ });
90
+ var CatalogImageSchema = z.object({
91
+ url: z.string().max(2048).url().refine((url) => url.startsWith("https://"), {
92
+ // 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 is fetched over https only."
94
+ }),
95
+ alt: text(300)
96
+ }).strict();
97
+ var CatalogVariantAxisSchema = z.object({
98
+ slug: CatalogFieldSlugSchema,
99
+ label: z.string().trim().min(1).max(120).optional()
100
+ }).strict();
101
+ var CatalogVariantSchema = z.object({
102
+ externalRef: CatalogExternalRefSchema,
103
+ /** The value per axis, keyed by the axis slug. */
104
+ options: z.record(
105
+ CatalogFieldSlugSchema,
106
+ z.string().trim().min(1).max(120)
107
+ ),
108
+ sku: text(100),
109
+ gtin: z.string().regex(/^[0-9]{8,14}$/).nullable().optional(),
110
+ prices: pricesSchema.optional(),
111
+ position: z.number().int().min(0).max(1e4).optional(),
112
+ active: z.boolean().optional()
113
+ }).strict();
114
+ var CATALOG_AVAILABILITIES = [
115
+ "draft",
116
+ "internal",
117
+ "scheduled",
118
+ "public"
119
+ ];
120
+ var CatalogProductUpsertSchema = z.object({
121
+ source: CatalogSourceSchema,
122
+ /** Required to create a product; optional to update one. */
123
+ name: z.string().trim().min(1).max(500).optional(),
124
+ slug: CatalogWebSlugSchema.nullable().optional(),
125
+ description: text(5e3),
126
+ /** HTML; cleaned against the same allow-list the app uses. */
127
+ longDescription: text(1e5),
128
+ sku: text(100),
129
+ gtin: z.string().regex(/^[0-9]{8,14}$/).nullable().optional(),
130
+ productType: z.enum(["digital", "physical"]).optional(),
131
+ /**
132
+ * Where the product stands. A new product is a `draft` unless this says
133
+ * otherwise -- an import should not put a hundred products on somebody's
134
+ * website because a run finished.
135
+ */
136
+ availability: z.enum(CATALOG_AVAILABILITIES).optional(),
137
+ /** The moment for `scheduled`; ignored for the other three. */
138
+ publishedAt: z.string().datetime({ offset: true }).nullable().optional(),
139
+ visibility: z.enum(["public", "unlisted"]).optional(),
140
+ metaTitle: text(300),
141
+ metaDescription: text(1e3),
142
+ /** Replaces the product's categories, by slug. */
143
+ categories: z.array(CatalogWebSlugSchema).max(50).optional(),
144
+ /**
145
+ * Make a category that does not exist yet instead of refusing. Off unless
146
+ * asked for, so a typo cannot become a category on a customer's website.
147
+ */
148
+ createMissingCategories: z.boolean().optional(),
149
+ /** Field values by field slug; `null` empties one field. */
150
+ fields: z.record(CatalogFieldSlugSchema, z.unknown()).optional(),
151
+ /** Which fields the product's own page shows, in that order. */
152
+ publicFields: z.array(CatalogFieldSlugSchema).max(100).optional(),
153
+ /**
154
+ * Add a choice a select field does not offer yet instead of refusing.
155
+ * Off unless asked for, for the same reason as categories.
156
+ */
157
+ addMissingOptions: z.boolean().optional(),
158
+ /** Replaces the product's prices as a whole. */
159
+ prices: pricesSchema.optional(),
160
+ /** Replaces the product's pictures, in this order. */
161
+ images: z.array(CatalogImageSchema).max(20).optional(),
162
+ variantAxes: z.array(CatalogVariantAxisSchema).min(1).max(3).optional(),
163
+ /** Replaces the product's variants; one missing here is archived. */
164
+ variants: z.array(CatalogVariantSchema).max(100).optional(),
165
+ /** `false` brings back a product the API archived earlier. */
166
+ archived: z.literal(false).optional()
167
+ }).strict().superRefine((body, ctx) => {
168
+ if (body.variants !== void 0 && body.variantAxes === void 0) {
169
+ ctx.addIssue({
170
+ code: "custom",
171
+ path: ["variantAxes"],
172
+ // i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
173
+ message: "Variants need the axes they differ along."
174
+ });
175
+ }
176
+ if (body.variants && body.variantAxes) {
177
+ const axes = body.variantAxes.map((axis) => axis.slug);
178
+ const seen = /* @__PURE__ */ new Set();
179
+ const refs = /* @__PURE__ */ new Set();
180
+ body.variants.forEach((variant, index) => {
181
+ const named = Object.keys(variant.options);
182
+ if (named.length !== axes.length || !axes.every((axis) => named.includes(axis))) {
183
+ ctx.addIssue({
184
+ code: "custom",
185
+ path: ["variants", index, "options"],
186
+ // i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
187
+ message: `A variant names a value for exactly these axes: ${axes.join(", ")}.`
188
+ });
189
+ }
190
+ const combination = axes.map((axis) => variant.options[axis] ?? "").join("\0");
191
+ if (seen.has(combination)) {
192
+ ctx.addIssue({
193
+ code: "custom",
194
+ path: ["variants", index, "options"],
195
+ // i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
196
+ message: "Two variants stand for the same combination."
197
+ });
198
+ }
199
+ if (refs.has(variant.externalRef)) {
200
+ ctx.addIssue({
201
+ code: "custom",
202
+ path: ["variants", index, "externalRef"],
203
+ // i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
204
+ message: "Two variants carry the same externalRef."
205
+ });
206
+ }
207
+ seen.add(combination);
208
+ refs.add(variant.externalRef);
209
+ });
210
+ }
211
+ if (body.variantAxes) {
212
+ const slugs = body.variantAxes.map((axis) => axis.slug);
213
+ if (new Set(slugs).size !== slugs.length) {
214
+ ctx.addIssue({
215
+ code: "custom",
216
+ path: ["variantAxes"],
217
+ // i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
218
+ message: "An axis is named twice."
219
+ });
220
+ }
221
+ }
222
+ if (body.availability === "scheduled" && !body.publishedAt) {
223
+ ctx.addIssue({
224
+ code: "custom",
225
+ path: ["publishedAt"],
226
+ // i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
227
+ message: "A scheduled product needs the moment it appears."
228
+ });
229
+ }
230
+ });
231
+ var CatalogProductArchiveSchema = z.object({ source: CatalogSourceSchema }).strict();
232
+ var CatalogCategoryUpsertSchema = z.object({
233
+ name: z.string().trim().min(1).max(200).optional(),
234
+ description: text(5e3),
235
+ /** `null` puts the category at the top of the tree. */
236
+ parentSlug: CatalogWebSlugSchema.nullable().optional(),
237
+ availability: z.enum(CATALOG_AVAILABILITIES).optional(),
238
+ publishedAt: z.string().datetime({ offset: true }).nullable().optional(),
239
+ metaTitle: text(300),
240
+ metaDescription: text(1e3)
241
+ }).strict();
242
+ var CatalogObjectSlugSchema = z.string().max(64).regex(FIELD_SLUG, {
243
+ message: (
244
+ // i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
245
+ "A catalogue slug starts with a letter and holds lower-case letters, digits and underscores."
246
+ )
247
+ });
248
+ var CatalogCatalogueUpsertSchema = z.object({
249
+ nameSingular: z.string().trim().min(1).max(120).optional(),
250
+ namePlural: z.string().trim().min(1).max(120).optional(),
251
+ /** A lucide icon name, as the object settings offer them. */
252
+ icon: z.string().trim().max(64).nullable().optional()
253
+ }).strict();
254
+ var CatalogFieldTypeSchema = z.enum([
255
+ "text",
256
+ "number",
257
+ "select",
258
+ "checkbox",
259
+ "url",
260
+ "date",
261
+ "content_text",
262
+ "repeater",
263
+ "cta",
264
+ "configurator"
265
+ ]);
266
+ var CATALOG_FIELD_TYPES = CatalogFieldTypeSchema.options;
267
+ var CatalogFieldOptionSchema = z.object({
268
+ value: z.string().trim().min(1).max(120),
269
+ label: z.string().trim().min(1).max(120).optional()
270
+ }).strict();
271
+ var CatalogAttributeUpsertSchema = z.object({
272
+ label: z.string().trim().min(1).max(120).optional(),
273
+ type: CatalogFieldTypeSchema.optional(),
274
+ /** For a select: whether a product may carry more than one choice. */
275
+ multiple: z.boolean().optional(),
276
+ /** Choices to make sure exist; never removes one that is not listed. */
277
+ options: z.array(CatalogFieldOptionSchema).max(500).optional(),
278
+ /** Choices to take off the list, by value. */
279
+ removeOptions: z.array(z.string().trim().min(1).max(120)).max(500).optional(),
280
+ /** Whether the public API may show this field at all. */
281
+ public: z.boolean().optional(),
282
+ /**
283
+ * The field's settings, replaced as a whole. Checked against the same
284
+ * schema the object builder saves with, per type: a repeater's `display`,
285
+ * `preset`, `itemFields` and `titleKey`; a call to action's `secondary`;
286
+ * a configurator's whole structure. An item field keeps its type once it
287
+ * exists.
288
+ */
289
+ config: z.record(z.string(), z.unknown()).optional()
290
+ }).strict();
291
+ var CONFIGURATOR_FILE_KINDS = ["model", "texture"];
292
+ var CONFIGURATOR_TEXTURE_CONTENT_TYPES = [
293
+ "image/webp",
294
+ "image/jpeg"
295
+ ];
296
+ var ConfiguratorFileUploadSchema = z.discriminatedUnion("step", [
297
+ z.object({
298
+ step: z.literal("start"),
299
+ /** The slug of the configurator field the file is for. */
300
+ field: CatalogFieldSlugSchema,
301
+ kind: z.enum(CONFIGURATOR_FILE_KINDS),
302
+ /** The bytes the caller is about to send, so a too-large file is refused before it travels. */
303
+ size: z.number().int().min(1),
304
+ /** For a texture: what kind of picture it is. A model is always glTF binary. */
305
+ contentType: z.enum(CONFIGURATOR_TEXTURE_CONTENT_TYPES).optional()
306
+ }).strict(),
307
+ z.object({
308
+ step: z.literal("finish"),
309
+ field: CatalogFieldSlugSchema,
310
+ kind: z.enum(CONFIGURATOR_FILE_KINDS),
311
+ /** The `uploadPath` `start` answered with. */
312
+ uploadPath: z.string().max(200)
313
+ }).strict()
314
+ ]);
10
315
 
11
316
  // src/tool-answers.ts
12
317
  import { CrmApiError } from "@solumflow-app/crm-client";
13
- function answer(text, data) {
318
+ import { z as z2 } from "zod";
319
+ function answer(text2, data) {
14
320
  return {
15
- content: [{ type: "text", text }],
321
+ content: [{ type: "text", text: text2 }],
16
322
  structuredContent: { result: data }
17
323
  };
18
324
  }
19
- function refusal(error) {
325
+ function refusal(error, scope) {
20
326
  if (!(error instanceof CrmApiError)) {
21
327
  throw error;
22
328
  }
23
329
  const lines = [`${error.code}: ${error.message}`];
24
- const next = NEXT_STEP[error.code];
330
+ const next = error.code === "forbidden" ? `The key may not do this. It needs to be a secret key with the scope "${scope}".` : NEXT_STEP[error.code];
25
331
  if (next) {
26
332
  lines.push(next);
27
333
  }
@@ -35,7 +341,6 @@ function refusal(error) {
35
341
  }
36
342
  var NEXT_STEP = {
37
343
  unauthorized: "The key is not recognised or was revoked. Ask the account owner for a new secret key.",
38
- forbidden: 'The key may not do this. It needs to be a secret key with the scope "website:manage".',
39
344
  feature_unavailable: "The module this needs is switched off for the account. Ask somebody to switch it on in the app.",
40
345
  rate_limited: "Too many requests. Wait a moment and try again.",
41
346
  internal_error: "Something went wrong in the app. Trying again is safe."
@@ -45,28 +350,65 @@ function codeBlock(snippet) {
45
350
  ${snippet}
46
351
  \`\`\``;
47
352
  }
353
+ var page = {
354
+ cursor: z2.string().optional().describe("The nextCursor of the previous page; leave out for the first."),
355
+ limit: z2.number().int().min(1).max(100).optional()
356
+ };
357
+ function more(nextCursor) {
358
+ return nextCursor ? `More on the next page: cursor ${nextCursor}` : "";
359
+ }
360
+ function money(cents2, currency) {
361
+ return `${(cents2 / 100).toFixed(2)} ${currency.toUpperCase()}`;
362
+ }
48
363
 
49
364
  // src/catalog-tools.ts
365
+ var objectInput = {
366
+ object: CatalogObjectSlugSchema.optional().describe(
367
+ "The slug of the catalogue (see list_catalogs). Leave out for the account\u2019s own catalogue."
368
+ )
369
+ };
50
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.';
51
- var slugOrId = z.string().min(1).max(200).describe("The address (slug) or the id of the product.");
371
+ var slugOrId = z3.string().min(1).max(200).describe("The address (slug) or the id of the product.");
52
372
  function registerCatalogTools(server, client) {
53
373
  server.registerTool(
54
374
  "list_catalog_fields",
55
375
  {
56
376
  title: "List the public fields of the catalogue",
57
377
  description: "The custom fields the catalogue publishes on its products, with their type, choices and, for a content block, how it is built. Read this once before building a product page: it says which blocks exist and what each one looks like. " + CONTENT_BLOCKS,
58
- inputSchema: {},
378
+ inputSchema: { ...objectInput },
59
379
  annotations: { readOnlyHint: true, idempotentHint: true }
60
380
  },
61
- async () => {
381
+ async ({ object: object2 }) => {
62
382
  try {
63
- const fields = await client.getFields();
383
+ const fields = await client.getFields(object2 ? { object: object2 } : void 0);
64
384
  return answer(
65
385
  fields.length === 0 ? "The catalogue publishes no custom fields." : fields.map(describeField).join("\n"),
66
386
  fields
67
387
  );
68
388
  } catch (error) {
69
- return refusal(error);
389
+ return refusal(error, "catalog:read");
390
+ }
391
+ }
392
+ );
393
+ server.registerTool(
394
+ "list_catalogs",
395
+ {
396
+ title: "List the product catalogues",
397
+ description: "The product catalogues this key can write to, the account\u2019s own first. A catalogue other than the account\u2019s own is named by its slug in `object` on every catalogue tool; upsert_catalog makes one.",
398
+ inputSchema: {},
399
+ annotations: { readOnlyHint: true, idempotentHint: true }
400
+ },
401
+ async () => {
402
+ try {
403
+ const catalogs = await client.getCatalogs();
404
+ return answer(
405
+ catalogs.map(
406
+ (catalog) => `${catalog.name} (${catalog.slug})${catalog.isDefault ? ", the account\u2019s own" : ""}`
407
+ ).join("\n") || "This account has no product catalogue.",
408
+ catalogs
409
+ );
410
+ } catch (error) {
411
+ return refusal(error, "catalog:read");
70
412
  }
71
413
  }
72
414
  );
@@ -90,10 +432,121 @@ function registerCatalogTools(server, client) {
90
432
  product
91
433
  );
92
434
  } catch (error) {
93
- return refusal(error);
435
+ return refusal(error, "catalog:read");
94
436
  }
95
437
  }
96
438
  );
439
+ server.registerTool(
440
+ "list_products",
441
+ {
442
+ title: "List the published products",
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 (from list_categories), or to one catalogue by the slug of its object. Read get_product for everything a product page needs. May be a minute old.",
444
+ inputSchema: {
445
+ ...page,
446
+ category: z3.string().uuid().optional().describe("A category id; includes its subcategories."),
447
+ object: z3.string().min(1).max(63).optional().describe(
448
+ 'One catalogue, by the slug of its object, such as "product".'
449
+ )
450
+ },
451
+ annotations: { readOnlyHint: true, idempotentHint: true }
452
+ },
453
+ async (input) => {
454
+ try {
455
+ const list = await client.getProducts(input);
456
+ return answer(
457
+ list.data.length === 0 ? "No published products match." : [...list.data.map(describeProduct), more(list.nextCursor)].filter(Boolean).join("\n"),
458
+ list
459
+ );
460
+ } catch (error) {
461
+ return refusal(error, "catalog:read");
462
+ }
463
+ }
464
+ );
465
+ server.registerTool(
466
+ "list_categories",
467
+ {
468
+ title: "List the published categories",
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.',
470
+ inputSchema: {
471
+ ...page,
472
+ parent: z3.union([z3.literal("root"), z3.string().uuid()]).optional().describe('"root" or the id of a category.')
473
+ },
474
+ annotations: { readOnlyHint: true, idempotentHint: true }
475
+ },
476
+ async (input) => {
477
+ try {
478
+ const list = await client.getCategories(input);
479
+ return answer(
480
+ list.data.length === 0 ? "No published categories." : [...list.data.map(describeCategory), more(list.nextCursor)].filter(Boolean).join("\n"),
481
+ list
482
+ );
483
+ } catch (error) {
484
+ return refusal(error, "catalog:read");
485
+ }
486
+ }
487
+ );
488
+ server.registerTool(
489
+ "get_category",
490
+ {
491
+ 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.",
493
+ inputSchema: {
494
+ slugOrId: z3.string().min(1).max(200).describe("The address (slug) or the id of the category."),
495
+ ...page
496
+ },
497
+ annotations: { readOnlyHint: true, idempotentHint: true }
498
+ },
499
+ async ({ slugOrId: wanted, ...listOptions }) => {
500
+ try {
501
+ const category = await client.getCategory(wanted, listOptions);
502
+ if (!category) {
503
+ return answer(`No category "${wanted}" on this account.`, null);
504
+ }
505
+ const { data, nextCursor } = category;
506
+ return answer(
507
+ [
508
+ `${data.name} (${data.slug}), ${data.productCount} products.`,
509
+ ...data.products.map(describeProduct),
510
+ more(nextCursor)
511
+ ].filter(Boolean).join("\n"),
512
+ category
513
+ );
514
+ } catch (error) {
515
+ return refusal(error, "catalog:read");
516
+ }
517
+ }
518
+ );
519
+ server.registerTool(
520
+ "get_product_availability",
521
+ {
522
+ title: "Whether a product can be bought now",
523
+ description: "Live, not cached: whether a visitor can buy the product right now. Never how many are left. For a stock badge that must be current; the inStock of a listing may be a minute old.",
524
+ inputSchema: { slugOrId },
525
+ annotations: { readOnlyHint: true, idempotentHint: true }
526
+ },
527
+ async ({ slugOrId: wanted }) => {
528
+ try {
529
+ const status = await client.getAvailability(wanted);
530
+ if (!status) {
531
+ return answer(`No product "${wanted}" on this account.`, null);
532
+ }
533
+ return answer(
534
+ `${status.slug}: ${status.inStock ? "in stock" : "out of stock"}.`,
535
+ status
536
+ );
537
+ } catch (error) {
538
+ return refusal(error, "catalog:read");
539
+ }
540
+ }
541
+ );
542
+ }
543
+ function describeProduct(product) {
544
+ const stock = product.inStock ? "" : ", out of stock";
545
+ return `- ${product.name} (${product.slug}): from ${money(product.priceFromCents, product.currency)}${stock}`;
546
+ }
547
+ function describeCategory(category) {
548
+ const path = category.path.map((step) => step.name).join(" / ");
549
+ return `- ${path || category.name} (${category.slug}, id ${category.id}): ${category.productCount} products`;
97
550
  }
98
551
  function describeField(field) {
99
552
  if (field.type !== "repeater") {
@@ -105,8 +558,159 @@ function describeField(field) {
105
558
  return `- ${field.slug}: ${field.label}, repeater (${field.display ?? "list"}${preset}; items: ${items}${title})`;
106
559
  }
107
560
 
561
+ // src/catalog-write-tools.ts
562
+ function target(object2) {
563
+ return object2 ? { object: object2 } : void 0;
564
+ }
565
+ var FIELD_KINDS = 'Kinds: text, number, select, checkbox, 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 }], titleKey?: the item field that names an item }). 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
+ 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
+ function registerCatalogWriteTools(server, client) {
568
+ server.registerTool(
569
+ "upsert_product",
570
+ {
571
+ 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.',
573
+ inputSchema: {
574
+ externalRef: CatalogExternalRefSchema.describe(
575
+ "The id of the product in the system named by `source`."
576
+ ),
577
+ ...CatalogProductUpsertSchema.shape,
578
+ ...objectInput
579
+ },
580
+ annotations: { destructiveHint: true, idempotentHint: true }
581
+ },
582
+ async ({ externalRef, object: object2, ...input }) => {
583
+ try {
584
+ const product = await client.upsertProduct(
585
+ externalRef,
586
+ input,
587
+ target(object2)
588
+ );
589
+ const verb = product.created ? "Made" : "Changed";
590
+ return answer(
591
+ `${verb} ${product.name} (${product.slug ?? "no address"}), ${product.availability}; ${product.variants.length} variants; pictures ${product.images}.`,
592
+ product
593
+ );
594
+ } catch (error) {
595
+ return refusal(error, "catalog:write");
596
+ }
597
+ }
598
+ );
599
+ server.registerTool(
600
+ "archive_product",
601
+ {
602
+ title: "Take a product out of the catalogue",
603
+ description: IDENTITY + " Archiving takes the product and its variants off the website and out of the shop at once. It stays in the database for every invoice that names it, and upsert_product with `archived: false` brings it back. Ask the user before archiving anything they did not name.",
604
+ inputSchema: {
605
+ externalRef: CatalogExternalRefSchema.describe(
606
+ "The id of the product in the system named by `source`."
607
+ ),
608
+ ...CatalogProductArchiveSchema.shape,
609
+ ...objectInput
610
+ },
611
+ annotations: { destructiveHint: true, idempotentHint: true }
612
+ },
613
+ async ({ externalRef, source, object: object2 }) => {
614
+ try {
615
+ const result = await client.archiveProduct(
616
+ externalRef,
617
+ source,
618
+ target(object2)
619
+ );
620
+ return answer(
621
+ result.alreadyArchived ? "That product was already archived." : "Archived.",
622
+ result
623
+ );
624
+ } catch (error) {
625
+ return refusal(error, "catalog:write");
626
+ }
627
+ }
628
+ );
629
+ server.registerTool(
630
+ "upsert_category",
631
+ {
632
+ 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.",
634
+ inputSchema: {
635
+ slug: CatalogWebSlugSchema.describe(
636
+ "The address of the category: lower case, digits and dashes."
637
+ ),
638
+ ...CatalogCategoryUpsertSchema.shape,
639
+ ...objectInput
640
+ },
641
+ annotations: { destructiveHint: false, idempotentHint: true }
642
+ },
643
+ async ({ slug, object: object2, ...input }) => {
644
+ try {
645
+ const category = await client.upsertCategory(
646
+ slug,
647
+ input,
648
+ target(object2)
649
+ );
650
+ return answer(
651
+ `${category.created ? "Made" : "Changed"} ${category.name} (${category.slug}).`,
652
+ category
653
+ );
654
+ } catch (error) {
655
+ return refusal(error, "catalog:write");
656
+ }
657
+ }
658
+ );
659
+ server.registerTool(
660
+ "upsert_field",
661
+ {
662
+ 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.",
664
+ inputSchema: {
665
+ slug: CatalogFieldSlugSchema.describe(
666
+ "The slug of the field: a lower-case letter, then lower case, digits and underscores."
667
+ ),
668
+ ...CatalogAttributeUpsertSchema.shape,
669
+ ...objectInput
670
+ },
671
+ annotations: { destructiveHint: true, idempotentHint: true }
672
+ },
673
+ async ({ slug, object: object2, ...input }) => {
674
+ try {
675
+ const field = await client.upsertField(slug, input, target(object2));
676
+ return answer(
677
+ `${field.created ? "Made" : "Changed"} ${field.label} (${field.slug}), ${field.type}, ${field.public ? "public" : "not public"}.`,
678
+ field
679
+ );
680
+ } catch (error) {
681
+ return refusal(error, "catalog:write");
682
+ }
683
+ }
684
+ );
685
+ server.registerTool(
686
+ "upsert_catalog",
687
+ {
688
+ title: "Make or rename a product catalogue",
689
+ description: 'A product catalogue of its own beside the account\u2019s products, such as "Fireplaces", known by its slug: sending the same slug again renames it. A new one needs both names, one product and many. Its fields, categories and products are then written with `object` set to this slug. Make one only when the user asks for a separate catalogue; most products belong in the account\u2019s own.',
690
+ inputSchema: {
691
+ slug: CatalogObjectSlugSchema.describe(
692
+ "The slug of the catalogue: a lower-case letter, then lower case, digits and underscores."
693
+ ),
694
+ ...CatalogCatalogueUpsertSchema.shape
695
+ },
696
+ annotations: { destructiveHint: false, idempotentHint: true }
697
+ },
698
+ async ({ slug, ...input }) => {
699
+ try {
700
+ const catalog = await client.upsertCatalog(slug, input);
701
+ return answer(
702
+ `${catalog.created ? "Made" : "Changed"} ${catalog.namePlural} (${catalog.slug}).`,
703
+ catalog
704
+ );
705
+ } catch (error) {
706
+ return refusal(error, "catalog:write");
707
+ }
708
+ }
709
+ );
710
+ }
711
+
108
712
  // src/configurator-tools.ts
109
- import { z as z5 } from "zod";
713
+ import { z as z7 } from "zod";
110
714
 
111
715
  // ../features/product-configurator/src/shared/configurator-limits.ts
112
716
  var CONFIGURATOR_LIMITS = {
@@ -155,11 +759,11 @@ function issue(path, code, params) {
155
759
  return params ? { path, code, params } : { path, code };
156
760
  }
157
761
  function formatIssuePath(path) {
158
- return path.reduce((text, part) => {
762
+ return path.reduce((text2, part) => {
159
763
  if (typeof part === "number") {
160
- return `${text}[${part}]`;
764
+ return `${text2}[${part}]`;
161
765
  }
162
- return text ? `${text}.${part}` : part;
766
+ return text2 ? `${text2}.${part}` : part;
163
767
  }, "");
164
768
  }
165
769
 
@@ -181,11 +785,11 @@ function tokenize(source) {
181
785
  while (end < source.length && /[0-9.]/.test(source.charAt(end))) {
182
786
  end += 1;
183
787
  }
184
- const text = source.slice(index, end);
185
- if (!/^(\d+(\.\d+)?|\.\d+)$/.test(text)) {
788
+ const text2 = source.slice(index, end);
789
+ if (!/^(\d+(\.\d+)?|\.\d+)$/.test(text2)) {
186
790
  return null;
187
791
  }
188
- tokens.push({ type: "number", value: Number(text) });
792
+ tokens.push({ type: "number", value: Number(text2) });
189
793
  index = end;
190
794
  continue;
191
795
  }
@@ -472,16 +1076,16 @@ function evaluateFormulaSource(source, scope) {
472
1076
  }
473
1077
 
474
1078
  // ../features/product-configurator/src/shared/geometry-document.schema.ts
475
- import { z as z2 } from "zod";
1079
+ import { z as z4 } from "zod";
476
1080
  var CONFIGURATOR_NAME_PATTERN = /^[a-z][a-z0-9_]{0,39}$/;
477
1081
  var CONFIGURATOR_CODE_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/;
478
- var ConfiguratorNameSchema = z2.string().regex(CONFIGURATOR_NAME_PATTERN, "configurator.invalidName");
479
- var ConfiguratorCodeSchema = z2.string().regex(CONFIGURATOR_CODE_PATTERN, "configurator.invalidCode");
480
- var FormulaSchema = z2.union([
481
- z2.number().finite(),
482
- z2.string().trim().min(1).max(CONFIGURATOR_LIMITS.formulaChars, "configurator.formulaTooLong")
1082
+ var ConfiguratorNameSchema = z4.string().regex(CONFIGURATOR_NAME_PATTERN, "configurator.invalidName");
1083
+ var ConfiguratorCodeSchema = z4.string().regex(CONFIGURATOR_CODE_PATTERN, "configurator.invalidCode");
1084
+ var FormulaSchema = z4.union([
1085
+ z4.number().finite(),
1086
+ z4.string().trim().min(1).max(CONFIGURATOR_LIMITS.formulaChars, "configurator.formulaTooLong")
483
1087
  ]);
484
- var Vector3Schema = z2.tuple([FormulaSchema, FormulaSchema, FormulaSchema]);
1088
+ var Vector3Schema = z4.tuple([FormulaSchema, FormulaSchema, FormulaSchema]);
485
1089
  var PRIMITIVE_TYPES = [
486
1090
  "box",
487
1091
  "rounded_top_box",
@@ -524,86 +1128,93 @@ var PrimitiveNodeFields = {
524
1128
  size: Vector3Schema,
525
1129
  at: Vector3Schema,
526
1130
  visible: FormulaSchema.optional(),
527
- params: z2.record(ConfiguratorNameSchema, FormulaSchema).optional()
1131
+ params: z4.record(ConfiguratorNameSchema, FormulaSchema).optional()
528
1132
  };
529
- var PrimitiveNodeSchema = z2.strictObject({
1133
+ var PrimitiveNodeSchema = z4.strictObject({
530
1134
  ...PrimitiveNodeFields,
531
- type: z2.enum(PRIMITIVE_TYPES).exclude(["model"])
1135
+ type: z4.enum(PRIMITIVE_TYPES).exclude(["model"])
532
1136
  });
533
- var ModelPathSchema = z2.string().regex(CONFIGURATOR_MODEL_PATH_PATTERN, "configurator.invalidModelPath");
534
- var ServedFileUrlSchema = z2.url({ protocol: /^https?$/ });
1137
+ var ModelPathSchema = z4.string().regex(CONFIGURATOR_MODEL_PATH_PATTERN, "configurator.invalidModelPath");
1138
+ var ServedFileUrlSchema = z4.url({ protocol: /^https?$/ });
535
1139
  function geometryNodeSchema(modelFile) {
536
- const ModelNodeSchema = z2.strictObject({
1140
+ const ModelNodeSchema = z4.strictObject({
537
1141
  ...PrimitiveNodeFields,
538
- type: z2.literal("model"),
1142
+ type: z4.literal("model"),
539
1143
  model: modelFile
540
1144
  });
541
- const ModelPerChoiceNodeSchema = z2.strictObject({
1145
+ const ModelPerChoiceNodeSchema = z4.strictObject({
542
1146
  ...PrimitiveNodeFields,
543
- type: z2.literal("model"),
1147
+ type: z4.literal("model"),
544
1148
  follows: ConfiguratorNameSchema,
545
- models: z2.record(ConfiguratorCodeSchema, modelFile).refine(
1149
+ models: z4.record(ConfiguratorCodeSchema, modelFile).refine(
546
1150
  (models) => Object.keys(models).length <= CONFIGURATOR_LIMITS.presets,
547
1151
  { message: "configurator.tooMany" }
548
1152
  )
549
1153
  });
550
- const node = z2.lazy(
551
- () => z2.union([
1154
+ const node = z4.lazy(
1155
+ () => z4.union([
552
1156
  PrimitiveNodeSchema,
553
1157
  ModelNodeSchema,
554
1158
  ModelPerChoiceNodeSchema,
555
- z2.strictObject({
1159
+ z4.strictObject({
556
1160
  id: ConfiguratorNameSchema,
557
- type: z2.literal("repeat"),
1161
+ type: z4.literal("repeat"),
558
1162
  count: FormulaSchema,
559
- index: z2.enum(["i", "j"]).optional(),
1163
+ index: z4.enum(["i", "j"]).optional(),
560
1164
  visible: FormulaSchema.optional(),
561
- children: z2.array(node).min(1)
1165
+ children: z4.array(node).min(1)
562
1166
  })
563
1167
  ])
564
1168
  );
565
1169
  return node;
566
1170
  }
567
1171
  var GeometryNodeSchema = geometryNodeSchema(ModelPathSchema);
568
- var GeometryBindingSchema = z2.strictObject({
1172
+ var GeometryBindingSchema = z4.strictObject({
569
1173
  param: ConfiguratorNameSchema,
570
- map: z2.record(ConfiguratorCodeSchema, z2.number().finite()).optional()
1174
+ map: z4.record(ConfiguratorCodeSchema, z4.number().finite()).optional()
571
1175
  });
572
- var GeometryInputSchema = z2.strictObject({
1176
+ var GeometryInputSchema = z4.strictObject({
573
1177
  name: ConfiguratorNameSchema,
574
1178
  binding: GeometryBindingSchema
575
1179
  });
576
- var GeometrySlotSchema = z2.strictObject({
1180
+ var GeometrySlotSchema = z4.strictObject({
577
1181
  name: ConfiguratorNameSchema,
578
1182
  param: ConfiguratorNameSchema.optional(),
579
1183
  material: ConfiguratorCodeSchema.optional(),
580
- color: z2.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor").optional()
1184
+ color: z4.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor").optional(),
1185
+ /**
1186
+ * Which way the grain of a picture runs on this part: `horizontal` (the
1187
+ * default) as the picture is drawn, `vertical` turned a quarter. It belongs
1188
+ * to the part, not the material: the same oak lies on a cabinet and stands
1189
+ * on a tall wall panel.
1190
+ */
1191
+ grain: z4.enum(["horizontal", "vertical"]).optional()
581
1192
  });
582
- var GeometryAnnotationSchema = z2.strictObject({
1193
+ var GeometryAnnotationSchema = z4.strictObject({
583
1194
  id: ConfiguratorNameSchema,
584
- axis: z2.enum(["x", "y"]),
1195
+ axis: z4.enum(["x", "y"]),
585
1196
  from: FormulaSchema,
586
1197
  to: FormulaSchema,
587
1198
  at: FormulaSchema,
588
- label: z2.string().trim().min(1).max(80).optional()
1199
+ label: z4.string().trim().min(1).max(80).optional()
589
1200
  });
590
1201
  function geometryDocumentSchema(modelFile) {
591
- return z2.strictObject({
1202
+ return z4.strictObject({
592
1203
  /** Rises with every new upload, so a product page can say which one it shows. */
593
- version: z2.number().int().min(1),
1204
+ version: z4.number().int().min(1),
594
1205
  /** The starter document this one was copied from, for information only. */
595
- startedFrom: z2.string().trim().max(80).optional(),
596
- inputs: z2.array(GeometryInputSchema).max(CONFIGURATOR_LIMITS.geometryInputs),
597
- constants: z2.record(ConfiguratorNameSchema, z2.number().finite()),
598
- slots: z2.array(GeometrySlotSchema).min(1).max(CONFIGURATOR_LIMITS.geometrySlots),
599
- root: z2.array(geometryNodeSchema(modelFile)).min(1),
600
- camera: z2.strictObject({
1206
+ startedFrom: z4.string().trim().max(80).optional(),
1207
+ inputs: z4.array(GeometryInputSchema).max(CONFIGURATOR_LIMITS.geometryInputs),
1208
+ constants: z4.record(ConfiguratorNameSchema, z4.number().finite()),
1209
+ slots: z4.array(GeometrySlotSchema).min(1).max(CONFIGURATOR_LIMITS.geometrySlots),
1210
+ root: z4.array(geometryNodeSchema(modelFile)).min(1),
1211
+ camera: z4.strictObject({
601
1212
  /** Degrees around the vertical axis, 0 is straight on. */
602
- azimuth: z2.number().min(-90).max(90),
1213
+ azimuth: z4.number().min(-90).max(90),
603
1214
  /** Degrees above the horizon. */
604
- elevation: z2.number().min(-10).max(80)
1215
+ elevation: z4.number().min(-10).max(80)
605
1216
  }).optional(),
606
- annotations: z2.array(GeometryAnnotationSchema).max(CONFIGURATOR_LIMITS.annotations)
1217
+ annotations: z4.array(GeometryAnnotationSchema).max(CONFIGURATOR_LIMITS.annotations)
607
1218
  });
608
1219
  }
609
1220
  var GeometryDocumentSchema = geometryDocumentSchema(ModelPathSchema);
@@ -893,98 +1504,98 @@ function followProblems(follows, path, context) {
893
1504
  }
894
1505
 
895
1506
  // ../features/product-configurator/src/shared/configurator-structure.schema.ts
896
- import { z as z3 } from "zod";
897
- var LabelSchema = z3.string().trim().min(1).max(80);
1507
+ import { z as z5 } from "zod";
1508
+ var LabelSchema = z5.string().trim().min(1).max(80);
898
1509
  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)$/;
899
- var TexturePathSchema = z3.string().regex(CONFIGURATOR_TEXTURE_PATH_PATTERN, "configurator.invalidTexturePath");
1510
+ var TexturePathSchema = z5.string().regex(CONFIGURATOR_TEXTURE_PATH_PATTERN, "configurator.invalidTexturePath");
900
1511
  var ParameterBase = {
901
1512
  key: ConfiguratorNameSchema,
902
1513
  label: LabelSchema,
903
1514
  tab: ConfiguratorNameSchema
904
1515
  };
905
- var ConfiguratorParameterSchema = z3.discriminatedUnion("kind", [
906
- z3.strictObject({
1516
+ var ConfiguratorParameterSchema = z5.discriminatedUnion("kind", [
1517
+ z5.strictObject({
907
1518
  ...ParameterBase,
908
- kind: z3.literal("number"),
1519
+ kind: z5.literal("number"),
909
1520
  /** `mm` is a measure, `count` a number of things (compartments). */
910
- unit: z3.enum(["mm", "count"]),
911
- step: z3.number().positive().finite(),
1521
+ unit: z5.enum(["mm", "count"]),
1522
+ step: z5.number().positive().finite(),
912
1523
  /** The widest range any model may offer; a model narrows it in its value. */
913
- min: z3.number().finite(),
914
- max: z3.number().finite()
1524
+ min: z5.number().finite(),
1525
+ max: z5.number().finite()
915
1526
  }),
916
- z3.strictObject({
1527
+ z5.strictObject({
917
1528
  ...ParameterBase,
918
- kind: z3.literal("choice"),
1529
+ kind: z5.literal("choice"),
919
1530
  list: ConfiguratorNameSchema
920
1531
  }),
921
- z3.strictObject({ ...ParameterBase, kind: z3.literal("material") }),
922
- z3.strictObject({ ...ParameterBase, kind: z3.literal("toggle") })
1532
+ z5.strictObject({ ...ParameterBase, kind: z5.literal("material") }),
1533
+ z5.strictObject({ ...ParameterBase, kind: z5.literal("toggle") })
923
1534
  ]);
924
- var choiceListSchema = (picture) => z3.strictObject({
1535
+ var choiceListSchema = (picture) => z5.strictObject({
925
1536
  key: ConfiguratorNameSchema,
926
1537
  label: LabelSchema,
927
- options: z3.array(
928
- z3.strictObject({
1538
+ options: z5.array(
1539
+ z5.strictObject({
929
1540
  code: ConfiguratorCodeSchema,
930
1541
  label: LabelSchema,
931
1542
  image: picture.optional()
932
1543
  })
933
1544
  ).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
934
1545
  });
935
- var materialSchema = (picture) => z3.strictObject({
1546
+ var materialSchema = (picture) => z5.strictObject({
936
1547
  code: ConfiguratorCodeSchema,
937
1548
  label: LabelSchema,
938
- color: z3.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor"),
1549
+ color: z5.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor"),
939
1550
  texture: picture.optional(),
940
1551
  /** Millimetres one repeat of the texture covers; the two maps below share it. */
941
- textureSize: z3.number().positive().max(1e4).optional(),
1552
+ textureSize: z5.number().positive().max(1e4).optional(),
942
1553
  /** Relief that moves with the light: a tangent-space normal map. */
943
1554
  normalMap: picture.optional(),
944
1555
  /** How deep the relief reads; 1 is the map as drawn. */
945
- normalScale: z3.number().min(0).max(3).optional(),
1556
+ normalScale: z5.number().min(0).max(3).optional(),
946
1557
  /** Where the surface is matte and where it shines (the green channel), times `roughness`. */
947
1558
  roughnessMap: picture.optional(),
948
- roughness: z3.number().min(0).max(1).optional(),
949
- metalness: z3.number().min(0).max(1).optional(),
1559
+ roughness: z5.number().min(0).max(1).optional(),
1560
+ metalness: z5.number().min(0).max(1).optional(),
950
1561
  /** How much of what is behind it shows through: glass in front of a flame. Absent is solid. */
951
- opacity: z3.number().min(0.05).max(1).optional(),
1562
+ opacity: z5.number().min(0.05).max(1).optional(),
952
1563
  /** Gives light rather than reflecting it: a flame, a LED. */
953
- emissive: z3.boolean().optional(),
1564
+ emissive: z5.boolean().optional(),
954
1565
  priceGroup: ConfiguratorCodeSchema.optional()
955
1566
  });
956
1567
  var ConfiguratorChoiceListSchema = choiceListSchema(TexturePathSchema);
957
1568
  var ConfiguratorMaterialSchema = materialSchema(TexturePathSchema);
958
- var ConditionValueSchema = z3.union([
959
- z3.number().finite(),
960
- z3.boolean(),
1569
+ var ConditionValueSchema = z5.union([
1570
+ z5.number().finite(),
1571
+ z5.boolean(),
961
1572
  ConfiguratorCodeSchema,
962
- z3.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
1573
+ z5.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
963
1574
  ]);
964
- var ConfiguratorConditionSchema = z3.strictObject({
1575
+ var ConfiguratorConditionSchema = z5.strictObject({
965
1576
  param: ConfiguratorNameSchema,
966
- op: z3.enum(["eq", "neq", "in", "gte", "lte"]),
1577
+ op: z5.enum(["eq", "neq", "in", "gte", "lte"]),
967
1578
  value: ConditionValueSchema
968
1579
  });
969
- var ConfiguratorRuleSchema = z3.discriminatedUnion("kind", [
1580
+ var ConfiguratorRuleSchema = z5.discriminatedUnion("kind", [
970
1581
  /** Show `param` only while `when` holds. */
971
- z3.strictObject({
972
- kind: z3.literal("visible"),
1582
+ z5.strictObject({
1583
+ kind: z5.literal("visible"),
973
1584
  param: ConfiguratorNameSchema,
974
1585
  when: ConfiguratorConditionSchema
975
1586
  }),
976
1587
  /** Narrow a number parameter: `max cabinet_height = total_height - 300`. */
977
- z3.strictObject({
978
- kind: z3.literal("limit"),
1588
+ z5.strictObject({
1589
+ kind: z5.literal("limit"),
979
1590
  param: ConfiguratorNameSchema,
980
- bound: z3.enum(["min", "max"]),
1591
+ bound: z5.enum(["min", "max"]),
981
1592
  formula: FormulaSchema
982
1593
  }),
983
1594
  /** While `when` holds, these codes of a choice or material are not offered. */
984
- z3.strictObject({
985
- kind: z3.literal("exclude"),
1595
+ z5.strictObject({
1596
+ kind: z5.literal("exclude"),
986
1597
  param: ConfiguratorNameSchema,
987
- codes: z3.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList),
1598
+ codes: z5.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList),
988
1599
  when: ConfiguratorConditionSchema
989
1600
  })
990
1601
  ]);
@@ -994,23 +1605,23 @@ var PriceRuleBase = {
994
1605
  /** How many times the amount counts, e.g. `compartments`. Absent is once. */
995
1606
  multiplier: FormulaSchema.optional()
996
1607
  };
997
- var ConfiguratorPriceRuleSchema = z3.discriminatedUnion("kind", [
1608
+ var ConfiguratorPriceRuleSchema = z5.discriminatedUnion("kind", [
998
1609
  /** The surcharge the model gives the chosen option. */
999
- z3.strictObject({
1610
+ z5.strictObject({
1000
1611
  ...PriceRuleBase,
1001
- kind: z3.literal("choice"),
1612
+ kind: z5.literal("choice"),
1002
1613
  param: ConfiguratorNameSchema
1003
1614
  }),
1004
1615
  /** The surcharge of the chosen material's price group. */
1005
- z3.strictObject({
1616
+ z5.strictObject({
1006
1617
  ...PriceRuleBase,
1007
- kind: z3.literal("material"),
1618
+ kind: z5.literal("material"),
1008
1619
  param: ConfiguratorNameSchema
1009
1620
  }),
1010
1621
  /** An amount, counted while a switch is on. */
1011
- z3.strictObject({
1622
+ z5.strictObject({
1012
1623
  ...PriceRuleBase,
1013
- kind: z3.literal("toggle"),
1624
+ kind: z5.literal("toggle"),
1014
1625
  param: ConfiguratorNameSchema,
1015
1626
  amount: ConfiguratorNameSchema
1016
1627
  }),
@@ -1018,10 +1629,10 @@ var ConfiguratorPriceRuleSchema = z3.discriminatedUnion("kind", [
1018
1629
  * A price on measure: `max(0, total_width - 1800) / 1000 * meter_price`.
1019
1630
  * Reads number parameters (in their unit) and amounts (in cents).
1020
1631
  */
1021
- z3.strictObject({
1632
+ z5.strictObject({
1022
1633
  key: ConfiguratorNameSchema,
1023
1634
  label: LabelSchema,
1024
- kind: z3.literal("formula"),
1635
+ kind: z5.literal("formula"),
1025
1636
  formula: FormulaSchema
1026
1637
  })
1027
1638
  ]);
@@ -1031,43 +1642,43 @@ var CONFIGURATOR_SCENE_LIGHTINGS = [
1031
1642
  "daylight"
1032
1643
  ];
1033
1644
  var CONFIGURATOR_SCENE_EDGES = ["soft", "sharp"];
1034
- var ConfiguratorSceneSchema = z3.strictObject({
1035
- lighting: z3.enum(CONFIGURATOR_SCENE_LIGHTINGS).optional(),
1036
- edges: z3.enum(CONFIGURATOR_SCENE_EDGES).optional()
1645
+ var ConfiguratorSceneSchema = z5.strictObject({
1646
+ lighting: z5.enum(CONFIGURATOR_SCENE_LIGHTINGS).optional(),
1647
+ edges: z5.enum(CONFIGURATOR_SCENE_EDGES).optional()
1037
1648
  });
1038
- var structureObjectSchema = (files) => z3.strictObject({
1039
- schemaVersion: z3.literal(1),
1649
+ var structureObjectSchema = (files) => z5.strictObject({
1650
+ schemaVersion: z5.literal(1),
1040
1651
  geometry: geometryDocumentSchema(files.model),
1041
1652
  /**
1042
1653
  * How the scene is presented. Part of the structure and not of the shape
1043
1654
  * document, so the owner sets it under `crm.manage` like a material.
1044
1655
  */
1045
1656
  scene: ConfiguratorSceneSchema.optional(),
1046
- tabs: z3.array(
1047
- z3.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
1657
+ tabs: z5.array(
1658
+ z5.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
1048
1659
  ).min(1).max(CONFIGURATOR_LIMITS.tabs),
1049
- parameters: z3.array(ConfiguratorParameterSchema).max(CONFIGURATOR_LIMITS.parameters),
1050
- choiceLists: z3.array(choiceListSchema(files.picture)).max(CONFIGURATOR_LIMITS.choiceLists),
1051
- materials: z3.array(materialSchema(files.picture)).max(CONFIGURATOR_LIMITS.materials),
1052
- priceGroups: z3.array(
1053
- z3.strictObject({ code: ConfiguratorCodeSchema, label: LabelSchema })
1660
+ parameters: z5.array(ConfiguratorParameterSchema).max(CONFIGURATOR_LIMITS.parameters),
1661
+ choiceLists: z5.array(choiceListSchema(files.picture)).max(CONFIGURATOR_LIMITS.choiceLists),
1662
+ materials: z5.array(materialSchema(files.picture)).max(CONFIGURATOR_LIMITS.materials),
1663
+ priceGroups: z5.array(
1664
+ z5.strictObject({ code: ConfiguratorCodeSchema, label: LabelSchema })
1054
1665
  ).max(CONFIGURATOR_LIMITS.priceGroups),
1055
1666
  /** Named amounts a model fills in and price rules read (`meter_price`). */
1056
- amounts: z3.array(
1057
- z3.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
1667
+ amounts: z5.array(
1668
+ z5.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
1058
1669
  ).max(CONFIGURATOR_LIMITS.amounts),
1059
1670
  /**
1060
1671
  * A list of fixed combinations a model offers, each setting some number
1061
1672
  * parameters at once: a fireplace and the niche it needs.
1062
1673
  */
1063
- presets: z3.strictObject({
1674
+ presets: z5.strictObject({
1064
1675
  label: LabelSchema,
1065
1676
  tab: ConfiguratorNameSchema,
1066
- sets: z3.array(ConfiguratorNameSchema).min(1).max(CONFIGURATOR_LIMITS.parameters),
1067
- required: z3.boolean()
1677
+ sets: z5.array(ConfiguratorNameSchema).min(1).max(CONFIGURATOR_LIMITS.parameters),
1678
+ required: z5.boolean()
1068
1679
  }).optional(),
1069
- rules: z3.array(ConfiguratorRuleSchema).max(CONFIGURATOR_LIMITS.rules),
1070
- priceRules: z3.array(ConfiguratorPriceRuleSchema).max(CONFIGURATOR_LIMITS.priceRules)
1680
+ rules: z5.array(ConfiguratorRuleSchema).max(CONFIGURATOR_LIMITS.rules),
1681
+ priceRules: z5.array(ConfiguratorPriceRuleSchema).max(CONFIGURATOR_LIMITS.priceRules)
1071
1682
  });
1072
1683
  var ConfiguratorStructureObjectSchema = structureObjectSchema({
1073
1684
  picture: TexturePathSchema,
@@ -1226,8 +1837,8 @@ function configuratorStructureProblems(structure) {
1226
1837
  };
1227
1838
  structure.rules.forEach((rule, index) => {
1228
1839
  const path = ["rules", index];
1229
- const target = parameters.get(rule.param);
1230
- if (!target) {
1840
+ const target2 = parameters.get(rule.param);
1841
+ if (!target2) {
1231
1842
  problems.push(
1232
1843
  issue([...path, "param"], "configurator.unknownParam", {
1233
1844
  key: rule.param
@@ -1236,7 +1847,7 @@ function configuratorStructureProblems(structure) {
1236
1847
  return;
1237
1848
  }
1238
1849
  if (rule.kind === "limit") {
1239
- if (target.kind !== "number") {
1850
+ if (target2.kind !== "number") {
1240
1851
  problems.push(
1241
1852
  issue([...path, "param"], "configurator.notANumberParam", {
1242
1853
  key: rule.param
@@ -1254,7 +1865,7 @@ function configuratorStructureProblems(structure) {
1254
1865
  return;
1255
1866
  }
1256
1867
  if (rule.kind === "exclude") {
1257
- const codes = codesOf(target, lists, materials);
1868
+ const codes = codesOf(target2, lists, materials);
1258
1869
  if (!codes) {
1259
1870
  problems.push(
1260
1871
  issue([...path, "param"], "configurator.notAChoiceParam", {
@@ -1333,8 +1944,8 @@ function configuratorStructureProblems(structure) {
1333
1944
  problems
1334
1945
  );
1335
1946
  }
1336
- const target = parameters.get(rule.param);
1337
- if (!target || target.kind !== rule.kind) {
1947
+ const target2 = parameters.get(rule.param);
1948
+ if (!target2 || target2.kind !== rule.kind) {
1338
1949
  problems.push(
1339
1950
  issue([...path, "param"], "configurator.priceRuleParamKind", {
1340
1951
  key: rule.param,
@@ -1483,55 +2094,55 @@ function parameterResolutionOrder(structure) {
1483
2094
  }
1484
2095
 
1485
2096
  // ../features/product-configurator/src/shared/configurator-value.schema.ts
1486
- import { z as z4 } from "zod";
1487
- var CentsSchema = z4.number().int().min(0).max(1e8);
2097
+ import { z as z6 } from "zod";
2098
+ var CentsSchema = z6.number().int().min(0).max(1e8);
1488
2099
  var PriceSchema = CentsSchema.nullable();
1489
- var ConfiguratorValueObjectSchema = z4.strictObject({
2100
+ var ConfiguratorValueObjectSchema = z6.strictObject({
1490
2101
  /** Null: the model has no price of its own, every quote is "open". */
1491
2102
  basePriceCents: PriceSchema,
1492
- numbers: z4.record(
2103
+ numbers: z6.record(
1493
2104
  ConfiguratorNameSchema,
1494
- z4.strictObject({
1495
- min: z4.number().finite(),
1496
- max: z4.number().finite(),
1497
- default: z4.number().finite()
2105
+ z6.strictObject({
2106
+ min: z6.number().finite(),
2107
+ max: z6.number().finite(),
2108
+ default: z6.number().finite()
1498
2109
  })
1499
2110
  ).default({}),
1500
2111
  /** Per choice list: the options this model offers, with their surcharge. */
1501
- choices: z4.record(
2112
+ choices: z6.record(
1502
2113
  ConfiguratorNameSchema,
1503
- z4.strictObject({
1504
- options: z4.record(
2114
+ z6.strictObject({
2115
+ options: z6.record(
1505
2116
  ConfiguratorCodeSchema,
1506
- z4.strictObject({ priceCents: PriceSchema })
2117
+ z6.strictObject({ priceCents: PriceSchema })
1507
2118
  ),
1508
2119
  default: ConfiguratorCodeSchema.optional()
1509
2120
  })
1510
2121
  ).default({}),
1511
2122
  /** Per material parameter: the materials this model offers. */
1512
- materials: z4.record(
2123
+ materials: z6.record(
1513
2124
  ConfiguratorNameSchema,
1514
- z4.strictObject({
1515
- offered: z4.array(ConfiguratorCodeSchema).max(CONFIGURATOR_LIMITS.materials),
2125
+ z6.strictObject({
2126
+ offered: z6.array(ConfiguratorCodeSchema).max(CONFIGURATOR_LIMITS.materials),
1516
2127
  default: ConfiguratorCodeSchema.optional()
1517
2128
  })
1518
2129
  ).default({}),
1519
2130
  /** Per switch: whether this model offers it, and how it starts. Absent is offered, off. */
1520
- toggles: z4.record(
2131
+ toggles: z6.record(
1521
2132
  ConfiguratorNameSchema,
1522
- z4.strictObject({ offered: z4.boolean(), default: z4.boolean() })
2133
+ z6.strictObject({ offered: z6.boolean(), default: z6.boolean() })
1523
2134
  ).default({}),
1524
2135
  /** The surcharge of each material price group. */
1525
- priceGroups: z4.record(ConfiguratorCodeSchema, PriceSchema).default({}),
2136
+ priceGroups: z6.record(ConfiguratorCodeSchema, PriceSchema).default({}),
1526
2137
  /** The amounts the structure names, such as a price per metre. */
1527
- amounts: z4.record(ConfiguratorNameSchema, PriceSchema).default({}),
1528
- presets: z4.array(
1529
- z4.strictObject({
2138
+ amounts: z6.record(ConfiguratorNameSchema, PriceSchema).default({}),
2139
+ presets: z6.array(
2140
+ z6.strictObject({
1530
2141
  code: ConfiguratorCodeSchema,
1531
- label: z4.string().trim().min(1).max(80),
2142
+ label: z6.string().trim().min(1).max(80),
1532
2143
  /** A product of the same account this preset stands for, e.g. the fireplace. */
1533
- productId: z4.string().uuid().nullable(),
1534
- values: z4.record(ConfiguratorNameSchema, z4.number().finite())
2144
+ productId: z6.string().uuid().nullable(),
2145
+ values: z6.record(ConfiguratorNameSchema, z6.number().finite())
1535
2146
  })
1536
2147
  ).max(CONFIGURATOR_LIMITS.presets).default([])
1537
2148
  });
@@ -1671,7 +2282,8 @@ function resolveConfiguration(params) {
1671
2282
  max,
1672
2283
  step: parameter.step,
1673
2284
  unit: parameter.unit,
1674
- fixed: fixedValue !== void 0
2285
+ fixed: fixedValue !== void 0,
2286
+ settled: fixedValue === void 0 && min === max
1675
2287
  });
1676
2288
  continue;
1677
2289
  }
@@ -1739,7 +2351,8 @@ function resolveConfiguration(params) {
1739
2351
  ...base,
1740
2352
  visible,
1741
2353
  value: visible ? current : null,
1742
- options
2354
+ options,
2355
+ settled: options.length === 1
1743
2356
  });
1744
2357
  }
1745
2358
  const parameters = structure.parameters.map((parameter) => resolved.get(parameter.key)).filter(
@@ -1770,7 +2383,8 @@ function resolveConfiguration(params) {
1770
2383
  }
1771
2384
  slots[slot.name] = {
1772
2385
  color: material?.color ?? slot.color ?? NEUTRAL_SLOT_COLOR,
1773
- material
2386
+ material,
2387
+ grain: slot.grain === "vertical" ? "vertical" : "horizontal"
1774
2388
  };
1775
2389
  }
1776
2390
  const normalised = { values: {} };
@@ -1809,8 +2423,8 @@ function expandGeometry(document, inputs, chosen = { choices: {} }) {
1809
2423
  const parts = [];
1810
2424
  const baseScope = { ...document.constants };
1811
2425
  for (const input of document.inputs) {
1812
- const number = inputs[input.name];
1813
- if (number === void 0 || !Number.isFinite(number)) {
2426
+ const number2 = inputs[input.name];
2427
+ if (number2 === void 0 || !Number.isFinite(number2)) {
1814
2428
  issues.push(
1815
2429
  issue(["inputs", input.name], "configurator.inputMissing", {
1816
2430
  name: input.name
@@ -1818,7 +2432,7 @@ function expandGeometry(document, inputs, chosen = { choices: {} }) {
1818
2432
  );
1819
2433
  continue;
1820
2434
  }
1821
- baseScope[input.name] = number;
2435
+ baseScope[input.name] = number2;
1822
2436
  }
1823
2437
  if (issues.length > 0) {
1824
2438
  return { ok: false, issues };
@@ -1910,11 +2524,11 @@ function expandGeometry(document, inputs, chosen = { choices: {} }) {
1910
2524
  }
1911
2525
  const params = {};
1912
2526
  for (const [key, formula] of Object.entries(node.params ?? {})) {
1913
- const number = evaluate(formula, scope, [...nodePath, "params", key]);
1914
- if (number === null) {
2527
+ const number2 = evaluate(formula, scope, [...nodePath, "params", key]);
2528
+ if (number2 === null) {
1915
2529
  return;
1916
2530
  }
1917
- params[key] = number;
2531
+ params[key] = number2;
1918
2532
  }
1919
2533
  parts.push({
1920
2534
  key: `${node.id}${keySuffix}`,
@@ -1987,8 +2601,17 @@ function modelOf(node, chosen) {
1987
2601
  }
1988
2602
 
1989
2603
  // src/configurator-tools.ts
1990
- 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 an uploaded .glb, params.fit 0 contain or 1 stretch, params.paint 1 to use the slot material; a model is uploaded in the object builder, never through this server) 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. This server can check such a document but never save it: a reseller uploads it in the object builder.';
1991
- var documentInput = z5.string().min(2).max(2e5).describe(
2604
+ import { readFile, stat } from "fs/promises";
2605
+ import { extname } from "path";
2606
+ var FILE_RULES = {
2607
+ model: { extensions: [".glb"], maxBytes: 10 * 1024 * 1024 },
2608
+ texture: {
2609
+ extensions: [".webp", ".jpg", ".jpeg"],
2610
+ maxBytes: 2 * 1024 * 1024
2611
+ }
2612
+ };
2613
+ 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 = z7.string().min(2).max(2e5).describe(
1992
2615
  'The JSON text of a whole configurator structure (it has "schemaVersion"), or of a shape document alone (it has "root").'
1993
2616
  );
1994
2617
  function registerConfiguratorTools(server, client) {
@@ -1996,14 +2619,14 @@ function registerConfiguratorTools(server, client) {
1996
2619
  "check_configurator_shape",
1997
2620
  {
1998
2621
  title: "Check a configurator structure or shape document",
1999
- description: "Checks a configurator structure or a shape document exactly as the object builder will when a reseller uploads it: the outline, every name a formula reads, bindings, cycles in the rules and the limits. A whole structure is also expanded at its smallest measures, so a part that would come out negative is named. Answers with every problem and where it is, or that the document is sound. " + CONFIGURATOR,
2622
+ description: "Checks a configurator structure or a shape document exactly as the API will when it is saved: the outline, every name a formula reads, bindings, cycles in the rules and the limits. A whole structure is also expanded at its smallest measures, so a part that would come out negative is named. Answers with every problem and where it is, or that the document is sound. " + CONFIGURATOR,
2000
2623
  inputSchema: { document: documentInput },
2001
2624
  annotations: { readOnlyHint: true, idempotentHint: true }
2002
2625
  },
2003
2626
  ({ document }) => {
2004
2627
  const problems = checkDocument(document);
2005
2628
  return answer(
2006
- problems.length === 0 ? "The document is sound. A reseller can upload it in the object builder." : [
2629
+ problems.length === 0 ? "The document is sound. upsert_configurator saves it." : [
2007
2630
  `${problems.length} problem(s):`,
2008
2631
  ...problems.map(describeIssue)
2009
2632
  ].join("\n"),
@@ -2011,14 +2634,125 @@ function registerConfiguratorTools(server, client) {
2011
2634
  );
2012
2635
  }
2013
2636
  );
2637
+ server.registerTool(
2638
+ "upsert_configurator",
2639
+ {
2640
+ title: "Save a configurator",
2641
+ description: 'Makes a configurator field, or changes one, with its whole structure. The structure is checked here first, exactly as check_configurator_shape does, and nothing is sent while it has a problem. Making the field, or changing its "geometry", needs a key with configurator:shape; changing parameters, lists, materials, prices or rules of an existing one needs only catalog:write. A new field needs a `label`. Upload models and textures first with upload_configurator_file and put the paths it answers in the structure. ' + CONFIGURATOR,
2642
+ inputSchema: {
2643
+ slug: CatalogFieldSlugSchema.describe(
2644
+ "The slug of the configurator field."
2645
+ ),
2646
+ label: z7.string().trim().min(1).max(120).optional(),
2647
+ structure: z7.string().min(2).max(2e5).describe(
2648
+ 'The JSON text of the whole configurator structure (it has "schemaVersion").'
2649
+ ),
2650
+ public: z7.boolean().optional().describe("Whether product pages may show the configurator."),
2651
+ ...objectInput
2652
+ },
2653
+ annotations: { destructiveHint: true, idempotentHint: true }
2654
+ },
2655
+ async ({ slug, label, structure, public: isPublic, object: object2 }) => {
2656
+ const problems = checkDocument(structure);
2657
+ if (problems.length > 0 || !structure.includes('"schemaVersion"')) {
2658
+ return {
2659
+ isError: true,
2660
+ content: [
2661
+ {
2662
+ type: "text",
2663
+ text: [
2664
+ problems.length > 0 ? `Not saved. ${problems.length} problem(s):` : 'Not saved: this is a shape document alone; save a whole structure (it has "schemaVersion").',
2665
+ ...problems.map(describeIssue)
2666
+ ].join("\n")
2667
+ }
2668
+ ]
2669
+ };
2670
+ }
2671
+ try {
2672
+ const field = await client.upsertField(
2673
+ slug,
2674
+ {
2675
+ ...label ? { label } : {},
2676
+ type: "configurator",
2677
+ config: JSON.parse(structure),
2678
+ ...isPublic === void 0 ? {} : { public: isPublic }
2679
+ },
2680
+ object2 ? { object: object2 } : void 0
2681
+ );
2682
+ return answer(
2683
+ `${field.created ? "Made" : "Changed"} the configurator ${field.label} (${field.slug}).`,
2684
+ field
2685
+ );
2686
+ } catch (error) {
2687
+ return refusal(error, "configurator:shape");
2688
+ }
2689
+ }
2690
+ );
2691
+ server.registerTool(
2692
+ "upload_configurator_file",
2693
+ {
2694
+ title: "Upload a model or a texture for a configurator",
2695
+ description: 'Reads one file from the user\u2019s disk and uploads it for a configurator field: a "model" is a binary glTF (.glb, at most 10 MB, everything inside the file, no Draco, no lights of its own, at most 200,000 triangles) and part of the shape, so it needs configurator:shape; a "texture" is a .webp or .jpg picture of at most 2 MB. Answers with the path to put in the structure. The field must exist first (upsert_configurator).',
2696
+ inputSchema: {
2697
+ fieldSlug: CatalogFieldSlugSchema.describe(
2698
+ "The slug of the configurator field."
2699
+ ),
2700
+ kind: z7.enum(CONFIGURATOR_FILE_KINDS),
2701
+ filePath: z7.string().min(1).max(1e3).describe("The absolute path of the file on this machine."),
2702
+ ...objectInput
2703
+ },
2704
+ annotations: { destructiveHint: false, idempotentHint: false }
2705
+ },
2706
+ async ({ fieldSlug, kind, filePath, object: object2 }) => {
2707
+ const rules = FILE_RULES[kind];
2708
+ const extension = extname(filePath).toLowerCase();
2709
+ if (!rules.extensions.includes(extension)) {
2710
+ return notUploaded(
2711
+ `A ${kind} is a ${rules.extensions.join(" or ")} file; this is "${extension || "no extension"}".`
2712
+ );
2713
+ }
2714
+ const info = await stat(filePath).catch(() => null);
2715
+ if (!info?.isFile()) {
2716
+ return notUploaded(`"${filePath}" is not a file on this machine.`);
2717
+ }
2718
+ if (info.size > rules.maxBytes) {
2719
+ return notUploaded(
2720
+ `The file is ${info.size} bytes; a ${kind} may be at most ${rules.maxBytes}.`
2721
+ );
2722
+ }
2723
+ try {
2724
+ const bytes = new Uint8Array(await readFile(filePath));
2725
+ const result = await client.uploadConfiguratorFile(
2726
+ fieldSlug,
2727
+ {
2728
+ kind,
2729
+ bytes,
2730
+ ...kind === "texture" ? {
2731
+ contentType: extension === ".webp" ? "image/webp" : "image/jpeg"
2732
+ } : {}
2733
+ },
2734
+ object2 ? { object: object2 } : void 0
2735
+ );
2736
+ return answer(
2737
+ `Uploaded. Put this path in the structure: ${result.path}`,
2738
+ result
2739
+ );
2740
+ } catch (error) {
2741
+ return refusal(
2742
+ error,
2743
+ kind === "model" ? "configurator:shape" : "catalog:write"
2744
+ );
2745
+ }
2746
+ }
2747
+ );
2014
2748
  server.registerTool(
2015
2749
  "read_configurator",
2016
2750
  {
2017
2751
  title: "Read the configurator of a product",
2018
2752
  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,
2019
2753
  inputSchema: {
2020
- slugOrId: z5.string().min(1).max(200).describe("The address (slug) or the id of the product."),
2021
- field: z5.string().min(1).max(64).describe("The slug of the configurator field.")
2754
+ slugOrId: z7.string().min(1).max(200).describe("The address (slug) or the id of the product."),
2755
+ field: z7.string().min(1).max(64).describe("The slug of the configurator field.")
2022
2756
  },
2023
2757
  annotations: { readOnlyHint: true, idempotentHint: true }
2024
2758
  },
@@ -2037,15 +2771,15 @@ function registerConfiguratorTools(server, client) {
2037
2771
  }
2038
2772
  return answer(`The configurator "${field}" of ${product.name}.`, value);
2039
2773
  } catch (error) {
2040
- return refusal(error);
2774
+ return refusal(error, "catalog:read");
2041
2775
  }
2042
2776
  }
2043
2777
  );
2044
2778
  }
2045
- function checkDocument(text) {
2779
+ function checkDocument(text2) {
2046
2780
  let parsed;
2047
2781
  try {
2048
- parsed = JSON.parse(text);
2782
+ parsed = JSON.parse(text2);
2049
2783
  } catch {
2050
2784
  return [{ path: [], code: "configurator.notJson" }];
2051
2785
  }
@@ -2089,18 +2823,102 @@ function describeIssue(problem) {
2089
2823
  const params = problem.params ? ` (${Object.entries(problem.params).map(([key, value]) => `${key}: ${value}`).join(", ")})` : "";
2090
2824
  return `- ${where}: ${problem.code}${params}`;
2091
2825
  }
2826
+ function notUploaded(text2) {
2827
+ return {
2828
+ isError: true,
2829
+ content: [{ type: "text", text: `Not uploaded. ${text2}` }]
2830
+ };
2831
+ }
2092
2832
  function isRecord(value) {
2093
2833
  return typeof value === "object" && value !== null && !Array.isArray(value);
2094
2834
  }
2095
2835
 
2836
+ // src/event-tools.ts
2837
+ import { z as z8 } from "zod";
2838
+ var moment = z8.string().datetime({ offset: true }).optional().describe("An ISO timestamp with its offset, such as 2026-11-01T00:00:00Z.");
2839
+ function registerEventTools(server, client) {
2840
+ server.registerTool(
2841
+ "list_events",
2842
+ {
2843
+ title: "List upcoming events",
2844
+ description: "The published events of the account, soonest first: title, when and where, the lowest ticket price, whether it is sold out and the link to its ticket page. Both ends of the window are optional and inclusive. Events are made in the app; this cannot create one. May be a minute old.",
2845
+ inputSchema: { ...page, from: moment, to: moment },
2846
+ annotations: { readOnlyHint: true, idempotentHint: true }
2847
+ },
2848
+ async (input) => {
2849
+ try {
2850
+ const list = await client.getEvents(input);
2851
+ return answer(
2852
+ list.events.length === 0 ? "No published events in this window." : [...list.events.map(describeEvent), more(list.nextCursor)].filter(Boolean).join("\n"),
2853
+ list
2854
+ );
2855
+ } catch (error) {
2856
+ return refusal(error, "events:read");
2857
+ }
2858
+ }
2859
+ );
2860
+ server.registerTool(
2861
+ "get_event",
2862
+ {
2863
+ title: "Read one event",
2864
+ 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
+ inputSchema: {
2866
+ idOrSlug: z8.string().min(1).max(200).describe("The id or the address (slug) of the event.")
2867
+ },
2868
+ annotations: { readOnlyHint: true, idempotentHint: true }
2869
+ },
2870
+ async ({ idOrSlug }) => {
2871
+ try {
2872
+ const event = await client.getEvent(idOrSlug);
2873
+ if (!event) {
2874
+ return answer(`No event "${idOrSlug}" on this account.`, null);
2875
+ }
2876
+ return answer(
2877
+ `${event.title} (${event.slug}), ${event.status}, ${event.ticketTypes.length} ticket types.`,
2878
+ event
2879
+ );
2880
+ } catch (error) {
2881
+ return refusal(error, "events:read");
2882
+ }
2883
+ }
2884
+ );
2885
+ server.registerTool(
2886
+ "get_event_availability",
2887
+ {
2888
+ title: "Seats left for an event",
2889
+ 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
+ inputSchema: {
2891
+ eventId: z8.string().uuid().describe("The id of the event.")
2892
+ },
2893
+ annotations: { readOnlyHint: true, idempotentHint: true }
2894
+ },
2895
+ async ({ eventId }) => {
2896
+ try {
2897
+ const seats = await client.getEventAvailability(eventId);
2898
+ if (!seats) {
2899
+ return answer(`No event "${eventId}" on this account.`, null);
2900
+ }
2901
+ return answer(
2902
+ seats.soldOut ? "Sold out." : `${seats.remaining ?? "No limit on"} seats left.`,
2903
+ seats
2904
+ );
2905
+ } catch (error) {
2906
+ return refusal(error, "events:read");
2907
+ }
2908
+ }
2909
+ );
2910
+ }
2911
+ function describeEvent(event) {
2912
+ const where = event.locationName ? `, ${event.locationName}` : "";
2913
+ const price = event.priceFromCents !== null && event.currency ? `, from ${money(event.priceFromCents, event.currency)}` : "";
2914
+ const sold = event.soldOut ? ", sold out" : "";
2915
+ return `- ${event.title} (id ${event.id}): ${event.startsAt}${where}${price}${sold} \u2014 ${event.url}`;
2916
+ }
2917
+
2096
2918
  // src/placement-tools.ts
2097
- import { z as z6 } from "zod";
2919
+ import { z as z9 } from "zod";
2098
2920
  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.";
2099
- var page = {
2100
- cursor: z6.string().optional().describe("The nextCursor of the previous page; leave out for the first."),
2101
- limit: z6.number().int().min(1).max(100).optional()
2102
- };
2103
- var origin = z6.string().regex(/^https?:\/\/[a-zA-Z0-9.-]+(:[0-9]{1,5})?$/).describe("Scheme, host and optional port; no path: https://www.example.com");
2921
+ var origin = z9.string().regex(/^https?:\/\/[a-zA-Z0-9.-]+(:[0-9]{1,5})?$/).describe("Scheme, host and optional port; no path: https://www.example.com");
2104
2922
  function registerPlacementTools(server, client) {
2105
2923
  server.registerTool(
2106
2924
  "list_forms",
@@ -2120,7 +2938,7 @@ function registerPlacementTools(server, client) {
2120
2938
  list
2121
2939
  );
2122
2940
  } catch (error) {
2123
- return refusal(error);
2941
+ return refusal(error, "website:manage");
2124
2942
  }
2125
2943
  }
2126
2944
  );
@@ -2140,7 +2958,7 @@ function registerPlacementTools(server, client) {
2140
2958
  pages
2141
2959
  );
2142
2960
  } catch (error) {
2143
- return refusal(error);
2961
+ return refusal(error, "website:manage");
2144
2962
  }
2145
2963
  }
2146
2964
  );
@@ -2163,7 +2981,7 @@ function registerPlacementTools(server, client) {
2163
2981
  list
2164
2982
  );
2165
2983
  } catch (error) {
2166
- return refusal(error);
2984
+ return refusal(error, "website:manage");
2167
2985
  }
2168
2986
  }
2169
2987
  );
@@ -2173,13 +2991,13 @@ function registerPlacementTools(server, client) {
2173
2991
  title: "Make or change a chat widget",
2174
2992
  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.",
2175
2993
  inputSchema: {
2176
- slug: z6.string().regex(/^[a-z][a-z0-9-]*$/).max(100).describe(
2994
+ slug: z9.string().regex(/^[a-z][a-z0-9-]*$/).max(100).describe(
2177
2995
  'The widget\u2019s address on this account, such as "website-chat".'
2178
2996
  ),
2179
- name: z6.string().min(1).max(200).optional().describe("Required when the widget does not exist yet."),
2180
- status: z6.enum(["draft", "published"]).optional().describe("Only a published widget answers on a site."),
2181
- addAllowedOrigins: z6.array(origin).max(20).optional(),
2182
- removeAllowedOrigins: z6.array(origin).max(20).optional()
2997
+ name: z9.string().min(1).max(200).optional().describe("Required when the widget does not exist yet."),
2998
+ status: z9.enum(["draft", "published"]).optional().describe("Only a published widget answers on a site."),
2999
+ addAllowedOrigins: z9.array(origin).max(20).optional(),
3000
+ removeAllowedOrigins: z9.array(origin).max(20).optional()
2183
3001
  },
2184
3002
  annotations: {
2185
3003
  readOnlyHint: false,
@@ -2197,7 +3015,7 @@ ${describeChatWidget(widget)}`,
2197
3015
  widget
2198
3016
  );
2199
3017
  } catch (error) {
2200
- return refusal(error);
3018
+ return refusal(error, "website:manage");
2201
3019
  }
2202
3020
  }
2203
3021
  );
@@ -2217,7 +3035,7 @@ ${describeChatWidget(widget)}`,
2217
3035
  site
2218
3036
  );
2219
3037
  } catch (error) {
2220
- return refusal(error);
3038
+ return refusal(error, "website:manage");
2221
3039
  }
2222
3040
  }
2223
3041
  );
@@ -2227,9 +3045,9 @@ ${describeChatWidget(widget)}`,
2227
3045
  title: "Set up or change the tracking script",
2228
3046
  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.",
2229
3047
  inputSchema: {
2230
- addAllowedHosts: z6.array(z6.string()).max(20).optional().describe('Hosts such as "www.example.com" or "*.example.com".'),
2231
- removeAllowedHosts: z6.array(z6.string()).max(20).optional(),
2232
- consentSignal: z6.enum([
3048
+ addAllowedHosts: z9.array(z9.string()).max(20).optional().describe('Hosts such as "www.example.com" or "*.example.com".'),
3049
+ removeAllowedHosts: z9.array(z9.string()).max(20).optional(),
3050
+ consentSignal: z9.enum([
2233
3051
  "ad_storage",
2234
3052
  "analytics_storage",
2235
3053
  "personalization_storage",
@@ -2254,7 +3072,7 @@ ${describeSiteTracking(site)}`,
2254
3072
  site
2255
3073
  );
2256
3074
  } catch (error) {
2257
- return refusal(error);
3075
+ return refusal(error, "website:manage");
2258
3076
  }
2259
3077
  }
2260
3078
  );
@@ -2288,9 +3106,6 @@ function describeSiteTracking(site) {
2288
3106
  codeBlock(site.embed)
2289
3107
  ].join("\n");
2290
3108
  }
2291
- function more(nextCursor) {
2292
- return nextCursor ? `More on the next page: cursor ${nextCursor}` : "";
2293
- }
2294
3109
 
2295
3110
  // src/server-config.ts
2296
3111
  function readServerConfig(env) {
@@ -2299,13 +3114,13 @@ function readServerConfig(env) {
2299
3114
  if (!apiKey) {
2300
3115
  return {
2301
3116
  ok: false,
2302
- error: 'SOLUMFLOW_API_KEY is not set. Make a secret key with the scope "website:manage" in the app (Settings \u2192 API keys) and pass it in the environment.'
3117
+ error: 'SOLUMFLOW_API_KEY is not set. Make a secret key in the app (Settings \u2192 API keys) with the scopes the agent needs: "website:manage" to place things on a site, "catalog:read" and "events:read" to read, "catalog:write" to fill the catalogue, and "configurator:shape" -- which only a reseller can give -- to make a 3D configurator and upload its models. Settings \u2192 Integrations \u2192 MCP server makes such a key and the command to add this server in one go. Pass it in the environment.'
2303
3118
  };
2304
3119
  }
2305
3120
  if (!apiKey.startsWith("crms_")) {
2306
3121
  return {
2307
3122
  ok: false,
2308
- error: 'SOLUMFLOW_API_KEY is not a secret key. Placing things on a website needs a `crms_` key with the scope "website:manage"; a `crmp_` key can only read the catalogue.'
3123
+ error: "SOLUMFLOW_API_KEY is not a secret key. This server needs a `crms_` key: a `crmp_` key is meant for a browser and can never place things on a website or write the catalogue."
2309
3124
  };
2310
3125
  }
2311
3126
  if (!/^https?:\/\/[^/]+$/.test(baseUrl)) {
@@ -2326,7 +3141,9 @@ async function main() {
2326
3141
  }
2327
3142
  const server = new McpServer({
2328
3143
  name: "crm-mcp-server",
2329
- version: "0.3.1"
3144
+ // Read from the package, so the version an agent is told is the one
3145
+ // that was published.
3146
+ version
2330
3147
  });
2331
3148
  const client = createClient({
2332
3149
  apiKey: config.apiKey,
@@ -2334,6 +3151,8 @@ async function main() {
2334
3151
  });
2335
3152
  registerPlacementTools(server, client);
2336
3153
  registerCatalogTools(server, client);
3154
+ registerCatalogWriteTools(server, client);
3155
+ registerEventTools(server, client);
2337
3156
  registerConfiguratorTools(server, client);
2338
3157
  await server.connect(new StdioServerTransport());
2339
3158
  }