@solumflow-app/crm-mcp-server 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,6 +52,7 @@ scope the key lacks answers `forbidden` and names the scope it needs.
52
52
 
53
53
  | Tool | Scope | What it does |
54
54
  | --- | --- | --- |
55
+ | `server_info` | none | The version of this server and of its client, the API address, and the fingerprint of the tool definitions it serves. |
55
56
  | `list_forms` | `website:manage` | Published forms, with their link and embed snippet. |
56
57
  | `list_booking_pages` | `website:manage` | Every page a visitor can book on, with its embed snippet. |
57
58
  | `list_chat_widgets` | `website:manage` | Chat widgets, the sites each may run on, and the script tag. |
@@ -60,10 +61,10 @@ scope the key lacks answers `forbidden` and names the scope it needs.
60
61
  | `update_site_tracking` | `website:manage` | Add or remove those domains. |
61
62
  | `list_catalogs` | `catalog:read` | The product catalogues the key can write to, and their slugs. |
62
63
  | `list_catalog_fields` | `catalog:read` | The public custom fields of a catalogue, including how each content block is built. |
63
- | `list_products` | `catalog:read` | One page of published products, by category or catalogue. |
64
+ | `list_products` | `catalog:read` | One page of published products, by category or catalogue. With `status` (`draft`, `archived`, `all`) and a key that has `catalog:write`, the concepts and archived ones too. |
64
65
  | `get_product` | `catalog:read` | One product as its page needs it. |
65
66
  | `get_product_availability` | `catalog:read` | Whether a product can be bought now, live. |
66
- | `list_categories` | `catalog:read` | The category tree, with product counts. |
67
+ | `list_categories` | `catalog:read` | The category tree, with product counts. `status` `draft` or `all` (with `catalog:write`) adds the concept categories. |
67
68
  | `get_category` | `catalog:read` | One category page and a page of its products. |
68
69
  | `read_configurator` | `catalog:read` | A product's 3D configurator document. |
69
70
  | `check_configurator_shape` | none | Check a configurator structure or shape the way the app does, without saving it. |
@@ -81,6 +82,21 @@ scope the key lacks answers `forbidden` and names the scope it needs.
81
82
  Every catalogue tool takes an optional `object`: the slug of another catalogue
82
83
  of the account (from `list_catalogs`). Left out, it is the account's own.
83
84
 
85
+ ## After an update
86
+
87
+ An agent keeps the tool definitions it was handed when its session began, and a
88
+ session that outlives an update of this server talks to the new server with the
89
+ old definitions. The server cannot make the agent reload. What it does:
90
+
91
+ - `server_info` says which version is running and carries
92
+ `definitionsVersion`, a fingerprint of every tool definition it serves.
93
+ - Each write tool names that fingerprint in its description and takes it back
94
+ as `schemaHash`. When the two differ, the call is still carried out and the
95
+ answer adds a note that the definitions are older than the server.
96
+ - It announces a changed tool list to clients that listen for it. Claude Code
97
+ does not always: **restart the session** (in Claude Code `/mcp` and
98
+ reconnect, or start a new session) after you update the package.
99
+
84
100
  ## Writing the catalogue
85
101
 
86
102
  The write tools change a customer's live catalogue, so it helps to know what the
@@ -96,6 +112,12 @@ API does with them:
96
112
  - **Typos do not become data.** A category or a select choice that does not
97
113
  exist is refused, unless `createMissingCategories` or `addMissingOptions` asks
98
114
  for it.
115
+ - **A repeater's items may leave out `id`.** SolumFlow makes one, and the
116
+ answer's `fields` holds it; send it back to change that item. Leave it out
117
+ again and the items are replaced by new ones, and their files fetched again.
118
+ - **Read back what you wrote** with `get_product_by_ref`: every field as stored
119
+ (`fields`), what a visitor sees of them (`publicFields`), and every category
120
+ with its `status`, a concept too.
99
121
  - **Archiving keeps the product** for the invoices that name it;
