webcake-storefront-mcp 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -139,8 +139,16 @@ export class WebcakeCmsApi {
139
139
  listPages() {
140
140
  return this.request("GET", `/api/v1/site/${this.siteId}/pages`);
141
141
  }
142
+ /** Create a page. The backend creates the page AND its source in one call, so `source`
143
+ * is REQUIRED and must be a JSON string (stringified here if an object is passed).
144
+ * `slug`/`is_homepage` are NOT applied at create — set them afterwards via updatePage. */
142
145
  createPage(params) {
143
- return this.request("POST", `/api/v1/site/${this.siteId}/page`, { body: params });
146
+ const body = { ...params };
147
+ if (body.source != null && typeof body.source !== "string")
148
+ body.source = JSON.stringify(body.source);
149
+ if (body.source == null)
150
+ body.source = JSON.stringify({ sections: [] });
151
+ return this.request("POST", `/api/v1/site/${this.siteId}/page`, { body });
144
152
  }
145
153
  updatePage(pageId, params) {
146
154
  return this.request("POST", `/api/v1/site/${this.siteId}/${pageId}/update_page`, { body: params });
@@ -169,8 +177,21 @@ export class WebcakeCmsApi {
169
177
  saveSite(params = {}) {
170
178
  return this.request("POST", `/api/v1/site/${this.siteId}/save`, { body: params, timeout: 60000 });
171
179
  }
172
- publishSite(params = {}) {
173
- return this.request("POST", `/api/v1/site/${this.siteId}/publish`, { body: params, timeout: 60000 });
180
+ /** Publish the site live. /publish runs the full "save" pipeline, which OVERWRITES
181
+ * site.settings with the body's `settings` so we send the CURRENT settings (else
182
+ * they'd be nulled, disabling use_store/use_blog/etc.). Other collections default to []. */
183
+ async publishSite(params = {}) {
184
+ let settings = params.settings;
185
+ if (settings === undefined) {
186
+ settings = await this.getSiteSettings().catch(() => ({}));
187
+ }
188
+ // The save pipeline stores site.settings as a JSON STRING — an object body is
189
+ // rejected (422). Stringify unless the caller already passed a string.
190
+ const settingsStr = typeof settings === "string" ? settings : JSON.stringify(settings || {});
191
+ return this.request("POST", `/api/v1/site/${this.siteId}/publish`, {
192
+ body: { global_sources: [], global_sections: [], page_contents: [], ...params, settings: settingsStr },
193
+ timeout: 60000,
194
+ });
174
195
  }
175
196
  uploadImageBase64({ base64, content_type } = {}) {
176
197
  return this.request("POST", `/api/v1/site/${this.siteId}/media/content/b64`, {
@@ -266,6 +287,22 @@ export class WebcakeCmsApi {
266
287
  deleteArticle(id) {
267
288
  return this.request("DELETE", `/api/v1/cms_function/${this.siteId}/blog/article/${id}`);
268
289
  }
290
+ /** Create a blog/article category. Command-based: pass a `commands` array whose entries
291
+ * each carry a caller-generated `data.id` (the new category id). Response is generic. */
292
+ createBlogCategory(commands) {
293
+ return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/blog/categories/create`, {
294
+ body: { site_id: this.siteId, commands },
295
+ timeout: 60000,
296
+ });
297
+ }
298
+ /** Create a blog article via the dashboard command pipeline (supports category linkage,
299
+ * images, summary, content). Caller generates the article id in each command's data.id. */
300
+ createBlogArticle(commands) {
301
+ return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/blog/articles/create`, {
302
+ body: { site_id: this.siteId, commands },
303
+ timeout: 60000,
304
+ });
305
+ }
269
306
  // ── Products ──
270
307
  listProducts(query) {
271
308
  return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/products/all`, { query });
@@ -282,6 +319,22 @@ export class WebcakeCmsApi {
282
319
  getProductsByCategory(categoryId) {
283
320
  return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/categories/products`, { query: { category_id: categoryId } });
284
321
  }
322
+ /** Create a product. Body wraps fields in `product_params`; the backend generates the
323
+ * id/slug and sets is_published. Pass variations (price/stock per SKU) + categories. */
324
+ createProduct(productParams) {
325
+ return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/products/create`, {
326
+ body: { site_id: this.siteId, product_params: productParams },
327
+ timeout: 60000,
328
+ });
329
+ }
330
+ /** Create a product category. Command-based: pass a `commands` array whose entries each
331
+ * carry a caller-generated `data.id` (the new category id). Response is generic. */
332
+ createProductCategory(commands) {
333
+ return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/categories/create`, {
334
+ body: { site_id: this.siteId, commands },
335
+ timeout: 60000,
336
+ });
337
+ }
285
338
  // ── Orders ──
286
339
  listOrders(query) {
287
340
  return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/orders/all`, { query });
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.5.1",
4
+ "d": "23/06/2026",
5
+ "type": "Fixed",
6
+ "en": "build_page now passes the finalized page source to create_page in the initial create call (required by the backend), then sets slug and is_homepage…",
7
+ "vi": "build_page nay truyền source trang đã hoàn thiện vào lời gọi create_page ban đầu (bắt buộc bởi backend), sau đó đặt slug và is_homepage qua một lời…"
8
+ },
9
+ {
10
+ "v": "1.5.0",
11
+ "d": "23/06/2026",
12
+ "type": "Added",
13
+ "en": "New create_product tool creates a storefront product (simple name + price, or advanced with named attributes and per-SKU variations); accepts hosted…",
14
+ "vi": "Tool mới create_product tạo sản phẩm cho storefront (đơn giản với tên + giá, hoặc nâng cao với attributes và variations theo từng SKU); nhận URL ảnh…"
15
+ },
2
16
  {
3
17
  "v": "1.4.0",
4
18
  "d": "23/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
41
  "en": "The remote landing page served by the serve command now includes a \"What's new\" section that renders a version timeline loaded from a build-time…",
28
42
  "vi": "Trang landing của lệnh serve nay có thêm mục \"Có gì mới\" hiển thị timeline lịch sử phiên bản được tải từ file changelog.json sinh ra lúc build (từ…"
29
- },
30
- {
31
- "v": "1.1.3",
32
- "d": "23/06/2026",
33
- "type": "Changed",
34
- "en": "The install command's interactive wizard now runs a three-step flow: environment selection (prod / staging / local), authentication, then IDE…",
35
- "vi": "Trình hướng dẫn tương tác của lệnh install nay chạy theo 3 bước: chọn môi trường (prod / staging / local), xác thực, rồi cấu hình IDE."
36
- },
37
- {
38
- "v": "1.1.2",
39
- "d": "23/06/2026",
40
- "type": "Fixed",
41
- "en": "The server no longer crashes at startup in container environments built with npm ci --ignore-scripts; the better-sqlite3 native SQLite module has…",
42
- "vi": "Server không còn bị crash khi khởi động trong môi trường container được build bằng npm ci --ignore-scripts; module SQLite native better-sqlite3 đã…"
43
43
  }
44
44
  ]
package/dist/server.js CHANGED
@@ -7,6 +7,7 @@ import { registerArticleTools } from "./tools/articles.js";
7
7
  import { registerCustomerTools } from "./tools/customers.js";
8
8
  import { registerAutomationTools } from "./tools/automation.js";
9
9
  import { registerProductTools } from "./tools/products.js";
10
+ import { registerCatalogWriteTools } from "./tools/catalog-write.js";
10
11
  import { registerOrderTools } from "./tools/orders.js";
11
12
  import { registerSiteStyleTools } from "./tools/site-style.js";
12
13
  import { registerAppTools } from "./tools/apps.js";
@@ -22,6 +23,8 @@ IMPORTANT: When the user asks ANY question about their website, store, products,
22
23
 
23
24
  You can also BUILD pages: use get_build_guide, list_elements, get_element to learn the BuilderX component model, new_section/new_element to compose, validate_page to check, then build_page (dry_run first) to create. Publishing is site-level via publish_site.
24
25
 
26
+ To make a generated site look real, also CREATE DATA so dataset bindings resolve: create_product_category + create_product (storefront), create_blog_category + create_article (blog). Get image URLs from search_images / upload_images first, then reference them. A good flow for a fresh site: create_site → create a few categories → create products in them → build_page (home + store/blog pages) → publish_site.
27
+
25
28
  Workflow:
26
29
  1. On first interaction, call get_current_context. The site is NOT set from env — if no site is selected yet, call list_my_sites and ask the user which site to work on, then switch_site (the choice is saved and reused next session). To start from scratch, create_site makes a new site and switches to it; then build a homepage with build_page (type:'main', is_homepage:true).
27
30
  2. Before answering a site-specific question, query the relevant tool.
@@ -50,6 +53,7 @@ export function createServer(api, opts = {}) {
50
53
  registerCustomerTools(server, api, handle);
51
54
  registerAutomationTools(server, api, handle);
52
55
  registerProductTools(server, api, handle);
56
+ registerCatalogWriteTools(server, api, handle);
53
57
  registerOrderTools(server, api, handle);
54
58
  registerSiteStyleTools(server, api, handle);
55
59
  registerAppTools(server, api, handle);
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { randomUUID } from "node:crypto";
2
3
  export function registerArticleTools(server, api, handle) {
3
4
  server.tool("list_articles", "List blog articles (metadata only, without HTML content). Use get_article to get full content", {
4
5
  page: z.number().optional().describe("Page number"),
@@ -27,16 +28,36 @@ export function registerArticleTools(server, api, handle) {
27
28
  server.tool("get_article", "Get article details by ID", {
28
29
  id: z.string().describe("Article ID"),
29
30
  }, ({ id }) => handle(() => api.getArticle(id)));
30
- server.tool("create_article", "Create a new blog article", {
31
+ server.tool("create_article", `Create a blog article so blog/post pages (post-list, grid-blog, post-overlay) have content.
32
+ Built via the dashboard command pipeline: title + optional summary, HTML content, image URLs, and
33
+ category linkage. Pass category_ids from create_blog_category / list articles' categories so the
34
+ post shows up under those categories (it is also auto-filed under the default category). Image URLs
35
+ must be hosted (search_images / upload_images). The backend generates the id and slug.`, {
31
36
  name: z.string().describe("Article title"),
32
- slug: z.string().describe("URL slug"),
33
- content: z.string().describe("HTML content"),
34
- summary: z.string().optional().describe("Summary"),
35
- category_id: z.string().optional().describe("Category ID"),
36
- tags: z.array(z.string()).optional().describe("Tags"),
37
- images: z.array(z.string()).optional().describe("Image URLs"),
38
- is_hidden: z.boolean().default(false).describe("Hide from public"),
39
- }, (params) => handle(() => api.createArticle(params)));
37
+ content: z.string().optional().describe("HTML content of the post"),
38
+ summary: z.string().optional().describe("Short summary / excerpt"),
39
+ images: z.array(z.string()).optional().describe("Hosted image URLs; the first is the cover image"),
40
+ category_ids: z.array(z.string()).optional().describe("Blog category IDs to file the post under (from create_blog_category)"),
41
+ }, ({ name, content, summary, images, category_ids }) => handle(async () => {
42
+ const id = randomUUID();
43
+ const commands = [{ name: "create_article", data: { id, name } }];
44
+ if (summary)
45
+ commands.push({ name: "summary_article", data: { id, summary } });
46
+ if (images && images.length)
47
+ commands.push({ name: "image_article", data: { id, images } });
48
+ if (content)
49
+ commands.push({ name: "content_article", data: { id, content } });
50
+ if (category_ids && category_ids.length)
51
+ commands.push({ name: "bulk_add_category_to_article", data: { id, ids: category_ids } });
52
+ await api.createBlogArticle(commands);
53
+ return {
54
+ success: true,
55
+ article_id: id,
56
+ name,
57
+ categories: category_ids || [],
58
+ cover: images?.[0] || null,
59
+ };
60
+ }));
40
61
  server.tool("update_article", "Update a blog article", {
41
62
  id: z.string().describe("Article ID"),
42
63
  name: z.string().optional().describe("New title"),
@@ -96,14 +96,20 @@ The source must be { sections: [...] } — build sections with new_section. Vali
96
96
  return { error: `Could not enable site.settings.${requiredFlag} (needed for a '${kind}' page). ${e?.message ?? e}` };
97
97
  }
98
98
  }
99
- const created = await api.createPage({ name, slug, ...(typeNum != null ? { type: typeNum } : {}), is_homepage });
99
+ // Expand runtime -> bp1..bp4 so the saved source actually renders on the storefront.
100
+ finalizeForRender(parsed);
101
+ // createPage saves the page AND its source in one call (source is required there).
102
+ const created = await api.createPage({ name, source: parsed, ...(typeNum != null ? { type: typeNum } : {}) });
100
103
  const pageId = newPageId(created);
101
104
  if (!pageId) {
102
- return { error: "Page created but no id was returned; cannot save source.", created };
105
+ return { error: "Page created but no id was returned.", created };
106
+ }
107
+ // slug / homepage are not applied at create — set them via update_page.
108
+ if (slug || is_homepage) {
109
+ await api
110
+ .updatePage(pageId, { ...(slug ? { slug } : {}), ...(is_homepage ? { is_homepage: true } : {}) })
111
+ .catch(() => { });
103
112
  }
104
- // Expand runtime -> bp1..bp4 so the saved source actually renders on the storefront.
105
- finalizeForRender(parsed);
106
- const saved = await api.updatePageSource(pageId, { source: parsed });
107
113
  return {
108
114
  success: true,
109
115
  page_id: pageId,
@@ -111,7 +117,6 @@ The source must be { sections: [...] } — build sections with new_section. Vali
111
117
  slug,
112
118
  page_type: kind ?? null,
113
119
  ...(feature ? { data_source: { flag: feature.flag, newly_enabled: feature.changed } } : {}),
114
- page_source_id: saved && saved.data && saved.data.id,
115
120
  stats: validation.stats,
116
121
  };
117
122
  }));
@@ -0,0 +1,129 @@
1
+ // Write tools for catalog DATA — products, product categories, blog categories — so a
2
+ // generated site has real content to render (grid-product / grid-category / post-list
3
+ // bindings resolve only when these exist). Articles already have create_article.
4
+ //
5
+ // The category endpoints are COMMAND-based and the CALLER must generate the new id
6
+ // (the response is a generic {code:success}), so we mint a UUID and return it.
7
+ import { z } from "zod";
8
+ import { randomUUID } from "node:crypto";
9
+ const variationSpec = z.object({
10
+ retail_price: z.number().describe("Selling price (what the customer pays)"),
11
+ original_price: z.number().optional().describe("List/compare-at price (defaults to retail_price)"),
12
+ remain_quantity: z.number().optional().describe("Stock quantity (default 100)"),
13
+ custom_id: z.string().optional().describe("SKU for this variation (auto-generated if omitted)"),
14
+ images: z.array(z.string()).optional().describe("Hosted image URLs for this variation"),
15
+ weight: z.number().optional().describe("Weight in grams (default 0)"),
16
+ fields: z
17
+ .array(z.object({ name: z.string(), value: z.string() }))
18
+ .optional()
19
+ .describe("Attribute values for this variation, e.g. [{name:'Color',value:'Đen'},{name:'Size',value:'M'}]"),
20
+ });
21
+ export function registerCatalogWriteTools(server, api, handle) {
22
+ server.tool("create_product", `Create a product so the storefront has real merchandise (grid-product / slider-product bindings need this).
23
+ Simple use: pass name + price (+ images, category_ids). One default variation with the price/stock is created for you.
24
+ Advanced use: pass attributes (e.g. Color/Size) + variations for a multi-SKU product.
25
+ Images must be HOSTED URLs — get them from search_images or upload_images first. The backend generates id, slug and publishes the product.`, {
26
+ name: z.string().describe("Product name"),
27
+ price: z.number().optional().describe("Selling price (used when you don't pass variations). Required unless variations are given."),
28
+ original_price: z.number().optional().describe("List/compare-at price for the simple single-variation case"),
29
+ stock: z.number().default(100).describe("Stock quantity for the simple single-variation case"),
30
+ sku: z.string().optional().describe("SKU / custom_id for the simple case (auto-generated if omitted)"),
31
+ images: z.array(z.string()).optional().describe("Hosted image URLs (search_images/upload_images). First image becomes the product thumbnail."),
32
+ description: z.string().optional().describe("Product description (HTML allowed)"),
33
+ category_ids: z.array(z.string()).optional().describe("Product category IDs to file the product under (from create_product_category / list_categories)"),
34
+ attributes: z
35
+ .array(z.object({ name: z.string(), values: z.array(z.string()) }))
36
+ .optional()
37
+ .describe("Variant axes, e.g. [{name:'Color',values:['Đen','Trắng']},{name:'Size',values:['S','M','L']}]"),
38
+ variations: z.array(variationSpec).optional().describe("Explicit per-SKU variations. Omit to auto-build one from price/stock/sku."),
39
+ }, ({ name, price, original_price, stock, sku, images, description, category_ids, attributes, variations }) => handle(async () => {
40
+ let vars = variations;
41
+ if (!vars || !vars.length) {
42
+ if (price == null)
43
+ throw new Error("Provide `price` (or explicit `variations`) to create a product.");
44
+ vars = [
45
+ {
46
+ custom_id: sku || `SKU-${randomUUID().slice(0, 8)}`,
47
+ retail_price: price,
48
+ original_price: original_price ?? price,
49
+ remain_quantity: stock ?? 100,
50
+ images: images || [],
51
+ weight: 0,
52
+ fields: [],
53
+ },
54
+ ];
55
+ }
56
+ else {
57
+ // Normalise: fill SKU / original_price / stock defaults per variation.
58
+ vars = vars.map((v) => ({
59
+ custom_id: v.custom_id || `SKU-${randomUUID().slice(0, 8)}`,
60
+ retail_price: v.retail_price,
61
+ original_price: v.original_price ?? v.retail_price,
62
+ remain_quantity: v.remain_quantity ?? 100,
63
+ images: v.images || [],
64
+ weight: v.weight ?? 0,
65
+ fields: v.fields || [],
66
+ is_hidden: false,
67
+ }));
68
+ }
69
+ const productParams = {
70
+ name,
71
+ variations: vars,
72
+ // These MUST be arrays — the backend does Enum.reduce over them and 500s on nil.
73
+ categories: category_ids || [],
74
+ ribbons: [],
75
+ ...(description ? { description } : {}),
76
+ ...(attributes ? { product_attributes: attributes } : {}),
77
+ ...(images && images.length ? { image: images[0] } : {}),
78
+ };
79
+ const res = await api.createProduct(productParams);
80
+ // Success response is { product: {...} } at the top level (not under data).
81
+ const prod = res?.product || res?.data?.product || res?.data?.attributes || res?.data || {};
82
+ const productId = prod?.id || null;
83
+ return {
84
+ success: true,
85
+ product_id: productId,
86
+ name,
87
+ slug: prod?.slug || null,
88
+ variations: vars.length,
89
+ categories: category_ids || [],
90
+ raw: productId ? undefined : res, // surface raw response only if we couldn't find the id
91
+ };
92
+ }));
93
+ server.tool("create_product_category", `Create a product category (so grid-category / a category page has something to show, and products can be filed under it).
94
+ Returns the new category id — pass it to create_product's category_ids. Image must be a hosted URL.`, {
95
+ name: z.string().describe("Category name"),
96
+ image: z.string().optional().describe("Hosted image URL for the category card"),
97
+ description: z.string().optional().describe("Category description"),
98
+ parent_id: z.string().optional().describe("Parent category id for a sub-category"),
99
+ }, ({ name, image, description, parent_id }) => handle(async () => {
100
+ const id = randomUUID();
101
+ const commands = [
102
+ { name: "create_category", data: { id, name, ...(parent_id ? { parent_id } : {}) } },
103
+ ];
104
+ if (image)
105
+ commands.push({ name: "image_category", data: { id, image } });
106
+ if (description) {
107
+ commands.push({
108
+ name: "multi_description",
109
+ data: { id, multi_description: [{ id: randomUUID(), title: name, description }] },
110
+ });
111
+ }
112
+ await api.createProductCategory(commands);
113
+ return { success: true, category_id: id, name, ...(parent_id ? { parent_id } : {}) };
114
+ }));
115
+ server.tool("create_blog_category", `Create a blog/article category. Returns the new category id — pass it to create_article's category_id so posts are grouped (post-list / blog pages bind to it). Image must be a hosted URL.`, {
116
+ name: z.string().describe("Blog category name"),
117
+ image: z.string().optional().describe("Hosted image URL"),
118
+ description: z.string().optional().describe("Category description"),
119
+ }, ({ name, image, description }) => handle(async () => {
120
+ const id = randomUUID();
121
+ const commands = [{ name: "create_category", data: { id, name } }];
122
+ if (description)
123
+ commands.push({ name: "description_category", data: { id, description } });
124
+ if (image)
125
+ commands.push({ name: "image_category", data: { id, image } });
126
+ await api.createBlogCategory(commands);
127
+ return { success: true, category_id: id, name };
128
+ }));
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-storefront-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "MCP server for the WebCake/StoreCake storefront builder — page CRUD, page authoring, products, orders, and more",
5
5
  "mcpName": "io.github.vuluu2k/webcake-storefront-mcp",
6
6
  "license": "MIT",