@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/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.4.0";
9
+ var version = "0.6.0";
10
10
 
11
- // src/catalog-tools.ts
12
- import { z as z3 } from "zod";
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((price) => price.label.toLowerCase());
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: z.string().max(2048).url().refine((url) => url.startsWith("https://"), {
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 is fetched over https only."
94
- }),
95
- alt: text(300)
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 a moment and try again.",
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, currency) {
361
- return `${(cents2 / 100).toFixed(2)} ${currency.toUpperCase()}`;
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. May be a minute old. Answers that it is not found for an unknown product. " + CONTENT_BLOCKS,
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(wanted);
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 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.",
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().uuid().optional().describe("A category id; includes its subcategories."),
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(input);
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 ? "No published products match." : [...list.data.map(describeProduct), more(list.nextCursor)].filter(Boolean).join("\n"),
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 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.',
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 ? "No published categories." : [...list.data.map(describeCategory), more(list.nextCursor)].filter(Boolean).join("\n"),
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
- return `- ${product.name} (${product.slug}): from ${money(product.priceFromCents, product.currency)}${stock}`;
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
- return `- ${path || category.name} (${category.slug}, id ${category.id}): ${category.productCount} products`;
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-write-tools.ts
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
- 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.';
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
- target(object2)
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
- target(object2)
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
- target(object2)
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
- const field = await client.upsertField(slug, input, target(object2));
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 z7 } from "zod";
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 z4 } from "zod";
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 = 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")
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 = z4.tuple([FormulaSchema, FormulaSchema, FormulaSchema]);
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: z4.record(ConfiguratorNameSchema, FormulaSchema).optional()
1652
+ params: z5.record(ConfiguratorNameSchema, FormulaSchema).optional()
1132
1653
  };
