webcake-storefront-mcp 1.31.10 → 1.31.12

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
@@ -1,6 +1,6 @@
1
1
  const DEFAULT_TIMEOUT = 15000;
2
- /** Stamped on every page this MCP server creates (backend column `pages.by_ai`),
3
- * so the builder can flag AI-authored pages. Mirrors the landing-page convention. */
2
+ /** Stamped on every site and page this MCP server creates (backend columns `sites.by_ai`
3
+ * and `pages.by_ai`), so the builder can flag AI-authored work. Mirrors the landing-page convention. */
4
4
  export const BY_AI_MARKER = "mcp";
5
5
  export class WebcakeCmsApi {
6
6
  baseUrl;
@@ -78,14 +78,20 @@ export class WebcakeCmsApi {
78
78
  * but NO pages. Returns { data: { site: { id, site_slug:{slug}, ... } } }.
79
79
  * Fails with 403 when the account's site quota is reached (free plan: 4 sites). */
80
80
  createSite(params) {
81
- return this.request("POST", `/api/v1/dashboard/site/create`, { body: params, timeout: 60000 });
81
+ return this.request("POST", `/api/v1/dashboard/site/create`, {
82
+ body: { ...params, by_ai: BY_AI_MARKER },
83
+ timeout: 60000,
84
+ });
82
85
  }
83
86
  /** Create a NEW site from a marketplace TEMPLATE by its theme id — the dedicated
84
87
  * "use this template" API. Clones the template's pages, global sections, cart, popups,
85
88
  * styles and fonts into a fresh account-owned site. Body: { id: <theme_id>, name, slug }.
86
89
  * Returns { data: { site: { id, ... } } }. (403 if the free 4-site quota is reached, prod.) */
87
90
  importStoreToTheme(params) {
88
- return this.request("POST", `/api/v1/dashboard/site/import_store_to_theme`, { body: params, timeout: 120000 });
91
+ return this.request("POST", `/api/v1/dashboard/site/import_store_to_theme`, {
92
+ body: { ...params, by_ai: BY_AI_MARKER },
93
+ timeout: 120000,
94
+ });
89
95
  }
90
96
  getSiteInfo() {
91
97
  return this.request("GET", `/api/v1/site/${this.siteId}/`);
@@ -519,6 +525,13 @@ export class WebcakeCmsApi {
519
525
  timeout: 60000,
520
526
  });
521
527
  }
528
+ /** Product "Cài đặt SEO" (spec set_product_seo). The backend ALWAYS writes custom_slug into
529
+ * slug, so pass the product's current slug or it is wiped. */
530
+ setProductSeo(id, meta_tags, custom_slug) {
531
+ return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/products/update_product`, {
532
+ body: { site_id: this.siteId, id, meta_tags, custom_slug, spec: "set_product_seo" },
533
+ });
534
+ }
522
535
  /** Delete products by id. Body: { site_id, ids }. */
523
536
  removeProducts(ids) {
524
537
  return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/products/remove`, {
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.31.12",
4
+ "d": "09/10/2026",
5
+ "type": "Added",
6
+ "en": "create_article and update_article accept a new seo object (page_title, meta_description, page_keywords, og_title, og_description, og_image) that…",
7
+ "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) để…"
8
+ },
9
+ {
10
+ "v": "1.31.11",
11
+ "d": "11/09/2026",
12
+ "type": "Changed",
13
+ "en": "create_site and create_site_from_template now stamp the new site with by_ai: \"mcp\" (persisted to the backend sites.by_ai column), extending the…",
14
+ "vi": "create_site và create_site_from_template nay đánh dấu site mới với by_ai: \"mcp\" (lưu vào cột sites.by_ai ở backend), mở rộng dấu hiệu AI-tạo vốn chỉ…"
15
+ },
2
16
  {
3
17
  "v": "1.31.10",
4
18
  "d": "08/09/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Fixed",
27
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…",
28
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…"
29
- },
30
- {
31
- "v": "1.31.6",
32
- "d": "26/06/2026",
33
- "type": "Removed",
34
- "en": "scaffold_store_pages, scaffold_global_sections, and scaffold_popup tools are removed; pages are now composed free-form from elements using…",
35
- "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…"
36
- },
37
- {
38
- "v": "1.31.5",
39
- "d": "26/06/2026",
40
- "type": "Added",
41
- "en": "Column definitions accepted by create_collection and update_collection_columns now support five new optional fields: note (help text for the…",
42
- "vi": "Định nghĩa cột được chấp nhận bởi create_collection và update_collection_columns nay hỗ trợ thêm năm trường tùy chọn mới: note (ghi chú/mô tả cho…"
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,72 @@ 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
+ })
83
+ .optional()
84
+ .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
+ /** The SEO save replaces the whole meta_tags array, so merge into the existing tags: passed
86
+ * fields overwrite their tag, "" removes it, structured_data replaces all JSON-LD scripts,
87
+ * everything else (custom tags) is kept. */
88
+ export function mergeSeoTags(seo, existing = []) {
89
+ let tags = [...existing];
90
+ for (const [key, def] of Object.entries(SEO_TAGS)) {
91
+ const value = seo[key];
92
+ if (value === undefined)
93
+ continue;
94
+ tags = tags.filter((t) => !def.match(t));
95
+ if (value !== "")
96
+ tags.push(def.build(value));
97
+ }
98
+ if (seo.structured_data) {
99
+ tags = tags.filter((t) => t?.type !== "script");
100
+ for (const m of seo.structured_data)
101
+ tags.push({ type: "script", props: { type: "application/ld+json" }, meta: { displayName: m.name }, children: m.json });
102
+ }
103
+ return tags;
104
+ }
39
105
  export function registerArticleTools(server, api, handle) {
40
106
  server.tool("list_articles", `List blog articles (metadata only, without HTML content). Use get_article for the full content.
41
107
  Pass term to search by title/slug. Pass category_id to list the posts filed under one blog category —
@@ -72,7 +138,8 @@ must be hosted (search_images / upload_images). The backend generates the id and
72
138
  summary: z.string().optional().describe("Short summary / excerpt"),
73
139
  images: z.array(z.string()).optional().describe("Hosted image URLs; the first is the cover image"),
74
140
  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 () => {
141
+ seo: seoSchema,
142
+ }, ({ name, content, summary, images, category_ids, seo }) => handle(async () => {
76
143
  const id = randomUUID();
77
144
  const commands = [{ name: "create_article", data: { id, name } }];
78
145
  if (summary)
@@ -83,6 +150,8 @@ must be hosted (search_images / upload_images). The backend generates the id and
83
150
  commands.push({ name: "content_article", data: { id, content } });
84
151
  if (category_ids && category_ids.length)
85
152
  commands.push({ name: "bulk_add_category_to_article", data: { id, ids: category_ids } });
153
+ if (seo)
154
+ commands.push({ name: "set_article_seo", data: { id, meta_tags: mergeSeoTags(seo) } });
86
155
  await api.createBlogArticle(commands);
87
156
  return {
88
157
  success: true,
@@ -106,7 +175,8 @@ the SAME call if you want a custom one (it is applied after the rename).`, {
106
175
  tags: z.array(z.string()).optional().describe("Article TAG IDs (uuids from the blog tag list) — not free text"),
107
176
  is_hidden: z.boolean().optional().describe("Hide from public"),
108
177
  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 () => {
178
+ seo: seoSchema.describe("SEO settings to change; other existing SEO tags are kept. Pass \"\" to clear a field."),
179
+ }, ({ id, name, slug, content, summary, images, category_ids, remove_category_ids, tags, is_hidden, published_at, seo }) => handle(async () => {
110
180
  const commands = [];
111
181
  // Order matters: name_article rewrites the slug, so the custom slug must come after it.
112
182
  if (name != null)
@@ -129,6 +199,11 @@ the SAME call if you want a custom one (it is applied after the rename).`, {
129
199
  commands.push({ name: "bulk_add_category_to_article", data: { id, ids: category_ids } });
130
200
  if (remove_category_ids && remove_category_ids.length)
131
201
  commands.push({ name: "bulk_remove_category_to_article", data: { id, ids: remove_category_ids } });
202
+ if (seo) {
203
+ const current = await api.getArticle(id);
204
+ const existing = (current?.data || current)?.meta_tags || [];
205
+ commands.push({ name: "set_article_seo", data: { id, meta_tags: mergeSeoTags(seo, existing) } });
206
+ }
132
207
  if (!commands.length)
133
208
  throw new Error("Nothing to update — pass at least one field besides id.");
134
209
  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,12 @@ 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
+ }
33
40
  export function registerCatalogWriteTools(server, api, handle) {
34
41
  server.tool("create_product", `Create a product so the storefront has real merchandise (grid-product / slider-product bindings need this).
35
42
  Simple use: pass name + price (+ images, category_ids). One default variation with the price/stock is created for you.
@@ -49,7 +56,8 @@ Images must be HOSTED URLs — get them from search_images or upload_images firs
49
56
  .optional()
50
57
  .describe("Variant axes, e.g. [{name:'Color',values:['Đen','Trắng']},{name:'Size',values:['S','M','L']}]"),
51
58
  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 () => {
59
+ seo: seoSchema,
60
+ }, ({ name, price, original_price, stock, sku, images, description, short_description, category_ids, attributes, variations, seo }) => handle(async () => {
53
61
  let vars = variations;
54
62
  // Enrich attribute axes with the id + keyword shape the storefront variation selector
55
63
  // needs (a raw {name,values} declares the axis but attaches it to no variation).
@@ -123,7 +131,11 @@ Images must be HOSTED URLs — get them from search_images or upload_images firs
123
131
  // Success response is { product: {...} } at the top level (not under data).
124
132
  const prod = res?.product || res?.data?.product || res?.data?.attributes || res?.data || {};
125
133
  const productId = prod?.id || null;
134
+ const warning = seo && productId
135
+ ? await applyProductSeo(api, productId, seo).then(() => undefined, (e) => `Product created, but SEO failed: ${e?.message ?? e}`)
136
+ : undefined;
126
137
  return {
138
+ ...(warning ? { warning } : {}),
127
139
  success: true,
128
140
  product_id: productId,
129
141
  name,
@@ -180,7 +192,8 @@ Images must be hosted CDN urls (search_images cdn_url / upload_images).`, {
180
192
  category_ids: z.array(z.string()).optional().describe("Replace the product's categories"),
181
193
  is_published: z.boolean().optional().describe("Publish/unpublish the product"),
182
194
  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 () => {
195
+ seo: seoSchema.describe("SEO settings to change; other existing SEO tags and the slug are kept. Pass \"\" to clear a field."),
196
+ }, ({ product_id, name, description, images, category_ids, is_published, variations, seo }) => handle(async () => {
184
197
  const productParams = { product_id };
185
198
  if (name != null)
186
199
  productParams.name = name;
@@ -206,8 +219,16 @@ Images must be hosted CDN urls (search_images cdn_url / upload_images).`, {
206
219
  is_hidden: false,
207
220
  }));
208
221
  }
209
- await api.updateProduct(productParams);
210
- return { success: true, product_id, updated: Object.keys(productParams).filter((k) => k !== "product_id") };
222
+ const updated = Object.keys(productParams).filter((k) => k !== "product_id");
223
+ if (!updated.length && !seo)
224
+ throw new Error("Nothing to update — pass at least one field besides product_id.");
225
+ if (updated.length)
226
+ await api.updateProduct(productParams);
227
+ if (seo) {
228
+ await applyProductSeo(api, product_id, seo);
229
+ updated.push("seo");
230
+ }
231
+ return { success: true, product_id, updated };
211
232
  }));
212
233
  server.tool("set_product_published", "Publish or unpublish one or more products quickly (without a full update).", {
213
234
  product_ids: z.array(z.string()).describe("Product ids"),
@@ -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.10",
3
+ "version": "1.31.12",
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",