@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 +24 -2
- package/dist/index.js +279 -38
- 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.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
|
-
/**
|
|
203
|
-
|
|
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 ? {
|
|
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
|
|
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.
|
|
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 ?
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
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);
|