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 +17 -4
- package/dist/changelog.json +14 -14
- package/dist/tools/appointment.js +63 -16
- package/dist/tools/articles.js +77 -2
- package/dist/tools/builder.js +39 -10
- package/dist/tools/catalog-write.js +25 -4
- package/dist/tools/page-draft.js +3 -12
- package/dist/tools/pages.js +38 -27
- package/package.json +1 -1
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
|
|
3
|
-
* so the builder can flag AI-authored
|
|
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`, {
|
|
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`, {
|
|
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`, {
|
package/dist/changelog.json
CHANGED
|
@@ -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.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 () =>
|
|
29
|
-
|
|
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) ──
|
package/dist/tools/articles.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/tools/builder.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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"),
|
package/dist/tools/page-draft.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { PAGE_TYPE_NUM, PAGE_TYPE_FLAG, PAGE_KINDS,
|
|
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
|
|
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;
|
package/dist/tools/pages.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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",
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
|
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.
|
|
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",
|