webcake-storefront-mcp 1.31.12 → 1.31.13

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
@@ -485,6 +485,18 @@ export class WebcakeCmsApi {
485
485
  timeout: 60000,
486
486
  });
487
487
  }
488
+ /** All blog categories (dashboard; includes meta_tags). */
489
+ listBlogCategories() {
490
+ return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/blog/categories/all`);
491
+ }
492
+ /** Update blog categories — same command pipeline as create (name_category / description_category /
493
+ * image_category / set_category_custom_slug / set_category_seo …), each with data.id. */
494
+ updateBlogCategory(commands) {
495
+ return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/blog/categories/update`, {
496
+ body: { site_id: this.siteId, commands },
497
+ timeout: 60000,
498
+ });
499
+ }
488
500
  /** Create a blog article via the dashboard command pipeline (supports category linkage,
489
501
  * images, summary, content). Caller generates the article id in each command's data.id. */
490
502
  createBlogArticle(commands) {
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.31.13",
4
+ "d": "09/10/2026",
5
+ "type": "Added",
6
+ "en": "create_product, update_product, create_product_category, update_product_category, and create_blog_category accept a new seo object to write…",
7
+ "vi": "create_product, update_product, create_product_category, update_product_category và create_blog_category nay nhận thêm đối tượng seo để ghi…"
8
+ },
2
9
  {
3
10
  "v": "1.31.12",
4
11
  "d": "09/10/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Fixed",
34
41
  "en": "Elements placed by new_section, new_row, and build_page that fill their grid cell (text, image, container, columns, repeaters) now receive…",
35
42
  "vi": "Các phần tử được đặt bởi new_section, new_row và build_page mà lấp đầy ô lưới (text, image, container, columns, repeaters) nay nhận constraintX:…"
36
- },
37
- {
38
- "v": "1.31.7",
39
- "d": "26/06/2026",
40
- "type": "Fixed",
41
- "en": "build_page, create_page, update_page, and commit_page_draft now strip a leading / from slug before saving; a slug like /cart would previously 404 on…",
42
- "vi": "build_page, create_page, update_page và commit_page_draft nay tự động loại bỏ dấu / đầu dòng khỏi slug trước khi lưu; các slug kiểu /cart trước đây…"
43
43
  }
44
44
  ]
@@ -79,14 +79,22 @@ export const seoSchema = z
79
79
  .array(z.object({ name: z.string().describe("Display name"), json: z.string().describe("JSON-LD object as a string, without <script>") }))
80
80
  .optional()
81
81
  .describe("Structured data markup (JSON-LD). REPLACES all existing markups; [] removes them."),
82
+ custom_tags: z
83
+ .array(z.object({ type: z.enum(["meta", "link"]).default("meta"), props: z.record(z.string()) }))
84
+ .optional()
85
+ .describe('Extra tags ("Thẻ bổ sung"), e.g. { "type": "meta", "props": { "name": "author", "content": "Togi" } }. Replaces a tag with the same name/property/rel.'),
86
+ remove_tags: z.array(z.string()).optional().describe('Remove tags by name/property/rel, e.g. ["author", "og:url"]'),
82
87
  })
83
88
  .optional()
84
89
  .describe("SEO settings (\"Cài đặt SEO\": basic, social share, advanced). Without a meta description the site falls back to the whole body text.");
85
90
  /** The SEO save replaces the whole meta_tags array, so merge into the existing tags: passed
86
91
  * fields overwrite their tag, "" removes it, structured_data replaces all JSON-LD scripts,
87
92
  * everything else (custom tags) is kept. */