100
122
  `upsert_product` with `archived: false` brings it back.
101
123
 
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ 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.5.0";
9
+ var version = "0.7.0";
10
10
 
11
11
  // src/catalog-migration-tools.ts
12
12
  import { z as z4 } from "zod";
@@ -38,6 +38,11 @@ var CatalogFieldSlugSchema = z.string().max(63).regex(FIELD_SLUG, {
38
38
  )
39
39
  });
40
40
  var cents = z.number().int().min(0).max(MAX_AMOUNT_CENTS);
41
+ var CatalogCategoryPlacementSchema = z.object({
42
+ slug: CatalogWebSlugSchema,
43
+ /** Lowest first; equal places stand in id order. */
44
+ position: z.number().int().min(-1e6).max(1e6).optional()
45
+ }).strict();
41
46
  var text = (max) => z.string().trim().max(max).nullable().optional();
42
47
  var CatalogPriceSchema = z.object({
43
48
  /** The price's own id at the source, so a renamed price stays itself. */
@@ -199,8 +204,13 @@ var CatalogProductUpsertSchema = z.object({
199
204
  socialTitle: text(300),
200
205
  socialDescription: text(1e3),
201
206
  socialImage: CatalogFileRefSchema.nullable().optional(),
202
- /** Replaces the product's categories, by slug. */
203
- categories: z.array(CatalogWebSlugSchema).max(50).optional(),
207
+ /**
208
+ * Replaces the product's categories, by slug, the first one leading. An
209
+ * entry may be `{ slug, position }` instead, to also set where the
210
+ * product stands on that category's page (`?sort=position`, lowest
211
+ * first); a plain slug leaves a stored place as it is.
212
+ */
213
+ categories: z.array(z.union([CatalogWebSlugSchema, CatalogCategoryPlacementSchema])).max(50).optional(),
204
214
  /**
205
215
  * Make a category that does not exist yet instead of refusing. Off unless
206
216
  * asked for, so a typo cannot become a category on a customer's website.
@@ -387,7 +397,13 @@ var CatalogAttributeUpsertSchema = z.object({
387
397
  * Bring the field back from the trash under its own id, with every value
388
398
  * the products kept. Nothing else may be sent with it.
389
399
  */
390
- restore: z.literal(true).optional()
400
+ restore: z.literal(true).optional(),
401
+ /**
402
+ * For a new content field (`content_text`, `repeater`, `cta`): put its
403
+ * block at the end of the product page. On by default; `false` makes the
404
+ * field only. Never moves or restores a block of an existing field.
405
+ */
406
+ placeOnPage: z.boolean().optional()
391
407
  }).strict();
392
408
  var CONFIGURATOR_FILE_KINDS = ["model", "texture"];
393
409
  var CONFIGURATOR_TEXTURE_CONTENT_TYPES = [
@@ -500,10 +516,15 @@ var objectInput = {
500
516
  "The slug of the catalogue (see list_catalogs). Leave out for the account\u2019s own catalogue."
501
517
  )
502
518
  };
503
- 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.';
519
+ 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).';
504
520
  var currency = z3.string().regex(/^[A-Z]{3}$/).optional().describe(
505
521
  "Every price in this currency (see list_currencies), each with its origin: base, fixed or converted. Left out, every price in its own currency."
506
522
  );
523
+ 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.';
524
+ var productStatus = z3.enum(["published", "draft", "archived", "all"]).optional().describe(`Which products. ${READ_STATUS_HINT}`);
525
+ var categoryStatus = z3.enum(["published", "draft", "all"]).optional().describe(
526
+ `Which categories. ${READ_STATUS_HINT.replace('"archived" what was taken out, ', "")} A category cannot be archived.`
527
+ );
507
528
  var slugOrId = z3.string().min(1).max(200).describe("The address (slug) or the id of the product.");
508
529
  function registerCatalogTools(server, client) {
509
530
  server.registerTool(
@@ -578,15 +599,18 @@ function registerCatalogTools(server, client) {
578
599
  "get_product",
579
600
  {
580
601
  title: "Read one product",
581
- description: "One product as its page needs it: name, prices, pictures, categories, variants and `fields`, keyed by field slug. Published products only, and may be a minute old: to read back what you just wrote, use get_product_by_ref. Answers that it is not found for an unknown product. " + CONTENT_BLOCKS,
582
- inputSchema: { slugOrId, currency },
602
+ 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 a server key), 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,
603
+ inputSchema: { slugOrId, currency, status: productStatus },
583
604
  annotations: { readOnlyHint: true, idempotentHint: true }
584
605
  },
585
- async ({ slugOrId: wanted, currency: inCurrency }) => {
606
+ async ({ slugOrId: wanted, currency: inCurrency, status }) => {
586
607
  try {
587
608
  const product = await client.getProduct(
588
609
  wanted,
589
- inCurrency ? { currency: inCurrency } : void 0
610
+ inCurrency || status ? {
611
+ ...inCurrency ? { currency: inCurrency } : {},
612
+ ...status ? { status } : {}
613
+ } : void 0
590
614
  );
591
615
  if (!product) {
592
616
  return answer(`No product "${wanted}" on this account.`, null);
@@ -604,8 +628,8 @@ function registerCatalogTools(server, client) {
604
628
  server.registerTool(
605
629
  "list_products",
606
630
  {
607
- title: "List the published products",
608
- 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. May be a minute old.",
631
+ title: "List the products",
632
+ 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 a server key). `sort` orders the published ones: -publishedAt (newest first, the default), publishedAt, name, or position -- the seller\u2019s own order on the page of `category`, which it needs. Each item carries `sku`, `publishedAt` and `stockQuantity` (null when not counted).",
609
633
  inputSchema: {
610
634
  ...page,
611
635
  category: z3.string().regex(
@@ -621,7 +645,11 @@ function registerCatalogTools(server, client) {
621
645
  object: z3.string().min(1).max(63).optional().describe(
622
646
  'One catalogue, by the slug of its object, such as "product".'
623
647
  ),
624
- currency
648
+ currency,
649
+ status: productStatus,
650
+ sort: z3.enum(["-publishedAt", "publishedAt", "name", "position"]).optional().describe(
651
+ "The order: newest first by default; position needs `category`."
652
+ )
625
653
  },
626
654
  annotations: { readOnlyHint: true, idempotentHint: true }
627
655
  },
@@ -637,7 +665,7 @@ function registerCatalogTools(server, client) {
637
665
  } : {}
638
666
  });
639
667
  return answer(
640
- list.data.length === 0 ? "No published products match." : [...list.data.map(describeProduct), more(list.nextCursor)].filter(Boolean).join("\n"),
668
+ 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"),
641
669
  list
642
670
  );
643
671
  } catch (error) {
@@ -648,11 +676,12 @@ function registerCatalogTools(server, client) {
648
676
  server.registerTool(
649
677
  "list_categories",
650
678
  {
651
- title: "List the published categories",
652
- 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.',
679
+ title: "List the categories",
680
+ 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 a server key).',
653
681
  inputSchema: {
654
682
  ...page,
655
- parent: z3.union([z3.literal("root"), z3.string().uuid()]).optional().describe('"root" or the id of a category.')
683
+ parent: z3.union([z3.literal("root"), z3.string().uuid()]).optional().describe('"root" or the id of a category.'),
684
+ status: categoryStatus
656
685
  },
657
686
  annotations: { readOnlyHint: true, idempotentHint: true }
658
687
  },
@@ -660,7 +689,7 @@ function registerCatalogTools(server, client) {
660
689
  try {
661
690
  const list = await client.getCategories(input);
662
691
  return answer(
663
- list.data.length === 0 ? "No published categories." : [...list.data.map(describeCategory), more(list.nextCursor)].filter(Boolean).join("\n"),
692
+ 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"),
664
693
  list
665
694
  );
666
695
  } catch (error) {
@@ -672,10 +701,11 @@ function registerCatalogTools(server, client) {
672
701
  "get_category",
673
702
  {
674
703
  title: "Read one category page",
675
- 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.",
704
+ 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, a server key); its products are the published ones.",
676
705
  inputSchema: {
677
706
  slugOrId: z3.string().min(1).max(200).describe("The address (slug) or the id of the category."),
678
- ...page
707
+ ...page,
708
+ status: categoryStatus
679
709
  },
680
710
  annotations: { readOnlyHint: true, idempotentHint: true }
681
711
  },
@@ -724,23 +754,49 @@ function registerCatalogTools(server, client) {
724
754
  );
725
755
  }
726
756
  function describeProduct(product) {
727
- const stock = product.inStock ? "" : ", out of stock";
757
+ const stock = `${product.inStock ? "" : ", out of stock"}${product.status ? `, ${product.status}` : ""}`;
728
758
  const from = product.priceFromCents === null ? `no price in ${product.currency}` : `from ${money(product.priceFromCents, product.currency)}`;
729
759
  return `- ${product.name} (${product.slug}): ${from}${stock}`;
730
760
  }
731
761
  function describeCategory(category) {
732
762
  const path = category.path.map((step) => step.name).join(" / ");
733
- return `- ${path || category.name} (${category.slug}, id ${category.id}): ${category.productCount} products`;
763
+ const state = category.status ? `, ${category.status}` : "";
764
+ return `- ${path || category.name} (${category.slug || "no address yet"}, id ${category.id}${state}): ${category.productCount} products`;
734
765
  }
735
766
  function describeField(field) {
736
767
  if (field.type !== "repeater") {
737
- return `- ${field.slug}: ${field.label}, ${field.type}`;
768
+ return `- ${field.slug}: ${field.label}, ${field.type}${settingsOf(field)}`;
738
769
  }
739
770
  const items = (field.itemFields ?? []).map(({ key, type }) => `${key}:${type}`).join(", ");
740
771
  const preset = field.preset ? `, ${field.preset}` : "";
741
772
  const title = field.titleKey ? `; titled by ${field.titleKey}` : "";
742
773
  return `- ${field.slug}: ${field.label}, repeater (${field.display ?? "list"}${preset}; items: ${items}${title})`;
743
774
  }
775
+ var SETTING_KEYS = [
776
+ "prefix",
777
+ "suffix",
778
+ "currency",
779
+ "symbolPosition",
780
+ "thousandsSeparator",
781
+ "decimalSeparator",
782
+ "decimals",
783
+ "display",
784
+ "layout",
785
+ "mode",
786
+ "secondary"
787
+ ];
788
+ function settingsOf(field) {
789
+ const given = SETTING_KEYS.flatMap(
790
+ (key) => field[key] === void 0 ? [] : [`${key} ${JSON.stringify(field[key])}`]
791
+ );
792
+ if (field.rows) {
793
+ given.push(`${field.rows.length} rows`);
794
+ }
795
+ if (field.columns) {
796
+ given.push(`${field.columns.length} columns`);
797
+ }
798
+ return given.length > 0 ? ` (${given.join(", ")})` : "";
799
+ }
744
800
 
745
801
  // src/catalog-migration-tools.ts
746
802
  import { readFile, stat } from "fs/promises";
@@ -781,7 +837,7 @@ function registerCatalogMigrationTools(server, client) {
781
837
  "get_product_by_ref",
782
838
  {
783
839
  title: "Read back a product you wrote",
784
- description: "One product by the reference it was written under (`source` + `externalRef`), a draft too: its page, 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.",
840
+ 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.",
785
841
  inputSchema: {
786
842
  externalRef: CatalogExternalRefSchema,
787
843
  source: CatalogSourceSchema,
@@ -809,7 +865,7 @@ function registerCatalogMigrationTools(server, client) {
809
865
  (file) => file.status === "pending"
810
866
  );
811
867
  return answer(
812
- `${product.name} (${product.slug || "no address"}), ${product.availability}; ${product.images.length} pictures; files: ${pending.length} pending, ${refused.length} refused${refused.length ? ` (${refused.map((file) => `${file.ref}: ${file.reason}`).join("; ")})` : ""}.`,
868
+ `${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("; ")})` : ""}.`,
813
869
  product
814
870
  );
815
871
  } catch (error) {
@@ -906,18 +962,25 @@ function registerCatalogMigrationTools(server, client) {
906
962
  "get_category_by_ref",
907
963
  {
908
964
  title: "Find a category by its reference",
909
- 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. Only a published category.",
965
+ 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 a server key).',
910
966
  inputSchema: {
911
967
  source: CatalogSourceSchema,
912
- externalRef: CatalogExternalRefSchema
968
+ externalRef: CatalogExternalRefSchema,
969
+ status: z4.enum(["published", "draft", "all"]).optional().describe(
970
+ "Which categories to look in; anything but published needs catalog:write."
971
+ )
913
972
  },
914
973
  annotations: { readOnlyHint: true, idempotentHint: true }
915
974
  },
916
- async ({ source, externalRef }) => {
975
+ async ({ source, externalRef, status }) => {
917
976
  try {
918
- const category = await client.getCategoryByRef(source, externalRef);
977
+ const category = await client.getCategoryByRef(
978
+ source,
979
+ externalRef,
980
+ status ? { status } : void 0
981
+ );
919
982
  return answer(
920
- category ? `${category.name} (${category.slug}), id ${category.id}.` : `No published category with reference "${externalRef}" from "${source}".`,
983
+ 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")'}.`,
921
984
  category
922
985
  );
923
986
  } catch (error) {
@@ -972,6 +1035,16 @@ function registerCatalogMigrationTools(server, client) {
972
1035
  }
973
1036
 
974
1037
  // src/catalog-write-tools.ts
1038
+ import { CrmApiError as CrmApiError2 } from "@solumflow-app/crm-client";
1039
+ function usedIn(details) {
1040
+ const blockers = details?.blockers;
1041
+ if (!Array.isArray(blockers) || blockers.length === 0) {
1042
+ return null;
1043
+ }
1044
+ return blockers.map(
1045
+ (entry) => `${String(entry.kind).replaceAll("_", " ")} (${String(entry.count)})`
1046
+ ).join(", ");
1047
+ }
975
1048
  function target2(object2) {
976
1049
  return object2 ? { object: object2 } : void 0;
977
1050
  }
@@ -982,7 +1055,7 @@ function registerCatalogWriteTools(server, client) {
982
1055
  "upsert_product",
983
1056
  {
984
1057
  title: "Make or change a product",
985
- 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. To write many products use upsert_products. Read list_catalog_fields first for the slugs `fields` takes.',
1058
+ 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.',
986
1059
  inputSchema: {
987
1060
  externalRef: CatalogExternalRefSchema.describe(
988
1061
  "The id of the product in the system named by `source`."
@@ -1039,6 +1112,49 @@ function registerCatalogWriteTools(server, client) {
1039
1112
  }
1040
1113
  }
1041
1114
  );
1115
+ server.registerTool(
1116
+ "delete_product",
1117
+ {
1118
+ title: "Delete a product for good",
1119
+ 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.",
1120
+ inputSchema: {
1121
+ externalRef: CatalogExternalRefSchema.describe(
1122
+ "The id of the product in the system named by `source`."
1123
+ ),
1124
+ ...CatalogProductArchiveSchema.shape,
1125
+ ...objectInput
1126
+ },
1127
+ annotations: { destructiveHint: true, idempotentHint: true }
1128
+ },
1129
+ async ({ externalRef, source, object: object2 }) => {
1130
+ try {
1131
+ const result = await client.deleteProduct(
1132
+ externalRef,
1133
+ source,
1134
+ target2(object2)
1135
+ );
1136
+ return answer("Deleted for good.", result);
1137
+ } catch (error) {
1138
+ if (error instanceof CrmApiError2 && error.code === "conflict") {
1139
+ const places = usedIn(error.details);
1140
+ if (places) {
1141
+ return {
1142
+ isError: true,
1143
+ content: [
1144
+ {
1145
+ type: "text",
1146
+ text: `conflict: ${error.message}
1147
+ Used in: ${places}.
1148
+ Archive it with archive_product instead.`
1149
+ }
1150
+ ]
1151
+ };
1152
+ }
1153
+ }
1154
+ return refusal(error, "catalog:write");
1155
+ }
1156
+ }
1157
+ );
1042
1158
  server.registerTool(
1043
1159
  "upsert_category",
1044
1160
  {
@@ -1073,7 +1189,7 @@ function registerCatalogWriteTools(server, client) {
1073
1189
  "upsert_field",
1074
1190
  {
1075
1191
  title: "Make or change a catalogue field",
1076
- 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. `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.",
1192
+ 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.",
1077
1193
  inputSchema: {
1078
1194
  slug: CatalogFieldSlugSchema.describe(
1079
1195
  "The slug of the field: a lower-case letter, then lower case, digits and underscores."
@@ -1092,7 +1208,7 @@ function registerCatalogWriteTools(server, client) {
1092
1208
  }
1093
1209
  const field = await client.upsertField(slug, input, target2(object2));
1094
1210
  return answer(
1095
- `${field.created ? "Made" : "Changed"} ${field.label} (${field.slug}), ${field.type}, ${field.public ? "public" : "not public"}.`,
1211
+ `${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"}).`),
1096
1212
  field
1097
1213
  );
1098
1214
  } catch (error) {
@@ -3550,7 +3666,119 @@ function readServerConfig(env) {
3550
3666
  return { ok: true, apiKey, baseUrl };
3551
3667
  }
3552
3668
 
3669
+ // src/server-info-tool.ts
3670
+ function registerServerInfoTool(server, info) {
3671
+ server.registerTool(
3672
+ "server_info",
3673
+ {
3674
+ title: "Which version of this server is running",
3675
+ 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.",
3676
+ inputSchema: {},
3677
+ annotations: { readOnlyHint: true, idempotentHint: true }
3678
+ },
3679
+ async () => {
3680
+ const result = {
3681
+ version: info.version,
3682
+ clientVersion: info.clientVersion,
3683
+ baseUrl: info.baseUrl,
3684
+ definitionsVersion: info.definitionsVersion()
3685
+ };
3686
+ return answer(
3687
+ `crm-mcp-server ${result.version} (client ${result.clientVersion}), API ${result.baseUrl}, tool definitions ${result.definitionsVersion}.`,
3688
+ result
3689
+ );
3690
+ }
3691
+ );
3692
+ }
3693
+
3694
+ // src/tool-definitions-version.ts
3695
+ import { z as z11 } from "zod";
3696
+ import { createHash } from "crypto";
3697
+ function toolDefinitionsHash(tools) {
3698
+ const definitions = [...tools].sort((left, right) => left.name.localeCompare(right.name)).map(({ name, config }) => ({
3699
+ name,
3700
+ title: config.title ?? null,
3701
+ description: config.description ?? null,
3702
+ inputSchema: config.inputSchema ? z11.toJSONSchema(z11.object(config.inputSchema), {
3703
+ io: "input",
3704
+ unrepresentable: "any"
3705
+ }) : null,
3706
+ annotations: config.annotations ?? null
3707
+ }));
3708
+ return createHash("sha256").update(JSON.stringify(definitions)).digest("hex").slice(0, 12);
3709
+ }
3710
+ function writesSomething(config) {
3711
+ return config.annotations?.readOnlyHint !== true;
3712
+ }
3713
+ function holdToolRegistrations(real) {
3714
+ const pending = [];
3715
+ let hash;
3716
+ const server = new Proxy(real, {
3717
+ get(target3, property) {
3718
+ if (property === "registerTool") {
3719
+ return (name, config, handler) => {
3720
+ pending.push({ name, config, handler });
3721
+ return void 0;
3722
+ };
3723
+ }
3724
+ const value = Reflect.get(target3, property, target3);
3725
+ return typeof value === "function" ? value.bind(target3) : value;
3726
+ }
3727
+ });
3728
+ function currentHash() {
3729
+ hash ??= toolDefinitionsHash(pending);
3730
+ return hash;
3731
+ }
3732
+ function publish() {
3733
+ const version2 = currentHash();
3734
+ for (const { name, config, handler } of pending) {
3735
+ if (!writesSomething(config)) {
3736
+ real.registerTool(
3737
+ name,
3738
+ config,
3739
+ handler
3740
+ );
3741
+ continue;
3742
+ }
3743
+ real.registerTool(
3744
+ name,
3745
+ {
3746
+ ...config,
3747
+ description: `${config.description ?? ""} Definitions version ${version2}: send it as schemaHash and the answer tells you when these tool definitions are older than the server.`,
3748
+ inputSchema: {
3749
+ ...config.inputSchema,
3750
+ schemaHash: z11.string().max(64).optional().describe(
3751
+ `The definitions version named in this tool's description (${version2}).`
3752
+ )
3753
+ }
3754
+ },
3755
+ (async ({ schemaHash, ...input }, extra) => {
3756
+ const result = await handler(input, extra);
3757
+ return schemaHash !== void 0 && schemaHash !== version2 ? {
3758
+ ...result,
3759
+ content: [
3760
+ ...result.content ?? [],
3761
+ { type: "text", text: staleDefinitions(version2) }
3762
+ ]
3763
+ } : result;
3764
+ })
3765
+ );
3766
+ }
3767
+ return version2;
3768
+ }
3769
+ return { server, publish, currentHash };
3770
+ }
3771
+ function staleDefinitions(version2) {
3772
+ 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.`;
3773
+ }
3774
+
3553
3775
  // src/index.ts
3776
+ import { createRequire } from "module";
3777
+ function installedClientVersion() {
3778
+ return createRequire(import.meta.url)(
3779
+ "@solumflow-app/crm-client/package.json"
3780
+ ).version;
3781
+ }
3554
3782
  async function main() {
3555
3783
  const config = readServerConfig(process.env);
3556
3784
  if (!config.ok) {
@@ -3567,13 +3795,26 @@ async function main() {
3567
3795
  apiKey: config.apiKey,
3568
3796
  baseUrl: config.baseUrl
3569
3797
  });
3570
- registerPlacementTools(server, client);
3571
- registerCatalogTools(server, client);
3572
- registerCatalogWriteTools(server, client);
3573
- registerCatalogMigrationTools(server, client);
3574
- registerEventTools(server, client);
3575
- registerConfiguratorTools(server, client);
3798
+ const held = holdToolRegistrations(server);
3799
+ registerServerInfoTool(held.server, {
3800
+ version,
3801
+ clientVersion: installedClientVersion(),
3802
+ baseUrl: config.baseUrl,
3803
+ definitionsVersion: held.currentHash
3804
+ });
3805
+ registerPlacementTools(held.server, client);
3806
+ registerCatalogTools(held.server, client);
3807
+ registerCatalogWriteTools(held.server, client);
3808
+ registerCatalogMigrationTools(held.server, client);
3809
+ registerEventTools(held.server, client);
3810
+ registerConfiguratorTools(held.server, client);
3811
+ held.publish();
3576
3812
  await server.connect(new StdioServerTransport());
3813
+ try {
3814
+ server.sendToolListChanged();
3815
+ } catch (error) {
3816
+ console.error("crm-mcp-server: could not announce the tool list:", error);
3817
+ }
3577
3818
  }
3578
3819
  main().catch((error) => {
3579
3820
  console.error("crm-mcp-server:", error);