webcake-storefront-mcp 1.31.11 → 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) {
@@ -525,6 +537,13 @@ export class WebcakeCmsApi {
525
537
  timeout: 60000,
526
538
  });
527
539
  }
540
+ /** Product "Cài đặt SEO" (spec set_product_seo). The backend ALWAYS writes custom_slug into
541
+ * slug, so pass the product's current slug or it is wiped. */
542
+ setProductSeo(id, meta_tags, custom_slug) {
543
+ return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/products/update_product`, {
544
+ body: { site_id: this.siteId, id, meta_tags, custom_slug, spec: "set_product_seo" },
545
+ });
546
+ }
528
547
  /** Delete products by id. Body: { site_id, ids }. */
529
548
  removeProducts(ids) {
530
549
  return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/products/remove`, {
@@ -1,4 +1,18 @@
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
+ },
9
+ {
10
+ "v": "1.31.12",
11
+ "d": "09/10/2026",
12
+ "type": "Added",
13
+ "en": "create_article and update_article accept a new seo object (page_title, meta_description, page_keywords, og_title, og_description, og_image) that…",
14
+ "vi": "create_article và update_article nay nhận thêm đối tượng seo (page_title, meta_description, page_keywords, og_title, og_description, og_image) để…"
15
+ },
2
16
  {
3
17
  "v": "1.31.11",
4
18
  "d": "11/09/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Fixed",
27
41
  "en": "Elements placed by new_section, new_row, and build_page that fill their grid cell (text, image, container, columns, repeaters) now receive…",
28
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:…"
29
- },
30
- {
31
- "v": "1.31.7",
32
- "d": "26/06/2026",
33
- "type": "Fixed",
34
- "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…",
35
- "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…"
36
- },
37
- {
38
- "v": "1.31.6",
39
- "d": "26/06/2026",
40
- "type": "Removed",
41
- "en": "scaffold_store_pages, scaffold_global_sections, and scaffold_popup tools are removed; pages are now composed free-form from elements using…",
42
- "vi": "Các tool scaffold_store_pages, scaffold_global_sections và scaffold_popup đã bị xóa; các trang nay được tổ hợp tự do từ các phần tử bằng…"
43
43
  }
44
44
  ]
@@ -1,7 +1,37 @@
1
1
  import { z } from "zod";
2
+ import { randomUUID } from "node:crypto";
2
3
  // Appointment / calendar booking app (Enum.Application appointment = 6).
3
4
  // Install first via install_app({ app: "appointment" }). Endpoints under /appointment/*.
4
5
  // A booking calendar references a classify (service type), an employee (assignee) and an address.