93
+ const tagKey = (t) => t?.props?.name || t?.props?.property || t?.props?.rel || t?.props?.["http-equiv"];
88
94
  export function mergeSeoTags(seo, existing = []) {
89
95
  let tags = [...existing];
96
+ if (seo.remove_tags)
97
+ tags = tags.filter((t) => !seo.remove_tags.includes(tagKey(t)));
90
98
  for (const [key, def] of Object.entries(SEO_TAGS)) {
91
99
  const value = seo[key];
92
100
  if (value === undefined)
@@ -100,6 +108,10 @@ export function mergeSeoTags(seo, existing = []) {
100
108
  for (const m of seo.structured_data)
101
109
  tags.push({ type: "script", props: { type: "application/ld+json" }, meta: { displayName: m.name }, children: m.json });
102
110
  }
111
+ for (const t of seo.custom_tags || []) {
112
+ tags = tags.filter((x) => tagKey(x) !== tagKey(t));
113
+ tags.push({ type: t.type || "meta", props: t.props });
114
+ }
103
115
  return tags;
104
116
  }
105
117
  export function registerArticleTools(server, api, handle) {
@@ -37,6 +37,21 @@ async function applyProductSeo(api, id, seo) {
37
37
  const p = res?.data || res?.product || res;
38
38
  await api.setProductSeo(id, mergeSeoTags(seo, p?.meta_tags || []), p?.slug ?? null);
39
39
  }
40
+ /** Find a category's current meta_tags in a list response (flat or tree-shaped). */
41
+ function findMetaTags(res, id) {
42
+ const stack = [res];
43
+ while (stack.length) {
44
+ const x = stack.pop();
45
+ if (Array.isArray(x))
46
+ stack.push(...x);
47
+ else if (x && typeof x === "object") {
48
+ if (x.id === id && "meta_tags" in x)
49
+ return x.meta_tags || [];
50
+ stack.push(...Object.values(x).filter((v) => v && typeof v === "object"));
51
+ }
52
+ }
53
+ return [];
54
+ }
40
55
  export function registerCatalogWriteTools(server, api, handle) {
41
56
  server.tool("create_product", `Create a product so the storefront has real merchandise (grid-product / slider-product bindings need this).
42
57
  Simple use: pass name + price (+ images, category_ids). One default variation with the price/stock is created for you.
@@ -151,7 +166,8 @@ Returns the new category id — pass it to create_product's category_ids. Image
151
166
  image: z.string().optional().describe("Hosted image URL for the category card"),
152
167
  description: z.string().optional().describe("Category description"),
153
168
  parent_id: z.string().optional().describe("Parent category id for a sub-category"),
154
- }, ({ name, image, description, parent_id }) => handle(async () => {
169
+ seo: seoSchema,
170
+ }, ({ name, image, description, parent_id, seo }) => handle(async () => {
155
171
  const id = randomUUID();
156
172
  const commands = [
157
173
  { name: "create_category", data: { id, name, ...(parent_id ? { parent_id } : {}) } },
@@ -164,6 +180,8 @@ Returns the new category id — pass it to create_product's category_ids. Image
164
180
  data: { id, multi_description: [{ id: randomUUID(), title: name, description }] },
165
181
  });
166
182
  }
183
+ if (seo)
184
+ commands.push({ name: "set_category_seo", data: { id, meta_tags: mergeSeoTags(seo) } });
167
185
  await api.createProductCategory(commands);
168
186
  return { success: true, category_id: id, name, ...(parent_id ? { parent_id } : {}) };
169
187
  }));
@@ -171,16 +189,44 @@ Returns the new category id — pass it to create_product's category_ids. Image
171
189
  name: z.string().describe("Blog category name"),
172
190
  image: z.string().optional().describe("Hosted image URL"),
173
191
  description: z.string().optional().describe("Category description"),
174
- }, ({ name, image, description }) => handle(async () => {
192
+ seo: seoSchema,
193
+ }, ({ name, image, description, seo }) => handle(async () => {
175
194
  const id = randomUUID();
176
195
  const commands = [{ name: "create_category", data: { id, name } }];
177
196
  if (description)
178
197
  commands.push({ name: "description_category", data: { id, description } });
179
198
  if (image)
180
199
  commands.push({ name: "image_category", data: { id, image } });
200
+ if (seo)
201
+ commands.push({ name: "set_category_seo", data: { id, meta_tags: mergeSeoTags(seo) } });
181
202
  await api.createBlogCategory(commands);
182
203
  return { success: true, category_id: id, name };
183
204
  }));
205
+ server.tool("update_blog_category", "Update a blog/article category (name, description, image, slug, SEO). Pass id + fields to change.", {
206
+ id: z.string().describe("Blog category id"),
207
+ name: z.string().optional().describe("New name"),
208
+ description: z.string().optional().describe("New description"),
209
+ image: z.string().optional().describe("New hosted image URL"),
210
+ slug: z.string().optional().describe("New custom slug"),
211
+ seo: seoSchema.describe("SEO settings to change; other existing SEO tags are kept. Pass \"\" to clear a field."),
212
+ }, ({ id, name, description, image, slug, seo }) => handle(async () => {
213
+ const commands = [];
214
+ // name_category may rewrite the slug, so the custom slug goes after it.
215
+ if (name != null)
216
+ commands.push({ name: "name_category", data: { id, name } });
217
+ if (slug != null)
218
+ commands.push({ name: "set_category_custom_slug", data: { id, custom_slug: slug } });
219
+ if (description != null)
220
+ commands.push({ name: "description_category", data: { id, description } });
221
+ if (image != null)
222
+ commands.push({ name: "image_category", data: { id, image } });
223
+ if (seo)
224
+ commands.push({ name: "set_category_seo", data: { id, meta_tags: mergeSeoTags(seo, findMetaTags(await api.listBlogCategories(), id)) } });
225
+ if (!commands.length)
226
+ throw new Error("Nothing to update — pass at least one field besides id.");
227
+ await api.updateBlogCategory(commands);
228
+ return { success: true, category_id: id, updated: commands.map((c) => c.name) };
229
+ }));
184
230
  // ── Edits: products & product categories ─────────────────────────────────────
185
231
  server.tool("update_product", `Update an existing product. Pass product_id + only the fields to change. To change
186
232
  price/stock, pass variations (get_product first to see the existing variation shape).
@@ -243,13 +289,14 @@ Images must be hosted CDN urls (search_images cdn_url / upload_images).`, {
243
289
  await api.removeProducts(product_ids);
244
290
  return { success: true, deleted: product_ids };
245
291
  }));
246
- server.tool("update_product_category", "Update a product category (name, image, description, or visibility). Pass id + fields to change.", {
292
+ server.tool("update_product_category", "Update a product category (name, image, description, visibility, SEO). Pass id + fields to change.", {
247
293
  id: z.string().describe("Category id"),
248
294
  name: z.string().optional().describe("New name"),
249
295
  image: z.string().optional().describe("New hosted image URL"),
250
296
  description: z.string().optional().describe("New description"),
251
297
  hidden: z.boolean().optional().describe("Hide (true) or show (false) the category"),
252
- }, ({ id, name, image, description, hidden }) => handle(async () => {
298
+ seo: seoSchema.describe("SEO settings to change; other existing SEO tags are kept. Pass \"\" to clear a field."),
299
+ }, ({ id, name, image, description, hidden, seo }) => handle(async () => {
253
300
  const commands = [];
254
301
  if (name != null)
255
302
  commands.push({ name: "name_category", data: { id, name } });
@@ -259,8 +306,10 @@ Images must be hosted CDN urls (search_images cdn_url / upload_images).`, {
259
306
  commands.push({ name: "multi_description", data: { id, multi_description: [{ id: randomUUID(), title: name || "", description }] } });
260
307
  if (hidden != null)
261
308
  commands.push({ name: "set_category_visible", data: { id, is_hidden: hidden } });
309
+ if (seo)
310
+ commands.push({ name: "set_category_seo", data: { id, meta_tags: mergeSeoTags(seo, findMetaTags(await api.listCategories(), id)) } });
262
311
  if (!commands.length)
263
- throw new Error("Nothing to update — pass at least one of name/image/description/hidden.");
312
+ throw new Error("Nothing to update — pass at least one of name/image/description/hidden/seo.");
264
313
  await api.updateProductCategory(commands);
265
314
  return { success: true, category_id: id, updated: commands.map((c) => c.name) };
266
315
  }));
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { seoSchema, mergeSeoTags } from "./articles.js";
2
3
  // Multilingual app (Enum.Application multilingual = 5). Install first via
3
4
  // install_app({ app: "multilingual" }), then enable languages. Each resource type
4
5
  // (product, category, article, …) has its own translations list + save endpoint.
@@ -60,6 +61,40 @@ For most resources \`items\` is an array of translation rows. For 'site' pass th
60
61
  body = { [def.field || `${resource}_translations`]: items, language_code };
61
62
  return api.mlSaveTranslations(def.action, body);
62
63
  }));
64
+ server.tool("set_translation_seo", `Set the SEO ("Cài đặt SEO") of ONE item's translation in a target language — product, article,
65
+ product category or blog category. Same fields as the item's own seo param; merged into that
66
+ translation's existing SEO tags (fields you don't pass are kept). Only meta_tags is written, so the
67
+ translated name/slug/content are untouched; the translation row is created if missing.`, {
68
+ resource: z.enum(["product", "article", "category", "article_category"]).describe("category = product category, article_category = blog category"),
69
+ id: z.string().describe("Item id (product / article / category id)"),
70
+ language_code: z.string().describe("Target language code, e.g. 'en'"),
71
+ seo: seoSchema.unwrap(),
72
+ }, ({ resource, id, language_code, seo }) => handle(async () => {
73
+ const path = `${resource}_translations`;
74
+ // The list API only filters by name (term), so scan pages to find the row by id.
75
+ // ponytail: up to 50 pages × 100 items; add an id filter on the backend if sites outgrow it.
76
+ let row;
77
+ for (let page = 1; page <= 50 && !row; page++) {
78
+ const box = unwrap(await api.mlGetTranslations(path, { language_code, page, limit: 100 }), path);
79
+ const rows = box?.data || [];
80
+ row = rows.find((r) => r.id === id);
81
+ if (rows.length < 100)
82
+ break;
83
+ }
84
+ const tr = row?.[`${resource}_translation`];
85
+ const idKey = resource === "article_category" ? "category_id" : `${resource}_id`;
86
+ const meta_tags = mergeSeoTags(seo, tr?.meta_tags || []);
87
+ const item = { [idKey]: id, site_id: api.siteId, meta_tags };
88
+ // Article / category / blog-category saves read `name` unconditionally (and re-derive the
89
+ // translated slug from it), so resend the current translated name, else the original one.
90
+ if (resource !== "product") {
91
+ item.name = tr?.name || row?.name;
92
+ if (!item.name)
93
+ throw new Error(`${resource} ${id} not found in the ${language_code} translation list.`);
94
+ }
95
+ await api.mlSaveTranslations(SAVE[resource].action, { [path]: [item], language_code });
96
+ return { success: true, resource, id, language_code, meta_tags };
97
+ }));
63
98
  server.tool("auto_translate", `Machine-translate a JSON blob from one language to another. \`type\` must be a valid translation
64
99
  type (e.g. 'product', 'category'). Returns { trans, count }.`, {
65
100
  type: z.string().describe("Translation type, e.g. 'product'"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-storefront-mcp",
3
- "version": "1.31.12",
3
+ "version": "1.31.13",
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",