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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.6.0";
10
10
 
11
11
  // src/catalog-migration-tools.ts
12
12
  import { z as z4 } from "zod";
@@ -387,7 +387,13 @@ var CatalogAttributeUpsertSchema = z.object({
387
387
  * Bring the field back from the trash under its own id, with every value
388
388
  * the products kept. Nothing else may be sent with it.
389
389
  */
390
- restore: z.literal(true).optional()
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()
391
397
  }).strict();
392
398
  var CONFIGURATOR_FILE_KINDS = ["model", "texture"];
393
399
  var CONFIGURATOR_TEXTURE_CONTENT_TYPES = [
@@ -500,10 +506,15 @@ var objectInput = {
500
506
  "The slug of the catalogue (see list_catalogs). Leave out for the account\u2019s own catalogue."
501
507
  )
502
508
  };
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.';
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).';
504
510
  var currency = z3.string().regex(/^[A-Z]{3}$/).optional().describe(
505
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."
506
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
+ );
507
518
  var slugOrId = z3.string().min(1).max(200).describe("The address (slug) or the id of the product.");
508
519
  function registerCatalogTools(server, client) {
509
520
  server.registerTool(
@@ -578,15 +589,18 @@ function registerCatalogTools(server, client) {
578
589
  "get_product",
579
590
  {
580
591
  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 },
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 },
583
594
  annotations: { readOnlyHint: true, idempotentHint: true }
584
595
  },
585
- async ({ slugOrId: wanted, currency: inCurrency }) => {
596
+ async ({ slugOrId: wanted, currency: inCurrency, status }) => {
586
597
  try {
587
598
  const product = await client.getProduct(
588
599
  wanted,
589
- inCurrency ? { currency: inCurrency } : void 0
600
+ inCurrency || status ? {
601
+ ...inCurrency ? { currency: inCurrency } : {},
602
+ ...status ? { status } : {}
603
+ } : void 0
590
604
  );
591
605
  if (!product) {
592
606
  return answer(`No product "${wanted}" on this account.`, null);
@@ -604,8 +618,8 @@ function registerCatalogTools(server, client) {
604
618
  server.registerTool(
605
619
  "list_products",
606
620
  {
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.",
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).",
609
623
  inputSchema: {
610
624
  ...page,
611
625
  category: z3.string().regex(
@@ -621,7 +635,8 @@ function registerCatalogTools(server, client) {
621
635
  object: z3.string().min(1).max(63).optional().describe(
622
636
  'One catalogue, by the slug of its object, such as "product".'
623
637
  ),
624
- currency
638
+ currency,
639
+ status: productStatus
625
640
  },
626
641
  annotations: { readOnlyHint: true, idempotentHint: true }
627
642
  },
@@ -637,7 +652,7 @@ function registerCatalogTools(server, client) {
637
652
  } : {}
638
653
  });
639
654
  return answer(
640
- 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"),
641
656
  list
642
657
  );
643
658
  } catch (error) {
@@ -648,11 +663,12 @@ function registerCatalogTools(server, client) {
648
663
  server.registerTool(
649
664
  "list_categories",
650
665
  {
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.',
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).',
653
668
  inputSchema: {
654
669
  ...page,
655
- 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
656
672
  },
657
673
  annotations: { readOnlyHint: true, idempotentHint: true }
658
674
  },
@@ -660,7 +676,7 @@ function registerCatalogTools(server, client) {
660
676
  try {
661
677
  const list = await client.getCategories(input);
662
678
  return answer(
663
- 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"),
664
680
  list
665
681
  );
666
682
  } catch (error) {
@@ -672,10 +688,11 @@ function registerCatalogTools(server, client) {
672
688
  "get_category",
673
689
  {
674
690
  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.",
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.",
676
692
  inputSchema: {
677
693
  slugOrId: z3.string().min(1).max(200).describe("The address (slug) or the id of the category."),
678
- ...page
694
+ ...page,
695
+ status: categoryStatus
679
696
  },
680
697
  annotations: { readOnlyHint: true, idempotentHint: true }
681
698
  },
@@ -724,23 +741,49 @@ function registerCatalogTools(server, client) {
724
741
  );
725
742
  }
726
743
  function describeProduct(product) {
727
- const stock = product.inStock ? "" : ", out of stock";
744
+ const stock = `${product.inStock ? "" : ", out of stock"}${product.status ? `, ${product.status}` : ""}`;
728
745
  const from = product.priceFromCents === null ? `no price in ${product.currency}` : `from ${money(product.priceFromCents, product.currency)}`;
729
746
  return `- ${product.name} (${product.slug}): ${from}${stock}`;
730
747
  }
731
748
  function describeCategory(category) {
732
749
  const path = category.path.map((step) => step.name).join(" / ");
733
- 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`;
734
752
  }
735
753
  function describeField(field) {
736
754
  if (field.type !== "repeater") {
737
- return `- ${field.slug}: ${field.label}, ${field.type}`;
755
+ return `- ${field.slug}: ${field.label}, ${field.type}${settingsOf(field)}`;
738
756
  }
739
757
  const items = (field.itemFields ?? []).map(({ key, type }) => `${key}:${type}`).join(", ");
740
758
  const preset = field.preset ? `, ${field.preset}` : "";
741
759
  const title = field.titleKey ? `; titled by ${field.titleKey}` : "";
742
760
  return `- ${field.slug}: ${field.label}, repeater (${field.display ?? "list"}${preset}; items: ${items}${title})`;
743
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
+ }
744
787
 
745
788
  // src/catalog-migration-tools.ts
746
789
  import { readFile, stat } from "fs/promises";
@@ -781,7 +824,7 @@ function registerCatalogMigrationTools(server, client) {
781
824
  "get_product_by_ref",
782
825
  {
783
826
  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.",
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.",
785
828
  inputSchema: {
786
829
  externalRef: CatalogExternalRefSchema,
787
830
  source: CatalogSourceSchema,
@@ -809,7 +852,7 @@ function registerCatalogMigrationTools(server, client) {
809
852
  (file) => file.status === "pending"
810
853
  );
811
854
  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("; ")})` : ""}.`,
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("; ")})` : ""}.`,
813
856
  product
814
857
  );
815
858
  } catch (error) {
@@ -906,18 +949,25 @@ function registerCatalogMigrationTools(server, client) {
906
949
  "get_category_by_ref",
907
950
  {
908
951
  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.",
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).',
910
953
  inputSchema: {
911
954
  source: CatalogSourceSchema,
912
- externalRef: CatalogExternalRefSchema
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
+ )
913
959
  },
914
960
  annotations: { readOnlyHint: true, idempotentHint: true }
915
961
  },
916
- async ({ source, externalRef }) => {
962
+ async ({ source, externalRef, status }) => {
917
963
  try {
918
- const category = await client.getCategoryByRef(source, externalRef);
964
+ const category = await client.getCategoryByRef(
965
+ source,
966
+ externalRef,
967
+ status ? { status } : void 0
968
+ );
919
969
  return answer(
920
- category ? `${category.name} (${category.slug}), id ${category.id}.` : `No published category with reference "${externalRef}" from "${source}".`,
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")'}.`,
921
971
  category
922
972
  );
923
973
  } catch (error) {
@@ -972,6 +1022,16 @@ function registerCatalogMigrationTools(server, client) {
972
1022
  }
973
1023
 
974
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
+ }
975
1035
  function target2(object2) {
976
1036
  return object2 ? { object: object2 } : void 0;
977
1037
  }
@@ -982,7 +1042,7 @@ function registerCatalogWriteTools(server, client) {
982
1042
  "upsert_product",
983
1043
  {
984
1044
  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.',
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.',
986
1046
  inputSchema: {
987
1047
  externalRef: CatalogExternalRefSchema.describe(
988
1048
  "The id of the product in the system named by `source`."
@@ -1039,6 +1099,49 @@ function registerCatalogWriteTools(server, client) {
1039
1099
  }
1040
1100
  }
1041
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
+ );
1042
1145
  server.registerTool(
1043
1146
  "upsert_category",
1044
1147
  {
@@ -1073,7 +1176,7 @@ function registerCatalogWriteTools(server, client) {
1073
1176
  "upsert_field",
1074
1177
  {
1075
1178
  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.",
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.",
1077
1180
  inputSchema: {
1078
1181
  slug: CatalogFieldSlugSchema.describe(
1079
1182
  "The slug of the field: a lower-case letter, then lower case, digits and underscores."
@@ -1092,7 +1195,7 @@ function registerCatalogWriteTools(server, client) {
1092
1195
  }
1093
1196
  const field = await client.upsertField(slug, input, target2(object2));
1094
1197
  return answer(
1095
- `${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"}).`),
1096
1199
  field
1097
1200
  );
1098
1201
  } catch (error) {
@@ -3550,7 +3653,119 @@ function readServerConfig(env) {
3550
3653
  return { ok: true, apiKey, baseUrl };
3551
3654
  }
3552
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
+
3553
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
+ }
3554
3769
  async function main() {
3555
3770
  const config = readServerConfig(process.env);
3556
3771
  if (!config.ok) {
@@ -3567,13 +3782,26 @@ async function main() {
3567
3782
  apiKey: config.apiKey,
3568
3783
  baseUrl: config.baseUrl
3569
3784
  });
3570
- registerPlacementTools(server, client);
3571
- registerCatalogTools(server, client);
3572
- registerCatalogWriteTools(server, client);
3573
- registerCatalogMigrationTools(server, client);
3574
- registerEventTools(server, client);
3575
- 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();
3576
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
+ }
3577
3805
  }
3578
3806
  main().catch((error) => {
3579
3807
  console.error("crm-mcp-server:", error);