6
+ // Working-hours shapes, as the dashboard's AppointmentConfigWeekdays / ModalConfigDay write them.
7
+ const WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
8
+ const hhmm = z.string().regex(/^\d{2}:\d{2}$/, "HH:mm");
9
+ const timeRanges = z
10
+ .array(z.object({ start_time: hhmm, end_time: hhmm }))
11
+ .max(3)
12
+ .describe('Up to 3 working ranges, e.g. [{ "start_time": "08:00", "end_time": "12:00" }]');
13
+ const weekdaysSchema = z
14
+ .array(z.object({ key: z.enum(WEEKDAYS), is_active: z.boolean().describe("false = day off"), configs: timeRanges }))
15
+ .max(7)
16
+ .describe("Fixed weekly hours (\"Thiết lập thời gian làm việc cố định\"), one entry per weekday; a weekday left out is a day off.");
17
+ const daysSchema = z
18
+ .array(z.object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("YYYY-MM-DD"), configs: timeRanges.describe("Ranges for that date; [] = day off") }))
19
+ .describe("Per-date overrides (\"Điều chỉnh thời gian làm việc cụ thể\") for leave, overtime, holidays — replace the weekly hours on that date.");
20
+ /** Validate + order the weekly hours monday → sunday (the booking check indexes by position), and give per-date overrides the ids the dashboard expects. */
21
+ export function normalizeHours(f) {
22
+ const out = { ...f };
23
+ if (f.config_weekdays) {
24
+ const week = weekdaysSchema.parse(f.config_weekdays);
25
+ out.config_weekdays = WEEKDAYS.map((k) => week.find((d) => d.key === k) ?? { key: k, is_active: false, configs: [] });
26
+ }
27
+ if (f.config_days)
28
+ out.config_days = daysSchema.parse(f.config_days).map((d) => ({
29
+ id: randomUUID(),
30
+ ...d,
31
+ configs: d.configs.map((c) => ({ id: randomUUID(), ...c })),
32
+ }));
33
+ return out;
34
+ }
5
35
  export function registerAppointmentTools(server, api, handle) {
6
36
  const listShape = {
7
37
  page: z.number().optional().describe("Page number (default 1)"),
@@ -11,25 +41,42 @@ export function registerAppointmentTools(server, api, handle) {
11
41
  const unwrap = (res, key) => res?.data?.[key] ?? res?.[key] ?? res?.data ?? res;
12
42
  // ── Calendars ──
13
43
  server.tool("list_appointment_calendars", "List booking calendars (services with availability rules) for the current site.", listShape, (query) => handle(async () => unwrap(await api.listAppointmentCalendars(query), "appointment_calendars")));
14
- server.tool("create_appointment_calendar", `Create a booking calendar. \`config_weekdays\`/\`config_days\` describe availability windows.
15
- \`appointment_classifies\` links service classifies by id ([{ "id": "..." }]). \`assignee_id\` = employee, \`appointment_address_id\` = location.`, {
16
- name: z.string().describe("Calendar / service name"),
17
- range_appointment: z.number().optional().describe("How many days ahead bookings open (default 7)"),
18
- duration_appointment: z.number().optional().describe("Slot length in minutes (default 30)"),
19
- max_appointment_per_day_of_assignee: z.number().optional().describe("Per-employee daily cap"),
20
- max_appointment_per_day_of_customer: z.number().optional().describe("Per-customer daily cap"),
21
- config_weekdays: z.array(z.record(z.any())).optional().describe("Weekly availability windows"),
22
- config_days: z.array(z.record(z.any())).optional().describe("Specific-date availability overrides"),
44
+ server.tool("create_appointment_calendar", `Create a booking calendar = ONE employee's (technician's) working schedule. A calendar is per
45
+ employee, not per service: one employee has at most one calendar per site, tied to one address.
46
+ To set up staff schedules: list/create_appointment_employee → list/create_appointment_address →
47
+ list/create_appointment_classify (services) → one calendar per employee with its own hours.
48
+ Same hours for many staff? Create one, then duplicate_appointment_calendars and change assignee_id.`, {
49
+ name: z.string().describe("Calendar title, e.g. the technician's name"),
50
+ assignee_id: z.string().describe("Employee id (list_appointment_employees) — one calendar per employee"),
51
+ appointment_address_id: z.string().describe("Address/location id (list_appointment_addresses)"),
52
+ appointment_classifies: z.array(z.object({ id: z.string() })).optional().describe('Services this employee takes, e.g. [{ "id": "..." }]'),
53
+ range_appointment: z.number().int().min(1).max(60).optional().describe("How many days ahead customers can book, 1–60 (default 7)"),
54
+ duration_appointment: z.number().int().optional().describe("Appointment length in minutes; also the slot step (default 30)"),
55
+ max_appointment_per_day_of_assignee: z.number().int().optional().describe("Max bookings per day for this employee"),
56
+ max_appointment_per_day_of_customer: z.number().int().optional().describe("Max bookings per day for one customer"),
57
+ config_weekdays: weekdaysSchema.optional().describe(`${weekdaysSchema.description} Omitted = every day 08:00–23:00, the dashboard default.`),
58
+ config_days: daysSchema.optional(),
23
59
  timezone: z.number().optional().describe("Timezone offset (hours, default 0)"),
24
- assignee_id: z.string().optional().describe("Employee (assignee) id"),
25
- appointment_address_id: z.string().optional().describe("Address id (location)"),
26
- appointment_classifies: z.array(z.record(z.any())).optional().describe('Linked classifies, e.g. [{ "id": "..." }]'),
27
60
  google_calendar_id: z.string().optional().describe("Linked Google Calendar id"),
28
- }, (fields) => handle(async () => unwrap(await api.createAppointmentCalendar(fields), "appointment_calendar")));
29
- server.tool("update_appointment_calendar", "Update a booking calendar. Pass `id` plus any fields to change (same shape as create).", {
61
+ }, (fields) => handle(async () => {
62
+ const f = normalizeHours(fields);
63
+ f.config_weekdays ??= WEEKDAYS.map((key) => ({
64
+ key,
65
+ is_active: true,
66
+ configs: [
67
+ { start_time: "08:00", end_time: "11:00" },
68
+ { start_time: "11:00", end_time: "18:00" },
69
+ { start_time: "18:00", end_time: "23:00" },
70
+ ],
71
+ }));
72
+ return unwrap(await api.createAppointmentCalendar(f), "appointment_calendar");
73
+ }));
74
+ server.tool("update_appointment_calendar", `Update a booking calendar (an employee's schedule). Pass \`id\` plus any fields to change, same shape
75
+ as create_appointment_calendar. config_weekdays / config_days REPLACE the whole list — to add one
76
+ day-off, read the calendar (list_appointment_calendars), append, and send the full config_days.`, {
30
77
  id: z.string().describe("Calendar id"),
31
- fields: z.record(z.any()).describe("Fields to update (name, config_weekdays, assignee_id, appointment_classifies, …)"),
32
- }, ({ id, fields }) => handle(async () => unwrap(await api.updateAppointmentCalendar({ id, ...fields }), "appointment_calendar")));
78
+ fields: z.record(z.any()).describe("Fields to update (name, config_weekdays, config_days, assignee_id, appointment_classifies, …)"),
79
+ }, ({ id, fields }) => handle(async () => unwrap(await api.updateAppointmentCalendar({ id, ...normalizeHours(fields) }), "appointment_calendar")));
33
80
  server.tool("delete_appointment_calendars", "Delete booking calendars by id.", { ids: z.array(z.string()).min(1).describe("Calendar ids") }, ({ ids }) => handle(() => api.deleteAppointmentCalendars(ids)));
34
81
  server.tool("duplicate_appointment_calendars", "Duplicate booking calendars by id (creates copies).", { ids: z.array(z.string()).min(1).describe("Calendar ids to duplicate") }, ({ ids }) => handle(() => api.duplicateAppointmentCalendars(ids)));
35
82
  // ── Appointments (bookings) ──
@@ -36,6 +36,84 @@ function summarizeArticle(a) {
36
36
  updated_at: a.updated_at,
37
37
  };
38
38
  }
39
+ /** "Cài đặt SEO" fields → the meta_tags entry each one is stored as (same shapes the dashboard's
40
+ * SEOBasic / SEOSocialShare / SEOAdvanced panels write; shared by articles and products). */
41
+ const meta = (property) => ({
42
+ match: (t) => t?.type !== "link" && t?.props?.property === property,
43
+ build: (content) => ({ type: "meta", props: { property, content } }),
44
+ });
45
+ const SEO_TAGS = {
46
+ page_title: meta("page_title"),
47
+ meta_description: meta("meta_description"),
48
+ page_keywords: meta("page_keywords"),
49
+ og_title: meta("og:title"),
50
+ og_description: meta("og:description"),
51
+ og_image: meta("og:image"),
52
+ og_site_name: meta("og:site_name"),
53
+ og_type: meta("og:type"),
54
+ og_url: meta("og:url"),
55
+ canonical: {
56
+ match: (t) => t?.props?.rel === "canonical",
57
+ build: (href) => ({ type: "link", props: { rel: "canonical", href } }),
58
+ },
59
+ robots: {
60
+ match: (t) => t?.props?.name === "robots",
61
+ build: (content) => ({ type: "meta", props: { name: "robots", content } }),
62
+ },
63
+ };
64
+ export const seoSchema = z
65
+ .object({
66
+ page_title: z.string().optional().describe("Title tag (~60 chars)"),
67
+ meta_description: z.string().optional().describe("Meta description (~155 chars)"),
68
+ page_keywords: z.string().optional().describe("Comma-separated keywords"),
69
+ og_title: z.string().optional().describe("Social share title (og:title)"),
70
+ og_description: z.string().optional().describe("Social share description (og:description)"),
71
+ og_image: z.string().optional().describe("Social share image URL (og:image), must be hosted"),
72
+ // ── Advanced ("SEO nâng cao") ──
73
+ canonical: z.string().optional().describe("Canonical URL (<link rel=canonical>)"),
74
+ og_site_name: z.string().optional().describe("og:site_name"),
75
+ og_type: z.string().optional().describe("og:type, e.g. article / product"),
76
+ og_url: z.string().optional().describe("og:url"),
77
+ robots: z.string().optional().describe('Robots meta, comma+space separated, e.g. "noindex, nofollow" or "max-snippet:-1, max-image-preview:large"'),
78
+ structured_data: z
79
+ .array(z.object({ name: z.string().describe("Display name"), json: z.string().describe("JSON-LD object as a string, without <script>") }))
80
+ .optional()
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"]'),
87
+ })
88
+ .optional()
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.");
90
+ /** The SEO save replaces the whole meta_tags array, so merge into the existing tags: passed
91
+ * fields overwrite their tag, "" removes it, structured_data replaces all JSON-LD scripts,
92
+ * everything else (custom tags) is kept. */
93
+ const tagKey = (t) => t?.props?.name || t?.props?.property || t?.props?.rel || t?.props?.["http-equiv"];
94
+ export function mergeSeoTags(seo, existing = []) {
95
+ let tags = [...existing];
96
+ if (seo.remove_tags)
97
+ tags = tags.filter((t) => !seo.remove_tags.includes(tagKey(t)));
98
+ for (const [key, def] of Object.entries(SEO_TAGS)) {
99
+ const value = seo[key];
100
+ if (value === undefined)
101
+ continue;
102
+ tags = tags.filter((t) => !def.match(t));
103
+ if (value !== "")
104
+ tags.push(def.build(value));
105
+ }
106
+ if (seo.structured_data) {
107
+ tags = tags.filter((t) => t?.type !== "script");
108
+ for (const m of seo.structured_data)
109
+ tags.push({ type: "script", props: { type: "application/ld+json" }, meta: { displayName: m.name }, children: m.json });
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
+ }
115
+ return tags;
116
+ }
39
117
  export function registerArticleTools(server, api, handle) {
40
118
  server.tool("list_articles", `List blog articles (metadata only, without HTML content). Use get_article for the full content.
41
119
  Pass term to search by title/slug. Pass category_id to list the posts filed under one blog category —
@@ -72,7 +150,8 @@ must be hosted (search_images / upload_images). The backend generates the id and
72
150
  summary: z.string().optional().describe("Short summary / excerpt"),
73
151
  images: z.array(z.string()).optional().describe("Hosted image URLs; the first is the cover image"),
74
152
  category_ids: z.array(z.string()).optional().describe("Blog category IDs to file the post under (from create_blog_category)"),
75
- }, ({ name, content, summary, images, category_ids }) => handle(async () => {
153
+ seo: seoSchema,
154
+ }, ({ name, content, summary, images, category_ids, seo }) => handle(async () => {
76
155
  const id = randomUUID();
77
156
  const commands = [{ name: "create_article", data: { id, name } }];
78
157
  if (summary)
@@ -83,6 +162,8 @@ must be hosted (search_images / upload_images). The backend generates the id and
83
162
  commands.push({ name: "content_article", data: { id, content } });
84
163
  if (category_ids && category_ids.length)
85
164
  commands.push({ name: "bulk_add_category_to_article", data: { id, ids: category_ids } });
165
+ if (seo)
166
+ commands.push({ name: "set_article_seo", data: { id, meta_tags: mergeSeoTags(seo) } });
86
167
  await api.createBlogArticle(commands);
87
168
  return {
88
169
  success: true,
@@ -106,7 +187,8 @@ the SAME call if you want a custom one (it is applied after the rename).`, {
106
187
  tags: z.array(z.string()).optional().describe("Article TAG IDs (uuids from the blog tag list) — not free text"),
107
188
  is_hidden: z.boolean().optional().describe("Hide from public"),
108
189
  published_at: z.string().optional().describe("Publish date, ISO/naive datetime (render_inserted_at)"),
109
- }, ({ id, name, slug, content, summary, images, category_ids, remove_category_ids, tags, is_hidden, published_at }) => handle(async () => {
190
+ seo: seoSchema.describe("SEO settings to change; other existing SEO tags are kept. Pass \"\" to clear a field."),
191
+ }, ({ id, name, slug, content, summary, images, category_ids, remove_category_ids, tags, is_hidden, published_at, seo }) => handle(async () => {
110
192
  const commands = [];
111
193
  // Order matters: name_article rewrites the slug, so the custom slug must come after it.
112
194
  if (name != null)
@@ -129,6 +211,11 @@ the SAME call if you want a custom one (it is applied after the rename).`, {
129
211
  commands.push({ name: "bulk_add_category_to_article", data: { id, ids: category_ids } });
130
212
  if (remove_category_ids && remove_category_ids.length)
131
213
  commands.push({ name: "bulk_remove_category_to_article", data: { id, ids: remove_category_ids } });
214
+ if (seo) {
215
+ const current = await api.getArticle(id);
216
+ const existing = (current?.data || current)?.meta_tags || [];
217
+ commands.push({ name: "set_article_seo", data: { id, meta_tags: mergeSeoTags(seo, existing) } });
218
+ }
132
219
  if (!commands.length)
133
220
  throw new Error("Nothing to update — pass at least one field besides id.");
134
221
  await api.updateBlogArticle(commands);
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { z } from "zod";
2
3
  import { BUILD_GUIDE } from "../builder/guide.js";
3
4
  import { PAGE_SCHEMA } from "../builder/page-schema.js";
@@ -112,8 +113,44 @@ export function buildPageSeo(seo = {}) {
112
113
  out.og_description = seo.og_description || seo.description;
113
114
  if (seo.thumbnail)
114
115
  out.og_image = seo.thumbnail;
116
+ // Advanced ("SEO nâng cao") — same keys the editor's SeoDrawer writes; passed through as-is.
117
+ for (const k of ["thumbnail_alt", "author", "canonical", "og_site_name", "og_type", "og_url", "robots", "site_markup", "site_name_markup", "hidden_meta_tags"])
118
+ if (seo[k] !== undefined)
119
+ out[k] = seo[k];
120
+ if (seo.markups)
121
+ out.markups = seo.markups.map((m) => ({ id: m.id || randomUUID(), name: m.name, code: m.code }));
115
122
  return out;
116
123
  }
124
+ /** Apply name / homepage / slug / settings / SEO to a page. The backend's update_page is a cond:
125
+ * is_homepage → only that; name → only the name; otherwise slug + settings, where settings is
126
+ * REPLACED (omitted → stored as "null", wiping page_mask/seo). So: one call per branch, and the
127
+ * last call always resends the page's current settings with the changes merged in. */
128
+ export async function applyPageMeta(api, pageId, { name, slug, is_homepage, settings, seo }) {
129
+ if (name != null)
130
+ await api.updatePage(pageId, { name });
131
+ if (is_homepage)
132
+ await api.updatePage(pageId, { is_homepage: true });
133
+ const seoBlock = seo ? buildPageSeo(seo) : {};
134
+ if (!slug && !settings && !Object.keys(seoBlock).length)
135
+ return;
136
+ // ponytail: no get-page-by-id route, so this pulls the full page list (with sources) once.
137
+ const res = await api.listPages();
138
+ const list = res?.data || res?.pages || res;
139
+ let cur = (Array.isArray(list) ? list : []).find((p) => p.id === pageId)?.settings;
140
+ if (typeof cur === "string")
141
+ try {
142
+ cur = JSON.parse(cur);
143
+ }
144
+ catch {
145
+ cur = null;
146
+ }
147
+ if (!cur || typeof cur !== "object")
148
+ cur = {};
149
+ const next = { ...cur, ...(settings || {}) };
150
+ if (Object.keys(seoBlock).length)
151
+ next.seo = { ...(next.seo || {}), ...seoBlock };
152
+ await api.updatePage(pageId, { ...(slug ? { slug } : {}), settings: next });
153
+ }
117
154
  /** Normalize a page slug to what the storefront matches on. The storefront routes by the
118
155
  * RAW path segment (e.g. "/cart" → path ["cart"]) and looks up `page.slug == "cart"`, so a
119
156
  * stored slug WITH a leading "/" (e.g. "/cart") never matches and the page 404s. The homepage
@@ -246,16 +283,7 @@ ONE page per site for type main (homepage) / error / maintain, and slugs are uni
246
283
  return { error: "Page created but no id was returned.", created };
247
284
  }
248
285
  // slug / homepage / SEO are not applied at create — set them via update_page.
249
- const seoBlock = seo ? buildPageSeo(seo) : null;
250
- if (cleanSlug || is_homepage || (seoBlock && Object.keys(seoBlock).length)) {
251
- await api
252
- .updatePage(pageId, {
253
- ...(cleanSlug ? { slug: cleanSlug } : {}),
254
- ...(is_homepage ? { is_homepage: true } : {}),
255
- ...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
256
- })
257
- .catch(() => { });
258
- }
286
+ const metaWarning = await applyPageMeta(api, pageId, { slug: cleanSlug, is_homepage, seo }).then(() => undefined, (e) => `Page saved, but slug/homepage/SEO failed: ${e?.message ?? e}`);
259
287
  return {
260
288
  success: true,
261
289
  page_id: pageId,
@@ -264,6 +292,7 @@ ONE page per site for type main (homepage) / error / maintain, and slugs are uni
264
292
  page_type: kind ?? null,
265
293
  ...(feature ? { data_source: { flag: feature.flag, newly_enabled: feature.changed } } : {}),
266
294
  stats: validation.stats,
295
+ ...(metaWarning ? { warning: metaWarning } : {}),
267
296
  };
268
297
  }));
269
298
  server.tool("add_section", `Append a section to an EXISTING page's source. Reads the current source, appends your section, validates, and (when dry_run=false) saves.
@@ -6,6 +6,7 @@
6
6
  // (the response is a generic {code:success}), so we mint a UUID and return it.
7
7
  import { z } from "zod";
8
8
  import { randomUUID } from "node:crypto";
9
+ import { seoSchema, mergeSeoTags } from "./articles.js";
9
10
  const variationSpec = z.object({
10
11
  retail_price: z.number().describe("Selling price (what the customer pays)"),
11
12
  original_price: z.number().optional().describe("List/compare-at price (defaults to retail_price)"),
@@ -30,6 +31,27 @@ function attrKeyValue(v) {
30
31
  function cartesian(lists) {
31
32
  return lists.reduce((acc, cur) => acc.flatMap((a) => cur.map((b) => [...a, b])), [[]]);
32
33
  }
34
+ /** Merge SEO fields into the product's meta_tags, resending its current slug (see setProductSeo). */
35
+ async function applyProductSeo(api, id, seo) {
36
+ const res = await api.getProduct(id);
37
+ const p = res?.data || res?.product || res;
38
+ await api.setProductSeo(id, mergeSeoTags(seo, p?.meta_tags || []), p?.slug ?? null);
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
+ }
33
55
  export function registerCatalogWriteTools(server, api, handle) {
34
56
  server.tool("create_product", `Create a product so the storefront has real merchandise (grid-product / slider-product bindings need this).
35
57
  Simple use: pass name + price (+ images, category_ids). One default variation with the price/stock is created for you.
@@ -49,7 +71,8 @@ Images must be HOSTED URLs — get them from search_images or upload_images firs
49
71
  .optional()
50
72
  .describe("Variant axes, e.g. [{name:'Color',values:['Đen','Trắng']},{name:'Size',values:['S','M','L']}]"),
51
73
  variations: z.array(variationSpec).optional().describe("Explicit per-SKU variations. Omit to auto-build one from price/stock/sku."),
52
- }, ({ name, price, original_price, stock, sku, images, description, short_description, category_ids, attributes, variations }) => handle(async () => {
74
+ seo: seoSchema,
75
+ }, ({ name, price, original_price, stock, sku, images, description, short_description, category_ids, attributes, variations, seo }) => handle(async () => {
53
76
  let vars = variations;
54
77
  // Enrich attribute axes with the id + keyword shape the storefront variation selector
55
78
  // needs (a raw {name,values} declares the axis but attaches it to no variation).
@@ -123,7 +146,11 @@ Images must be HOSTED URLs — get them from search_images or upload_images firs
123
146
  // Success response is { product: {...} } at the top level (not under data).
124
147
  const prod = res?.product || res?.data?.product || res?.data?.attributes || res?.data || {};
125
148
  const productId = prod?.id || null;
149
+ const warning = seo && productId
150
+ ? await applyProductSeo(api, productId, seo).then(() => undefined, (e) => `Product created, but SEO failed: ${e?.message ?? e}`)
151
+ : undefined;
126
152
  return {
153
+ ...(warning ? { warning } : {}),
127
154
  success: true,
128
155
  product_id: productId,
129
156
  name,
@@ -139,7 +166,8 @@ Returns the new category id — pass it to create_product's category_ids. Image
139
166
  image: z.string().optional().describe("Hosted image URL for the category card"),
140
167
  description: z.string().optional().describe("Category description"),
141
168
  parent_id: z.string().optional().describe("Parent category id for a sub-category"),
142
- }, ({ name, image, description, parent_id }) => handle(async () => {
169
+ seo: seoSchema,
170
+ }, ({ name, image, description, parent_id, seo }) => handle(async () => {
143
171
  const id = randomUUID();
144
172
  const commands = [
145
173
  { name: "create_category", data: { id, name, ...(parent_id ? { parent_id } : {}) } },
@@ -152,6 +180,8 @@ Returns the new category id — pass it to create_product's category_ids. Image
152
180
  data: { id, multi_description: [{ id: randomUUID(), title: name, description }] },
153
181
  });
154
182
  }
183
+ if (seo)
184
+ commands.push({ name: "set_category_seo", data: { id, meta_tags: mergeSeoTags(seo) } });
155
185
  await api.createProductCategory(commands);
156
186
  return { success: true, category_id: id, name, ...(parent_id ? { parent_id } : {}) };
157
187
  }));
@@ -159,16 +189,44 @@ Returns the new category id — pass it to create_product's category_ids. Image
159
189
  name: z.string().describe("Blog category name"),
160
190
  image: z.string().optional().describe("Hosted image URL"),
161
191
  description: z.string().optional().describe("Category description"),
162
- }, ({ name, image, description }) => handle(async () => {
192
+ seo: seoSchema,
193
+ }, ({ name, image, description, seo }) => handle(async () => {
163
194
  const id = randomUUID();
164
195
  const commands = [{ name: "create_category", data: { id, name } }];
165
196
  if (description)
166
197
  commands.push({ name: "description_category", data: { id, description } });
167
198
  if (image)
168
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) } });
169
202
  await api.createBlogCategory(commands);
170
203
  return { success: true, category_id: id, name };
171
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
+ }));
172
230
  // ── Edits: products & product categories ─────────────────────────────────────
173
231
  server.tool("update_product", `Update an existing product. Pass product_id + only the fields to change. To change
174
232
  price/stock, pass variations (get_product first to see the existing variation shape).
@@ -180,7 +238,8 @@ Images must be hosted CDN urls (search_images cdn_url / upload_images).`, {
180
238
  category_ids: z.array(z.string()).optional().describe("Replace the product's categories"),
181
239
  is_published: z.boolean().optional().describe("Publish/unpublish the product"),
182
240
  variations: z.array(variationSpec).optional().describe("Replace variations (price/stock/SKU per variant). Reuse existing custom_ids from get_product to edit in place."),
183
- }, ({ product_id, name, description, images, category_ids, is_published, variations }) => handle(async () => {
241
+ seo: seoSchema.describe("SEO settings to change; other existing SEO tags and the slug are kept. Pass \"\" to clear a field."),
242
+ }, ({ product_id, name, description, images, category_ids, is_published, variations, seo }) => handle(async () => {
184
243
  const productParams = { product_id };
185
244
  if (name != null)
186
245
  productParams.name = name;
@@ -206,8 +265,16 @@ Images must be hosted CDN urls (search_images cdn_url / upload_images).`, {
206
265
  is_hidden: false,
207
266
  }));
208
267
  }
209
- await api.updateProduct(productParams);
210
- return { success: true, product_id, updated: Object.keys(productParams).filter((k) => k !== "product_id") };
268
+ const updated = Object.keys(productParams).filter((k) => k !== "product_id");
269
+ if (!updated.length && !seo)
270
+ throw new Error("Nothing to update — pass at least one field besides product_id.");
271
+ if (updated.length)
272
+ await api.updateProduct(productParams);
273
+ if (seo) {
274
+ await applyProductSeo(api, product_id, seo);
275
+ updated.push("seo");
276
+ }
277
+ return { success: true, product_id, updated };
211
278
  }));
212
279
  server.tool("set_product_published", "Publish or unpublish one or more products quickly (without a full update).", {
213
280
  product_ids: z.array(z.string()).describe("Product ids"),
@@ -222,13 +289,14 @@ Images must be hosted CDN urls (search_images cdn_url / upload_images).`, {
222
289
  await api.removeProducts(product_ids);
223
290
  return { success: true, deleted: product_ids };
224
291
  }));
225
- 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.", {
226
293
  id: z.string().describe("Category id"),
227
294
  name: z.string().optional().describe("New name"),
228
295
  image: z.string().optional().describe("New hosted image URL"),
229
296
  description: z.string().optional().describe("New description"),
230
297
  hidden: z.boolean().optional().describe("Hide (true) or show (false) the category"),
231
- }, ({ 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 () => {
232
300
  const commands = [];
233
301
  if (name != null)
234
302
  commands.push({ name: "name_category", data: { id, name } });
@@ -238,8 +306,10 @@ Images must be hosted CDN urls (search_images cdn_url / upload_images).`, {
238
306
  commands.push({ name: "multi_description", data: { id, multi_description: [{ id: randomUUID(), title: name || "", description }] } });
239
307
  if (hidden != null)
240
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)) } });
241
311
  if (!commands.length)
242
- 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.");
243
313
  await api.updateProductCategory(commands);
244
314
  return { success: true, category_id: id, updated: commands.map((c) => c.name) };
245
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'"),
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { PAGE_TYPE_NUM, PAGE_TYPE_FLAG, PAGE_KINDS, buildPageSeo, normalizeSlug, checkPageCreateConflict } from "./builder.js";
2
+ import { PAGE_TYPE_NUM, PAGE_TYPE_FLAG, PAGE_KINDS, applyPageMeta, normalizeSlug, checkPageCreateConflict } from "./builder.js";
3
3
  import { validatePage, finalizeForRender, reassignIds } from "../builder/page.js";
4
4
  import { createDraft, getDraft, setDraft, appendDraftSection, listDrafts, delDraft, } from "../persistence/draft-cache.js";
5
5
  // Friendly result when a draft is gone (disposable cache: expired ~2h or restart).
@@ -156,19 +156,10 @@ RESUMABLE: if a request fails mid-commit, the draft keeps its page_id + committe
156
156
  // All sections committed → apply slug / homepage / SEO, then drop the draft.
157
157
  // Strip a leading "/" so the storefront's bare-segment match resolves (else 404).
158
158
  const cleanSlug = normalizeSlug(draft.meta.slug);
159
- const seoBlock = draft.meta.seo ? buildPageSeo(draft.meta.seo) : null;
160
- if (cleanSlug || draft.meta.is_homepage || (seoBlock && Object.keys(seoBlock).length)) {
161
- await api
162
- .updatePage(draft.page_id, {
163
- ...(cleanSlug ? { slug: cleanSlug } : {}),
164
- ...(draft.meta.is_homepage ? { is_homepage: true } : {}),
165
- ...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
166
- })
167
- .catch(() => { });
168
- }
159
+ const metaWarning = await applyPageMeta(api, draft.page_id, { slug: cleanSlug, is_homepage: draft.meta.is_homepage, seo: draft.meta.seo }).then(() => undefined, (e) => `Page saved, but slug/homepage/SEO failed: ${e?.message ?? e}`);
169
160
  const pageId = draft.page_id;
170
161
  await delDraft(draft_id);
171
- return { success: true, page_id: pageId, total_sections: total, stats: validation.stats };
162
+ return { success: true, page_id: pageId, total_sections: total, stats: validation.stats, ...(metaWarning ? { warning: metaWarning } : {}) };
172
163
  }
173
164
  catch (e) {
174
165
  const committed = draft.committed_count ?? 0;
@@ -3,7 +3,30 @@ import { CUSTOM_CODE_GUIDE } from "../guides.js";
3
3
  import { getConfirmMode } from "./context.js";
4
4
  import { normalizeEvents } from "../builder/events.js";
5
5
  import { normalizeBindings } from "../builder/bindings.js";
6
- import { PAGE_TYPE_NUM, PAGE_KINDS, buildPageSeo, normalizeSlug, checkPageCreateConflict } from "./builder.js";
6
+ import { PAGE_TYPE_NUM, PAGE_KINDS, applyPageMeta, normalizeSlug, checkPageCreateConflict } from "./builder.js";
7
+ const pageSeoSchema = z.object({
8
+ title: z.string().optional(),
9
+ description: z.string().optional(),
10
+ keyword: z.string().optional(),
11
+ favicon: z.string().optional(),
12
+ thumbnail: z.string().optional().describe("Share image; also og:image"),
13
+ og_title: z.string().optional().describe("Defaults to title"),
14
+ og_description: z.string().optional().describe("Defaults to description"),
15
+ // ── Advanced ("SEO nâng cao") ──
16
+ thumbnail_alt: z.string().optional(),
17
+ author: z.string().optional(),
18
+ canonical: z.string().optional().describe("Canonical URL"),
19
+ og_site_name: z.string().optional(),
20
+ og_type: z.string().optional(),
21
+ og_url: z.string().optional(),
22
+ robots: z.string().optional().describe('Robots meta, e.g. "noindex, nofollow" or "max-snippet:-1, max-image-preview:large"'),
23
+ markups: z
24
+ .array(z.object({ name: z.string(), code: z.string().describe("JSON-LD as a string, without <script>") }))
25
+ .optional()
26
+ .describe("Structured data markups (JSON-LD). REPLACES the page's list; [] removes them."),
27
+ site_name_markup: z.boolean().optional().describe("Emit the site-name markup (default on)"),
28
+ hidden_meta_tags: z.array(z.string()).optional().describe("Meta tag keys to hide, e.g. ['og:url']"),
29
+ });
7
30
  /**
8
31
  * Page source utilities.
9
32
  *
@@ -299,8 +322,7 @@ Examples:
299
322
  slug: z.string().describe("URL slug WITHOUT a leading slash, e.g. 'about', 'collections', 'cart'. A leading '/' is stripped automatically (the storefront matches the bare path segment, so '/cart' would 404). Homepage needs no slug (pass is_homepage:true)."),
300
323
  type: z.enum(PAGE_KINDS).optional().describe("Page kind (main/store/member/blog/custom/error/maintain). store/member/blog need their data-source flag enabled — prefer build_page which auto-enables it. main/error/maintain are one-per-site; use 'custom' for extra pages."),
301
324
  is_homepage: z.boolean().default(false).describe("Set as homepage"),
302
- seo: z
303
- .object({ title: z.string().optional(), description: z.string().optional(), keyword: z.string().optional(), favicon: z.string().optional(), thumbnail: z.string().optional() })
325
+ seo: pageSeoSchema
304
326
  .optional()
305
327
  .describe("SEO → settings.seo (title/description/keyword/favicon/thumbnail). Tokens {{name_page}}/{{name_site}} allowed."),
306
328
  }, ({ name, slug, type, is_homepage, seo }) => handle(async () => {
@@ -314,37 +336,26 @@ Examples:
314
336
  const created = await api.createPage({ name, ...(typeNum != null ? { type: typeNum } : {}) });
315
337
  invalidatePageCache();
316
338
  const pageId = (created && (created.id || created.data?.id || created.page?.id)) || null;
317
- const seoBlock = seo ? buildPageSeo(seo) : null;
318
- if (pageId && (cleanSlug || is_homepage || (seoBlock && Object.keys(seoBlock).length))) {
319
- await api
320
- .updatePage(pageId, {
321
- ...(cleanSlug ? { slug: cleanSlug } : {}),
322
- ...(is_homepage ? { is_homepage: true } : {}),
323
- ...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
324
- })
325
- .catch(() => { });
326
- invalidatePageCache();
327
- }
328
- return { success: true, page_id: pageId, name, slug: cleanSlug ?? null, type: type ?? null, raw: pageId ? undefined : created };
339
+ const warning = pageId
340
+ ? await applyPageMeta(api, pageId, { slug: cleanSlug, is_homepage, seo }).then(() => undefined, (e) => `Page created, but slug/homepage/SEO failed: ${e?.message ?? e}`)
341
+ : undefined;
342
+ invalidatePageCache();
343
+ return { success: true, page_id: pageId, name, slug: cleanSlug ?? null, type: type ?? null, raw: pageId ? undefined : created, ...(warning ? { warning } : {}) };
329
344
  }));
330
- server.tool("update_page", "Update page properties (name, slug, settings, custom code)", {
345
+ server.tool("update_page", `Update page properties: name, slug, homepage, SEO ("Cài đặt SEO") and settings.
346
+ seo / settings are MERGED into the page's current settings — fields you don't pass are kept.`, {
331
347
  page_id: z.string().describe("Page ID"),
332
348
  name: z.string().optional().describe("New name"),
333
349
  slug: z.string().optional().describe("New slug WITHOUT a leading slash (e.g. 'about', 'cart'). A leading '/' is stripped automatically — '/cart' would 404 on the storefront."),
334
350
  is_homepage: z.boolean().optional().describe("Set as homepage"),
335
- settings: z.record(z.any()).optional().describe("Page settings"),
336
- }, ({ page_id, ...params }) => handle(async () => {
351
+ seo: pageSeoSchema.optional().describe("SEO → settings.seo (title/description/keyword/favicon/thumbnail/og_*). Merged; other SEO fields kept. Tokens {{name_page}}/{{name_site}} allowed."),
352
+ settings: z.record(z.any()).optional().describe("Other page settings keys to merge (top-level keys replace)"),
353
+ }, ({ page_id, name, slug, is_homepage, seo, settings }) => handle(async () => {
337
354
  // Normalize slug (strip leading "/") so the storefront's bare-segment match resolves.
338
- if (params.slug !== undefined) {
339
- const clean = normalizeSlug(params.slug);
340
- if (clean)
341
- params.slug = clean;
342
- else
343
- delete params.slug;
344
- }
345
- const res = await api.updatePage(page_id, params);
355
+ const cleanSlug = slug !== undefined ? normalizeSlug(slug) : undefined;
356
+ await applyPageMeta(api, page_id, { name, slug: cleanSlug, is_homepage, seo, settings });
346
357
  invalidatePageCache();
347
- return res;
358
+ return { success: true, page_id };
348
359
  }));
349
360
  server.tool("get_site_custom_code", `Get custom code of the site (CSS/JS). Two modes:
350
361
  - Default: returns ALL 4 code fields (full content)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-storefront-mcp",
3
- "version": "1.31.11",
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",