@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 +24 -2
- package/dist/index.js +264 -36
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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.
|
|
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 ? {
|
|
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
|
|
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.
|
|
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 ?
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
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);
|