1133
- var PrimitiveNodeSchema = z4.strictObject({
1654
+ var PrimitiveNodeSchema = z5.strictObject({
1134
1655
  ...PrimitiveNodeFields,
1135
- type: z4.enum(PRIMITIVE_TYPES).exclude(["model"])
1656
+ type: z5.enum(PRIMITIVE_TYPES).exclude(["model"])
1136
1657
  });
1137
- var ModelPathSchema = z4.string().regex(CONFIGURATOR_MODEL_PATH_PATTERN, "configurator.invalidModelPath");
1138
- var ServedFileUrlSchema = z4.url({ protocol: /^https?$/ });
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 = z4.strictObject({
1661
+ const ModelNodeSchema = z5.strictObject({
1141
1662
  ...PrimitiveNodeFields,
1142
- type: z4.literal("model"),
1663
+ type: z5.literal("model"),
1143
1664
  model: modelFile
1144
1665
  });
1145
- const ModelPerChoiceNodeSchema = z4.strictObject({
1666
+ const ModelPerChoiceNodeSchema = z5.strictObject({
1146
1667
  ...PrimitiveNodeFields,
1147
- type: z4.literal("model"),
1668
+ type: z5.literal("model"),
1148
1669
  follows: ConfiguratorNameSchema,
1149
- models: z4.record(ConfiguratorCodeSchema, modelFile).refine(
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 = z4.lazy(
1155
- () => z4.union([
1675
+ const node = z5.lazy(
1676
+ () => z5.union([
1156
1677
  PrimitiveNodeSchema,
1157
1678
  ModelNodeSchema,
1158
1679
  ModelPerChoiceNodeSchema,
1159
- z4.strictObject({
1680
+ z5.strictObject({
1160
1681
  id: ConfiguratorNameSchema,
1161
- type: z4.literal("repeat"),
1682
+ type: z5.literal("repeat"),
1162
1683
  count: FormulaSchema,
1163
- index: z4.enum(["i", "j"]).optional(),
1684
+ index: z5.enum(["i", "j"]).optional(),
1164
1685
  visible: FormulaSchema.optional(),
1165
- children: z4.array(node).min(1)
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 = z4.strictObject({
1693
+ var GeometryBindingSchema = z5.strictObject({
1173
1694
  param: ConfiguratorNameSchema,
1174
- map: z4.record(ConfiguratorCodeSchema, z4.number().finite()).optional()
1695
+ map: z5.record(ConfiguratorCodeSchema, z5.number().finite()).optional()
1175
1696
  });
1176
- var GeometryInputSchema = z4.strictObject({
1697
+ var GeometryInputSchema = z5.strictObject({
1177
1698
  name: ConfiguratorNameSchema,
1178
1699
  binding: GeometryBindingSchema
1179
1700
  });
1180
- var GeometrySlotSchema = z4.strictObject({
1701
+ var GeometrySlotSchema = z5.strictObject({
1181
1702
  name: ConfiguratorNameSchema,
1182
1703
  param: ConfiguratorNameSchema.optional(),
1183
1704
  material: ConfiguratorCodeSchema.optional(),
1184
- color: z4.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor").optional(),
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: z4.enum(["horizontal", "vertical"]).optional()
1712
+ grain: z5.enum(["horizontal", "vertical"]).optional()
1192
1713
  });
1193
- var GeometryAnnotationSchema = z4.strictObject({
1714
+ var GeometryAnnotationSchema = z5.strictObject({
1194
1715
  id: ConfiguratorNameSchema,
1195
- axis: z4.enum(["x", "y"]),
1716
+ axis: z5.enum(["x", "y"]),
1196
1717
  from: FormulaSchema,
1197
1718
  to: FormulaSchema,
1198
1719
  at: FormulaSchema,
1199
- label: z4.string().trim().min(1).max(80).optional()
1720
+ label: z5.string().trim().min(1).max(80).optional()
1200
1721
  });
1201
1722
  function geometryDocumentSchema(modelFile) {
1202
- return z4.strictObject({
1723
+ return z5.strictObject({
1203
1724
  /** Rises with every new upload, so a product page can say which one it shows. */
1204
- version: z4.number().int().min(1),
1725
+ version: z5.number().int().min(1),
1205
1726
  /** The starter document this one was copied from, for information only. */
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({
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: z4.number().min(-90).max(90),
1734
+ azimuth: z5.number().min(-90).max(90),
1214
1735
  /** Degrees above the horizon. */
1215
- elevation: z4.number().min(-10).max(80)
1736
+ elevation: z5.number().min(-10).max(80)
1216
1737
  }).optional(),
1217
- annotations: z4.array(GeometryAnnotationSchema).max(CONFIGURATOR_LIMITS.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 z5 } from "zod";
1508
- var LabelSchema = z5.string().trim().min(1).max(80);
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 = z5.string().regex(CONFIGURATOR_TEXTURE_PATH_PATTERN, "configurator.invalidTexturePath");
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 = z5.discriminatedUnion("kind", [
1517
- z5.strictObject({
2037
+ var ConfiguratorParameterSchema = z6.discriminatedUnion("kind", [
2038
+ z6.strictObject({
1518
2039
  ...ParameterBase,
1519
- kind: z5.literal("number"),
2040
+ kind: z6.literal("number"),
1520
2041
  /** `mm` is a measure, `count` a number of things (compartments). */
1521
- unit: z5.enum(["mm", "count"]),
1522
- step: z5.number().positive().finite(),
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: z5.number().finite(),
1525
- max: z5.number().finite()
2045
+ min: z6.number().finite(),
2046
+ max: z6.number().finite()
1526
2047
  }),
1527
- z5.strictObject({
2048
+ z6.strictObject({
1528
2049
  ...ParameterBase,
1529
- kind: z5.literal("choice"),
2050
+ kind: z6.literal("choice"),
1530
2051
  list: ConfiguratorNameSchema
1531
2052
  }),
1532
- z5.strictObject({ ...ParameterBase, kind: z5.literal("material") }),
1533
- z5.strictObject({ ...ParameterBase, kind: z5.literal("toggle") })
2053
+ z6.strictObject({ ...ParameterBase, kind: z6.literal("material") }),
2054
+ z6.strictObject({ ...ParameterBase, kind: z6.literal("toggle") })
1534
2055
  ]);
1535
- var choiceListSchema = (picture) => z5.strictObject({
2056
+ var choiceListSchema = (picture) => z6.strictObject({
1536
2057
  key: ConfiguratorNameSchema,
1537
2058
  label: LabelSchema,
1538
- options: z5.array(
1539
- z5.strictObject({
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) => z5.strictObject({
2067
+ var materialSchema = (picture) => z6.strictObject({
1547
2068
  code: ConfiguratorCodeSchema,
1548
2069
  label: LabelSchema,
1549
- color: z5.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor"),
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: z5.number().positive().max(1e4).optional(),
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: z5.number().min(0).max(3).optional(),
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: z5.number().min(0).max(1).optional(),
1560
- metalness: z5.number().min(0).max(1).optional(),
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: z5.number().min(0.05).max(1).optional(),
2083
+ opacity: z6.number().min(0.05).max(1).optional(),
1563
2084
  /** Gives light rather than reflecting it: a flame, a LED. */
1564
- emissive: z5.boolean().optional(),
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 = z5.union([
1570
- z5.number().finite(),
1571
- z5.boolean(),
2090
+ var ConditionValueSchema = z6.union([
2091
+ z6.number().finite(),
2092
+ z6.boolean(),
1572
2093
  ConfiguratorCodeSchema,
1573
- z5.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
2094
+ z6.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
1574
2095
  ]);
1575
- var ConfiguratorConditionSchema = z5.strictObject({
2096
+ var ConfiguratorConditionSchema = z6.strictObject({
1576
2097
  param: ConfiguratorNameSchema,
1577
- op: z5.enum(["eq", "neq", "in", "gte", "lte"]),
2098
+ op: z6.enum(["eq", "neq", "in", "gte", "lte"]),
1578
2099
  value: ConditionValueSchema
1579
2100
  });
1580
- var ConfiguratorRuleSchema = z5.discriminatedUnion("kind", [
2101
+ var ConfiguratorRuleSchema = z6.discriminatedUnion("kind", [
1581
2102
  /** Show `param` only while `when` holds. */
1582
- z5.strictObject({
1583
- kind: z5.literal("visible"),
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
- z5.strictObject({
1589
- kind: z5.literal("limit"),
2109
+ z6.strictObject({
2110
+ kind: z6.literal("limit"),
1590
2111
  param: ConfiguratorNameSchema,
1591
- bound: z5.enum(["min", "max"]),
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
- z5.strictObject({
1596
- kind: z5.literal("exclude"),
2116
+ z6.strictObject({
2117
+ kind: z6.literal("exclude"),
1597
2118
  param: ConfiguratorNameSchema,
1598
- codes: z5.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList),
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 = z5.discriminatedUnion("kind", [
2129
+ var ConfiguratorPriceRuleSchema = z6.discriminatedUnion("kind", [
1609
2130
  /** The surcharge the model gives the chosen option. */
1610
- z5.strictObject({
2131
+ z6.strictObject({
1611
2132
  ...PriceRuleBase,
1612
- kind: z5.literal("choice"),
2133
+ kind: z6.literal("choice"),
1613
2134
  param: ConfiguratorNameSchema
1614
2135
  }),
1615
2136
  /** The surcharge of the chosen material's price group. */
1616
- z5.strictObject({
2137
+ z6.strictObject({
1617
2138
  ...PriceRuleBase,
1618
- kind: z5.literal("material"),
2139
+ kind: z6.literal("material"),
1619
2140
  param: ConfiguratorNameSchema
1620
2141
  }),
1621
2142
  /** An amount, counted while a switch is on. */
1622
- z5.strictObject({
2143
+ z6.strictObject({
1623
2144
  ...PriceRuleBase,
1624
- kind: z5.literal("toggle"),
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
- z5.strictObject({
2153
+ z6.strictObject({
1633
2154
  key: ConfiguratorNameSchema,
1634
2155
  label: LabelSchema,
1635
- kind: z5.literal("formula"),
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 = z5.strictObject({
1646
- lighting: z5.enum(CONFIGURATOR_SCENE_LIGHTINGS).optional(),
1647
- edges: z5.enum(CONFIGURATOR_SCENE_EDGES).optional()
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) => z5.strictObject({
1650
- schemaVersion: z5.literal(1),
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: z5.array(
1658
- z5.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
2178
+ tabs: z6.array(
2179
+ z6.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
1659
2180
  ).min(1).max(CONFIGURATOR_LIMITS.tabs),
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 })
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: z5.array(
1668
- z5.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
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: z5.strictObject({
2195
+ presets: z6.strictObject({
1675
2196
  label: LabelSchema,
1676
2197
  tab: ConfiguratorNameSchema,
1677
- sets: z5.array(ConfiguratorNameSchema).min(1).max(CONFIGURATOR_LIMITS.parameters),
1678
- required: z5.boolean()
2198
+ sets: z6.array(ConfiguratorNameSchema).min(1).max(CONFIGURATOR_LIMITS.parameters),
2199
+ required: z6.boolean()
1679
2200
  }).optional(),
1680
- rules: z5.array(ConfiguratorRuleSchema).max(CONFIGURATOR_LIMITS.rules),
1681
- priceRules: z5.array(ConfiguratorPriceRuleSchema).max(CONFIGURATOR_LIMITS.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 target2 = parameters.get(rule.param);
1841
- if (!target2) {
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 (target2.kind !== "number") {
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(target2, lists, materials);
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 target2 = parameters.get(rule.param);
1948
- if (!target2 || target2.kind !== rule.kind) {
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 z6 } from "zod";
2098
- var CentsSchema = z6.number().int().min(0).max(1e8);
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 = z6.strictObject({
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: z6.record(
2624
+ numbers: z7.record(
2104
2625
  ConfiguratorNameSchema,
2105
- z6.strictObject({
2106
- min: z6.number().finite(),
2107
- max: z6.number().finite(),
2108
- default: z6.number().finite()
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: z6.record(
2633
+ choices: z7.record(
2113
2634
  ConfiguratorNameSchema,
2114
- z6.strictObject({
2115
- options: z6.record(
2635
+ z7.strictObject({
2636
+ options: z7.record(
2116
2637
  ConfiguratorCodeSchema,
2117
- z6.strictObject({ priceCents: PriceSchema })
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: z6.record(
2644
+ materials: z7.record(
2124
2645
  ConfiguratorNameSchema,
2125
- z6.strictObject({
2126
- offered: z6.array(ConfiguratorCodeSchema).max(CONFIGURATOR_LIMITS.materials),
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: z6.record(
2652
+ toggles: z7.record(
2132
2653
  ConfiguratorNameSchema,
2133
- z6.strictObject({ offered: z6.boolean(), default: z6.boolean() })
2654
+ z7.strictObject({ offered: z7.boolean(), default: z7.boolean() })
2134
2655
  ).default({}),
2135
2656
  /** The surcharge of each material price group. */
2136
- priceGroups: z6.record(ConfiguratorCodeSchema, PriceSchema).default({}),
2657
+ priceGroups: z7.record(ConfiguratorCodeSchema, PriceSchema).default({}),
2137
2658
  /** The amounts the structure names, such as a price per metre. */
2138
- amounts: z6.record(ConfiguratorNameSchema, PriceSchema).default({}),
2139
- presets: z6.array(
2140
- z6.strictObject({
2659
+ amounts: z7.record(ConfiguratorNameSchema, PriceSchema).default({}),
2660
+ presets: z7.array(
2661
+ z7.strictObject({
2141
2662
  code: ConfiguratorCodeSchema,
2142
- label: z6.string().trim().min(1).max(80),
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: z6.string().uuid().nullable(),
2145
- values: z6.record(ConfiguratorNameSchema, z6.number().finite())
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 FILE_RULES = {
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 = z7.string().min(2).max(2e5).describe(
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: z7.string().trim().min(1).max(120).optional(),
2647
- structure: z7.string().min(2).max(2e5).describe(
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: z7.boolean().optional().describe("Whether product pages may show the configurator."),
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: z7.enum(CONFIGURATOR_FILE_KINDS),
2701
- filePath: z7.string().min(1).max(1e3).describe("The absolute path of the file on this machine."),
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 = FILE_RULES[kind];
2708
- const extension = extname(filePath).toLowerCase();
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 stat(filePath).catch(() => null);
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 readFile(filePath));
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: 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.")
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 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.");
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: z8.string().min(1).max(200).describe("The id or the address (slug) of the event.")
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: z8.string().uuid().describe("The id of the event.")
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 z9 } from "zod";
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 = z9.string().regex(/^https?:\/\/[a-zA-Z0-9.-]+(:[0-9]{1,5})?$/).describe("Scheme, host and optional port; no path: https://www.example.com");
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: z9.string().regex(/^[a-z][a-z0-9-]*$/).max(100).describe(
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: 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()
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: 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([
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
- registerPlacementTools(server, client);
3153
- registerCatalogTools(server, client);
3154
- registerCatalogWriteTools(server, client);
3155
- registerEventTools(server, client);
3156
- registerConfiguratorTools(server, client);
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);