webcake-storefront-mcp 1.31.8 → 1.31.10
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 +43 -9
- package/dist/builder/guide.js +8 -0
- package/dist/changelog.json +14 -14
- package/dist/tools/articles.js +107 -34
- package/dist/tools/builder.js +67 -4
- package/dist/tools/context.js +5 -8
- package/dist/tools/page-draft.js +17 -3
- package/dist/tools/pages.js +8 -3
- package/package.json +2 -2
package/dist/api.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
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. */
|
|
4
|
+
export const BY_AI_MARKER = "mcp";
|
|
2
5
|
export class WebcakeCmsApi {
|
|
3
6
|
baseUrl;
|
|
4
7
|
token;
|
|
@@ -149,13 +152,17 @@ export class WebcakeCmsApi {
|
|
|
149
152
|
}
|
|
150
153
|
/** Create a page. The backend creates the page AND its source in one call, so `source`
|
|
151
154
|
* is REQUIRED and must be a JSON string (stringified here if an object is passed).
|
|
152
|
-
* `slug`/`is_homepage` are NOT applied at create — set them afterwards via updatePage.
|
|
155
|
+
* `slug`/`is_homepage` are NOT applied at create — set them afterwards via updatePage.
|
|
156
|
+
* Every page born here is AI-authored, so we stamp `by_ai` (persisted to `pages.by_ai`)
|
|
157
|
+
* and the builder shows an "AI" tag next to the page name. */
|
|
153
158
|
createPage(params, opts) {
|
|
154
159
|
const body = { ...params };
|
|
155
160
|
if (body.source != null && typeof body.source !== "string")
|
|
156
161
|
body.source = JSON.stringify(body.source);
|
|
157
162
|
if (body.source == null)
|
|
158
163
|
body.source = JSON.stringify({ sections: [] });
|
|
164
|
+
if (body.by_ai == null)
|
|
165
|
+
body.by_ai = BY_AI_MARKER;
|
|
159
166
|
return this.request("POST", `/api/v1/site/${this.siteId}/page`, { body, timeout: opts?.timeout });
|
|
160
167
|
}
|
|
161
168
|
updatePage(pageId, params) {
|
|
@@ -422,20 +429,47 @@ export class WebcakeCmsApi {
|
|
|
422
429
|
return this.request("DELETE", `/api/v1/dashboard/site/${this.siteId}/db_collections/${id}`, { headers });
|
|
423
430
|
}
|
|
424
431
|
// ── Blog Articles ──
|
|
432
|
+
// NOTE: the `/api/v1/cms_function/:site_id/blog/article/*` routes are NOT usable from here.
|
|
433
|
+
// They sit behind builderx_api's CmsFunctionAuth plug, which requires the per-site CMS *admin*
|
|
434
|
+
// token (api_cms_keys, plug_type "admin_cms") as the Bearer — a storefront/session JWT always
|
|
435
|
+
// gets 401 "Token not found". Everything below therefore uses the same dashboard blog API the
|
|
436
|
+
// builder UI calls with the session JWT, except article detail, which has no dashboard route
|
|
437
|
+
// and uses the public read route.
|
|
438
|
+
/** List articles (dashboard). Query: page, limit, term (searches name + slug).
|
|
439
|
+
* Response: { success, articles: { data: [...], total_entries, page, limit, term } }. */
|
|
425
440
|
listArticles(query) {
|
|
426
|
-
return this.request("GET", `/api/v1/
|
|
441
|
+
return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/blog/articles/all`, { query });
|
|
427
442
|
}
|
|
428
|
-
|
|
429
|
-
|
|
443
|
+
/** Articles filed under ONE blog category. Query: page, limit, article_id?, category_ids?.
|
|
444
|
+
* Response: { data: { articles: [...] } }. */
|
|
445
|
+
listArticlesByCategory(categoryId, query) {
|
|
446
|
+
return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/blog/articles/${categoryId}`, { query });
|
|
430
447
|
}
|
|
431
|
-
|
|
432
|
-
|
|
448
|
+
/** Article detail (full content). Public read route — the dashboard API has no GET-by-id.
|
|
449
|
+
* Response: { success, data: {...article} }; 422 "Article not found!" when missing/removed. */
|
|
450
|
+
getArticle(id) {
|
|
451
|
+
return this.request("GET", `/view/${this.siteId}/article/${id}`);
|
|
452
|
+
}
|
|
453
|
+
/** Update an article through the dashboard command pipeline — same command shape as create
|
|
454
|
+
* (name_article / summary_article / content_article / image_article / set_article_custom_slug /
|
|
455
|
+
* set_article_visible / set_article_tags / bulk_add_category_to_article …). Each command's
|
|
456
|
+
* `data.id` is the article id. Commands run in order, so slug commands must follow name ones. */
|
|
457
|
+
updateBlogArticle(commands) {
|
|
458
|
+
return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/blog/articles/update`, {
|
|
459
|
+
body: { site_id: this.siteId, commands },
|
|
460
|
+
timeout: 60000,
|
|
461
|
+
});
|
|
433
462
|
}
|
|
434
|
-
|
|
435
|
-
|
|
463
|
+
/** Soft-delete articles (sets is_removed) via the dashboard command pipeline. */
|
|
464
|
+
deleteArticles(ids) {
|
|
465
|
+
return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/blog/articles/delete`, {
|
|
466
|
+
body: { site_id: this.siteId, commands: [{ name: "bulk_delete_article", data: { ids } }] },
|
|
467
|
+
timeout: 60000,
|
|
468
|
+
});
|
|
436
469
|
}
|
|
470
|
+
/** Delete a single article. */
|
|
437
471
|
deleteArticle(id) {
|
|
438
|
-
return this.
|
|
472
|
+
return this.deleteArticles([id]);
|
|
439
473
|
}
|
|
440
474
|
/** Create a blog/article category. Command-based: pass a `commands` array whose entries
|
|
441
475
|
* each carry a caller-generated `data.id` (the new category id). Response is generic. */
|
package/dist/builder/guide.js
CHANGED
|
@@ -301,6 +301,14 @@ Rule of thumb: if the page shows products, a cart, customer/order data, or blog
|
|
|
301
301
|
set \`type\` accordingly so the binding source is turned on. A binding target like
|
|
302
302
|
\`product::product_price\` REQUIRES its page to be the matching type.
|
|
303
303
|
|
|
304
|
+
### ONE page per singleton type — only \`custom\` is unlimited
|
|
305
|
+
A site has exactly ONE homepage (\`main\`), ONE \`error\` page and ONE \`maintain\` page; a second
|
|
306
|
+
one only shadows the first, so \`create_page\` / \`build_page\` / \`start_page_draft\` REFUSE it and
|
|
307
|
+
hand you the existing \`page_id\` — edit that page (replace_page_source / add_section /
|
|
308
|
+
update_page) instead of creating another. \`store\`/\`member\`/\`blog\` may have several pages
|
|
309
|
+
(cart + checkout + collections…, login + register…) but each SLUG is unique per site, so those
|
|
310
|
+
are refused on a duplicate slug. \`custom\` pages are unlimited as long as their slugs differ.
|
|
311
|
+
|
|
304
312
|
## Build the WHOLE storefront — every page to the SAME standard (NOT just the home page)
|
|
305
313
|
A shop is multi-page. Build EACH page to a real e-commerce standard with the same palette,
|
|
306
314
|
spacing and header/footer — never leave the home page rich and the rest as bare stubs.
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.31.10",
|
|
4
|
+
"d": "08/09/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "list_articles accepts a term parameter to search articles by title or slug, and its response now includes page and limit alongside total.",
|
|
7
|
+
"vi": "list_articles nay nhận thêm tham số term để tìm bài viết theo tiêu đề hoặc slug, và kết quả trả về nay có thêm page và limit bên cạnh total."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.31.9",
|
|
11
|
+
"d": "07/09/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "Pages created by create_page, build_page, and commit_page_draft are now stamped with by_ai: \"mcp\" (persisted to the backend pages.by_ai column) so…",
|
|
14
|
+
"vi": "Các trang được tạo bởi create_page, build_page và commit_page_draft nay được đánh dấu by_ai: \"mcp\" (lưu vào cột pages.by_ai ở backend) để builder có…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.31.8",
|
|
4
18
|
"d": "29/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Added",
|
|
27
41
|
"en": "Column definitions accepted by create_collection and update_collection_columns now support five new optional fields: note (help text for the…",
|
|
28
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…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.31.4",
|
|
32
|
-
"d": "26/06/2026",
|
|
33
|
-
"type": "Added",
|
|
34
|
-
"en": "The guide returned by get_http_function and get_site_custom_code now includes a verified end-to-end \"Custom data TABLES (collections)\" section…",
|
|
35
|
-
"vi": "Hướng dẫn trả về bởi get_http_function và get_site_custom_code nay bổ sung phần \"Custom data TABLES (collections)\" đã được xác minh thực tế, ghi lại…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.31.3",
|
|
39
|
-
"d": "26/06/2026",
|
|
40
|
-
"type": "Added",
|
|
41
|
-
"en": "New update_collection_columns tool reads the current collection schema and PATCHes it with the system columns plus the provided custom columns,…",
|
|
42
|
-
"vi": "Tool mới update_collection_columns đọc schema hiện tại của collection rồi PATCH lại với các cột hệ thống cộng các cột tùy chỉnh được cung cấp, cho…"
|
|
43
43
|
}
|
|
44
44
|
]
|
package/dist/tools/articles.js
CHANGED
|
@@ -1,33 +1,67 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
/** The dashboard blog API answers with two different envelopes:
|
|
4
|
+
* /blog/articles/all → { articles: { data: [...], total_entries, page, limit } }
|
|
5
|
+
* /blog/articles/{category} → { data: { articles: [...] } }
|
|
6
|
+
* Flatten both to a plain array. */
|
|
7
|
+
function pickArticles(res) {
|
|
8
|
+
const candidates = [
|
|
9
|
+
res?.articles?.data,
|
|
10
|
+
res?.articles,
|
|
11
|
+
res?.data?.articles?.data,
|
|
12
|
+
res?.data?.articles,
|
|
13
|
+
res?.data?.data,
|
|
14
|
+
res?.data,
|
|
15
|
+
res,
|
|
16
|
+
];
|
|
17
|
+
for (const c of candidates)
|
|
18
|
+
if (Array.isArray(c))
|
|
19
|
+
return c;
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
function summarizeArticle(a) {
|
|
23
|
+
return {
|
|
24
|
+
id: a.id || a._id,
|
|
25
|
+
name: a.name,
|
|
26
|
+
slug: a.slug,
|
|
27
|
+
summary: a.summary || undefined,
|
|
28
|
+
cover: (Array.isArray(a.images) && a.images[0]) || undefined,
|
|
29
|
+
category_ids: Array.isArray(a.article_categories)
|
|
30
|
+
? a.article_categories.map((ac) => ac.category_id).filter(Boolean)
|
|
31
|
+
: undefined,
|
|
32
|
+
tags: a.tags && a.tags.length ? a.tags : undefined,
|
|
33
|
+
is_hidden: a.is_hidden,
|
|
34
|
+
published_at: a.render_inserted_at || undefined,
|
|
35
|
+
inserted_at: a.inserted_at,
|
|
36
|
+
updated_at: a.updated_at,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
3
39
|
export function registerArticleTools(server, api, handle) {
|
|
4
|
-
server.tool("list_articles",
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
40
|
+
server.tool("list_articles", `List blog articles (metadata only, without HTML content). Use get_article for the full content.
|
|
41
|
+
Pass term to search by title/slug. Pass category_id to list the posts filed under one blog category —
|
|
42
|
+
that view is the public one, so hidden posts and posts scheduled in the future are left out.`, {
|
|
43
|
+
page: z.number().optional().describe("Page number (default 1)"),
|
|
44
|
+
limit: z.number().optional().describe("Items per page (default 20)"),
|
|
45
|
+
category_id: z.string().optional().describe("Filter by blog category id"),
|
|
46
|
+
term: z.string().optional().describe("Search articles by title or slug"),
|
|
47
|
+
}, ({ page, limit, category_id, term }) => handle(async () => {
|
|
48
|
+
const res = category_id
|
|
49
|
+
? await api.listArticlesByCategory(category_id, { page, limit })
|
|
50
|
+
: await api.listArticles({ page, limit, term });
|
|
51
|
+
const articles = pickArticles(res);
|
|
13
52
|
return {
|
|
14
|
-
data: articles.map(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
summary: a.summary || undefined,
|
|
19
|
-
category_id: a.category_id || undefined,
|
|
20
|
-
tags: a.tags || undefined,
|
|
21
|
-
is_hidden: a.is_hidden,
|
|
22
|
-
created_at: a.created_at,
|
|
23
|
-
updated_at: a.updated_at,
|
|
24
|
-
})),
|
|
25
|
-
total: res.total || articles.length,
|
|
53
|
+
data: articles.map(summarizeArticle),
|
|
54
|
+
total: res?.articles?.total_entries ?? articles.length,
|
|
55
|
+
page: res?.articles?.page ?? page ?? 1,
|
|
56
|
+
limit: res?.articles?.limit ?? limit ?? articles.length,
|
|
26
57
|
};
|
|
27
58
|
}));
|
|
28
|
-
server.tool("get_article", "Get article details by ID", {
|
|
59
|
+
server.tool("get_article", "Get article details by ID (includes the full HTML content)", {
|
|
29
60
|
id: z.string().describe("Article ID"),
|
|
30
|
-
}, ({ id }) => handle(() =>
|
|
61
|
+
}, ({ id }) => handle(async () => {
|
|
62
|
+
const res = await api.getArticle(id);
|
|
63
|
+
return (res && res.data) || res;
|
|
64
|
+
}));
|
|
31
65
|
server.tool("create_article", `Create a blog article so blog/post pages (post-list, grid-blog, post-overlay) have content.
|
|
32
66
|
Built via the dashboard command pipeline: title + optional summary, HTML content, image URLs, and
|
|
33
67
|
category linkage. Pass category_ids from create_blog_category / list articles' categories so the
|
|
@@ -58,17 +92,56 @@ must be hosted (search_images / upload_images). The backend generates the id and
|
|
|
58
92
|
cover: images?.[0] || null,
|
|
59
93
|
};
|
|
60
94
|
}));
|
|
61
|
-
server.tool("update_article",
|
|
95
|
+
server.tool("update_article", `Update a blog article. Only the fields you pass are changed — each one becomes a command in the
|
|
96
|
+
same dashboard pipeline create_article uses. NOTE: renaming regenerates the slug, so pass slug in
|
|
97
|
+
the SAME call if you want a custom one (it is applied after the rename).`, {
|
|
62
98
|
id: z.string().describe("Article ID"),
|
|
63
|
-
name: z.string().optional().describe("New title"),
|
|
64
|
-
slug: z.string().optional().describe("New slug"),
|
|
65
|
-
content: z.string().optional().describe("New HTML content"),
|
|
66
|
-
summary: z.string().optional().describe("New summary"),
|
|
67
|
-
|
|
68
|
-
|
|
99
|
+
name: z.string().optional().describe("New title (regenerates the slug unless slug is also passed)"),
|
|
100
|
+
slug: z.string().optional().describe("New custom slug"),
|
|
101
|
+
content: z.string().optional().describe("New HTML content (replaces the old content)"),
|
|
102
|
+
summary: z.string().optional().describe("New summary / excerpt"),
|
|
103
|
+
images: z.array(z.string()).optional().describe("Hosted image URLs; the first is the cover image"),
|
|
104
|
+
category_ids: z.array(z.string()).optional().describe("Blog category IDs to file the post under (added, existing ones are kept)"),
|
|
105
|
+
remove_category_ids: z.array(z.string()).optional().describe("Blog category IDs to unfile the post from"),
|
|
106
|
+
tags: z.array(z.string()).optional().describe("Article TAG IDs (uuids from the blog tag list) — not free text"),
|
|
69
107
|
is_hidden: z.boolean().optional().describe("Hide from public"),
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
108
|
+
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 () => {
|
|
110
|
+
const commands = [];
|
|
111
|
+
// Order matters: name_article rewrites the slug, so the custom slug must come after it.
|
|
112
|
+
if (name != null)
|
|
113
|
+
commands.push({ name: "name_article", data: { id, name } });
|
|
114
|
+
if (slug != null)
|
|
115
|
+
commands.push({ name: "set_article_custom_slug", data: { id, custom_slug: slug } });
|
|
116
|
+
if (summary != null)
|
|
117
|
+
commands.push({ name: "summary_article", data: { id, summary } });
|
|
118
|
+
if (content != null)
|
|
119
|
+
commands.push({ name: "content_article", data: { id, content } });
|
|
120
|
+
if (images)
|
|
121
|
+
commands.push({ name: "image_article", data: { id, images } });
|
|
122
|
+
if (tags)
|
|
123
|
+
commands.push({ name: "set_article_tags", data: { id, article_tags: tags } });
|
|
124
|
+
if (is_hidden != null)
|
|
125
|
+
commands.push({ name: "set_article_visible", data: { id, is_hidden } });
|
|
126
|
+
if (published_at != null)
|
|
127
|
+
commands.push({ name: "set_article_render_inserted_at", data: { id, render_inserted_at: published_at } });
|
|
128
|
+
if (category_ids && category_ids.length)
|
|
129
|
+
commands.push({ name: "bulk_add_category_to_article", data: { id, ids: category_ids } });
|
|
130
|
+
if (remove_category_ids && remove_category_ids.length)
|
|
131
|
+
commands.push({ name: "bulk_remove_category_to_article", data: { id, ids: remove_category_ids } });
|
|
132
|
+
if (!commands.length)
|
|
133
|
+
throw new Error("Nothing to update — pass at least one field besides id.");
|
|
134
|
+
await api.updateBlogArticle(commands);
|
|
135
|
+
return { success: true, article_id: id, updated: commands.map((c) => c.name) };
|
|
136
|
+
}));
|
|
137
|
+
server.tool("delete_article", "Delete blog articles (soft delete — they disappear from the site)", {
|
|
138
|
+
id: z.string().optional().describe("Article ID"),
|
|
139
|
+
ids: z.array(z.string()).optional().describe("Several article IDs to delete in one call"),
|
|
140
|
+
}, ({ id, ids }) => handle(async () => {
|
|
141
|
+
const list = [...(ids || []), ...(id ? [id] : [])].filter(Boolean);
|
|
142
|
+
if (!list.length)
|
|
143
|
+
throw new Error("Pass id or ids.");
|
|
144
|
+
await api.deleteArticles(list);
|
|
145
|
+
return { success: true, deleted: list };
|
|
146
|
+
}));
|
|
74
147
|
}
|
package/dist/tools/builder.js
CHANGED
|
@@ -38,6 +38,59 @@ export const PAGE_TYPE_FLAG = {
|
|
|
38
38
|
error: "use_error", maintain: "use_maintain",
|
|
39
39
|
};
|
|
40
40
|
export const PAGE_KINDS = ["main", "store", "member", "blog", "custom", "error", "maintain"];
|
|
41
|
+
/** Page kinds a site only ever has ONE of. The homepage is `main`, and the storefront
|
|
42
|
+
* resolves exactly one `error` / `maintain` page — a second one just shadows the first,
|
|
43
|
+
* so creating it is always a mistake. `store` / `member` / `blog` are NOT here: a site
|
|
44
|
+
* legitimately has several of each (cart + checkout + collections…, login + register…),
|
|
45
|
+
* they are kept unique by SLUG instead. `custom` is unlimited. */
|
|
46
|
+
export const SINGLETON_PAGE_KINDS = ["main", "error", "maintain"];
|
|
47
|
+
function briefPage(p) {
|
|
48
|
+
return { id: p.id, name: p.name, slug: p.slug ?? null, type: p.type ?? null, is_homepage: !!p.is_homepage };
|
|
49
|
+
}
|
|
50
|
+
/** Guard run before CREATING a page, so the agent edits the existing page instead of
|
|
51
|
+
* piling up duplicates the storefront will never route to. Two rules:
|
|
52
|
+
* 1. singleton kinds (main/error/maintain, incl. `is_homepage`) — one per site;
|
|
53
|
+
* 2. every other kind — the slug must be free (the backend has a (site_id, slug)
|
|
54
|
+
* unique index, so a duplicate slug fails there anyway, just with a vaguer error).
|
|
55
|
+
* `custom` pages stay unlimited as long as their slugs differ.
|
|
56
|
+
* Returns null when the page may be created. A failed lookup never blocks the create. */
|
|
57
|
+
export async function checkPageCreateConflict(api, { kind, slug, is_homepage }) {
|
|
58
|
+
let pages;
|
|
59
|
+
try {
|
|
60
|
+
const res = await api.listPages();
|
|
61
|
+
pages = (res && res.data) || res || [];
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(pages))
|
|
67
|
+
return null;
|
|
68
|
+
const singleton = is_homepage ? "main" : kind;
|
|
69
|
+
if (singleton && SINGLETON_PAGE_KINDS.includes(singleton)) {
|
|
70
|
+
const typeNum = PAGE_TYPE_NUM[singleton];
|
|
71
|
+
const found = pages.find((p) => (singleton === "main" ? !!p.is_homepage || p.type === typeNum : p.type === typeNum));
|
|
72
|
+
if (found) {
|
|
73
|
+
return {
|
|
74
|
+
error: `This site already has a '${singleton}' page ("${found.name}"), and only one is allowed — ` +
|
|
75
|
+
`only 'custom' pages can be created repeatedly. Edit page ${found.id} instead ` +
|
|
76
|
+
`(replace_page_source / add_section / update_page), or create a 'custom' page.`,
|
|
77
|
+
existing_page: briefPage(found),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const cleanSlug = normalizeSlug(slug ?? undefined);
|
|
82
|
+
if (cleanSlug) {
|
|
83
|
+
const found = pages.find((p) => p.slug === cleanSlug);
|
|
84
|
+
if (found) {
|
|
85
|
+
return {
|
|
86
|
+
error: `This site already has a page at slug "${cleanSlug}" ("${found.name}"). Slugs are unique per site — ` +
|
|
87
|
+
`edit page ${found.id} instead (replace_page_source / add_section / update_page), or pick another slug.`,
|
|
88
|
+
existing_page: briefPage(found),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
41
94
|
/** Build the page.settings.seo block from simple inputs (the real shape; tokens like
|
|
42
95
|
* {{name_page}} / {{name_site}} are resolved by the storefront). */
|
|
43
96
|
export function buildPageSeo(seo = {}) {
|
|
@@ -118,7 +171,8 @@ Example children: [{ "type":"container", "children":[{"type":"image","opts":{...
|
|
|
118
171
|
}));
|
|
119
172
|
server.tool("build_page", `Create a brand-new page AND set its full content source in one step.
|
|
120
173
|
Two-step safety: call with dry_run=true (default) to validate and preview, then dry_run=false to actually create + save.
|
|
121
|
-
The source must be { sections: [...] } — build sections with new_section. Validation errors block the real save
|
|
174
|
+
The source must be { sections: [...] } — build sections with new_section. Validation errors block the real save.
|
|
175
|
+
ONE page per site for type main (homepage) / error / maintain, and slugs are unique per site: a duplicate is refused (dry_run reports blocked:true) and you get the existing page_id to edit instead. Only 'custom' pages can be created over and over.`, {
|
|
122
176
|
name: z.string().describe("Page name"),
|
|
123
177
|
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). Store pages MUST use the conventional slugs: category='collections', product detail='products', cart='cart', checkout='checkout', thank-you='complete'. The homepage needs no slug (pass is_homepage:true)."),
|
|
124
178
|
source: z.any().describe("Full page source { sections: [...] } (object or JSON string)"),
|
|
@@ -148,18 +202,27 @@ The source must be { sections: [...] } — build sections with new_section. Vali
|
|
|
148
202
|
// Strip a leading "/" — the storefront matches `page.slug == "<segment>"` (no slash),
|
|
149
203
|
// so "/cart" would 404. Homepage (blank/"/") → undefined (matched by is_nil(slug)).
|
|
150
204
|
const cleanSlug = normalizeSlug(slug);
|
|
205
|
+
// Only 'custom' pages may be created over and over; singleton kinds and taken
|
|
206
|
+
// slugs must be edited in place instead of duplicated.
|
|
207
|
+
const conflict = await checkPageCreateConflict(api, { kind, slug: cleanSlug, is_homepage });
|
|
151
208
|
if (dry_run) {
|
|
152
209
|
return {
|
|
153
210
|
dry_run: true,
|
|
211
|
+
...(conflict ? { blocked: true, conflict } : {}),
|
|
154
212
|
validation,
|
|
155
213
|
request: { name, slug: cleanSlug ?? null, type: kind ?? null, page_type_num: typeNum ?? null, is_homepage, sections: (parsed && parsed.sections || []).length },
|
|
156
214
|
will_enable_feature: requiredFlag ?? null,
|
|
157
215
|
renders_at_breakpoints: ["bp1", "bp2", "bp3", "bp4"],
|
|
158
|
-
hint:
|
|
159
|
-
?
|
|
160
|
-
:
|
|
216
|
+
hint: conflict
|
|
217
|
+
? conflict.error
|
|
218
|
+
: validation.valid
|
|
219
|
+
? `Looks valid. On save, every node's runtime is expanded into the bp1..bp4 keys the storefront renders. Call again with dry_run=false to create and save the page.${requiredFlag ? ` Will also enable site.settings.${requiredFlag} so its data bindings resolve.` : ""}`
|
|
220
|
+
: "Fix the errors above before saving.",
|
|
161
221
|
};
|
|
162
222
|
}
|
|
223
|
+
if (conflict) {
|
|
224
|
+
return { error: conflict.error, existing_page: conflict.existing_page };
|
|
225
|
+
}
|
|
163
226
|
if (!validation.valid) {
|
|
164
227
|
return { error: "Validation failed — not saving.", validation };
|
|
165
228
|
}
|
package/dist/tools/context.js
CHANGED
|
@@ -52,17 +52,14 @@ async function clearSeedData(api) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
catch { /* best-effort */ }
|
|
55
|
-
// Blog articles — list then delete
|
|
55
|
+
// Blog articles — list then bulk-delete (best-effort; blog cleanup must never fail site creation).
|
|
56
56
|
try {
|
|
57
57
|
const res = await api.listArticles({ page: 1, limit: 200 });
|
|
58
|
-
const articles = (res && res.data
|
|
58
|
+
const articles = (res && (res.articles?.data || res.articles || res.data)) || [];
|
|
59
59
|
const ids = (Array.isArray(articles) ? articles : []).map((a) => a.id).filter(Boolean);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
result.articles++;
|
|
64
|
-
}
|
|
65
|
-
catch { /* skip the ones that fail */ }
|
|
60
|
+
if (ids.length) {
|
|
61
|
+
await api.deleteArticles(ids);
|
|
62
|
+
result.articles = ids.length;
|
|
66
63
|
}
|
|
67
64
|
}
|
|
68
65
|
catch { /* best-effort */ }
|
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, buildPageSeo, normalizeSlug } from "./builder.js";
|
|
2
|
+
import { PAGE_TYPE_NUM, PAGE_TYPE_FLAG, PAGE_KINDS, buildPageSeo, 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).
|
|
@@ -25,7 +25,8 @@ function newPageId(res) {
|
|
|
25
25
|
* commit, so re-running commit_page_draft continues from where it stopped.
|
|
26
26
|
*/
|
|
27
27
|
export function registerPageDraftTools(server, api, handle) {
|
|
28
|
-
server.tool("start_page_draft", `Start a page draft (no network). Build a multi-section page safely: cache each section with add_draft_section, then commit_page_draft persists it to the backend INCREMENTALLY (resumable on timeout). Use this instead of build_page for large/multi-section pages. The draft cache is DISPOSABLE (Redis on the remote server when REDIS_URL is set, in-memory otherwise; sliding ~2h TTL) — if a draft is ever lost, just re-send the sections, never a failure
|
|
28
|
+
server.tool("start_page_draft", `Start a page draft (no network). Build a multi-section page safely: cache each section with add_draft_section, then commit_page_draft persists it to the backend INCREMENTALLY (resumable on timeout). Use this instead of build_page for large/multi-section pages. The draft cache is DISPOSABLE (Redis on the remote server when REDIS_URL is set, in-memory otherwise; sliding ~2h TTL) — if a draft is ever lost, just re-send the sections, never a failure.
|
|
29
|
+
ONE page per site for type main (homepage) / error / maintain, and slugs are unique per site: the draft is refused up-front with the existing page_id to edit instead. Only 'custom' pages can be created over and over.`, {
|
|
29
30
|
name: z.string().describe("Page name"),
|
|
30
31
|
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). Store pages MUST use: category='collections', product='products', cart='cart', checkout='checkout', thank-you='complete'. Homepage needs no slug (is_homepage:true)."),
|
|
31
32
|
type: z
|
|
@@ -44,6 +45,11 @@ export function registerPageDraftTools(server, api, handle) {
|
|
|
44
45
|
.optional()
|
|
45
46
|
.describe("SEO for this page → settings.seo (applied on commit)."),
|
|
46
47
|
}, ({ name, slug, type, is_homepage, seo }) => handle(async () => {
|
|
48
|
+
// Fail before the agent builds any sections: only 'custom' pages may be created
|
|
49
|
+
// repeatedly — singleton kinds and taken slugs must be edited in place.
|
|
50
|
+
const conflict = await checkPageCreateConflict(api, { kind: type, slug, is_homepage });
|
|
51
|
+
if (conflict)
|
|
52
|
+
return { error: conflict.error, existing_page: conflict.existing_page };
|
|
47
53
|
const draft = await createDraft(api.siteId, { name, slug, type, is_homepage, seo });
|
|
48
54
|
return {
|
|
49
55
|
draft_id: draft.draft_id,
|
|
@@ -101,8 +107,16 @@ RESUMABLE: if a request fails mid-commit, the draft keeps its page_id + committe
|
|
|
101
107
|
const full = { sections: draft.sections };
|
|
102
108
|
const validation = validatePage(full);
|
|
103
109
|
const total = draft.sections.length;
|
|
110
|
+
// Re-check on commit — a draft can sit for hours, and the page may have been
|
|
111
|
+
// created meanwhile. Skipped when resuming: the page already exists.
|
|
112
|
+
const conflict = draft.page_id
|
|
113
|
+
? null
|
|
114
|
+
: await checkPageCreateConflict(api, { kind: draft.meta.type, slug: draft.meta.slug, is_homepage: draft.meta.is_homepage });
|
|
104
115
|
if (dry_run) {
|
|
105
|
-
return { dry_run: true, draft_id, total_sections: total, validation, stats: validation.stats };
|
|
116
|
+
return { dry_run: true, draft_id, total_sections: total, ...(conflict ? { blocked: true, conflict } : {}), validation, stats: validation.stats };
|
|
117
|
+
}
|
|
118
|
+
if (conflict) {
|
|
119
|
+
return { error: conflict.error, existing_page: conflict.existing_page };
|
|
106
120
|
}
|
|
107
121
|
if (!validation.valid) {
|
|
108
122
|
return { error: "Validation failed — not committing.", validation };
|
package/dist/tools/pages.js
CHANGED
|
@@ -3,7 +3,7 @@ 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 } from "./builder.js";
|
|
6
|
+
import { PAGE_TYPE_NUM, PAGE_KINDS, buildPageSeo, normalizeSlug, checkPageCreateConflict } from "./builder.js";
|
|
7
7
|
/**
|
|
8
8
|
* Page source utilities.
|
|
9
9
|
*
|
|
@@ -294,10 +294,10 @@ Examples:
|
|
|
294
294
|
const results = searchElements(source, filters);
|
|
295
295
|
return { page_id, matched: results.length, elements: results };
|
|
296
296
|
}));
|
|
297
|
-
server.tool("create_page", "Create a new (empty) page. For a page with content use build_page instead. type is a KIND (main/store/member/blog/custom/error/maintain) mapped to the numeric backend type; pass seo so it doesn't publish with an empty title.", {
|
|
297
|
+
server.tool("create_page", "Create a new (empty) page. For a page with content use build_page instead. type is a KIND (main/store/member/blog/custom/error/maintain) mapped to the numeric backend type; pass seo so it doesn't publish with an empty title. ONE page per site for main (homepage) / error / maintain, and slugs are unique per site — a duplicate is refused with the existing page_id to edit instead; only 'custom' pages can be created over and over.", {
|
|
298
298
|
name: z.string().describe("Page name"),
|
|
299
299
|
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
|
-
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."),
|
|
300
|
+
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
301
|
is_homepage: z.boolean().default(false).describe("Set as homepage"),
|
|
302
302
|
seo: z
|
|
303
303
|
.object({ title: z.string().optional(), description: z.string().optional(), keyword: z.string().optional(), favicon: z.string().optional(), thumbnail: z.string().optional() })
|
|
@@ -306,6 +306,11 @@ Examples:
|
|
|
306
306
|
}, ({ name, slug, type, is_homepage, seo }) => handle(async () => {
|
|
307
307
|
const cleanSlug = normalizeSlug(slug);
|
|
308
308
|
const typeNum = type ? PAGE_TYPE_NUM[type] : undefined;
|
|
309
|
+
// Only 'custom' pages may be created repeatedly — singleton kinds (homepage/error/
|
|
310
|
+
// maintain) and taken slugs must be edited in place instead of duplicated.
|
|
311
|
+
const conflict = await checkPageCreateConflict(api, { kind: type, slug: cleanSlug, is_homepage });
|
|
312
|
+
if (conflict)
|
|
313
|
+
return { error: conflict.error, existing_page: conflict.existing_page };
|
|
309
314
|
const created = await api.createPage({ name, ...(typeNum != null ? { type: typeNum } : {}) });
|
|
310
315
|
invalidatePageCache();
|
|
311
316
|
const pageId = (created && (created.id || created.data?.id || created.page?.id)) || null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-storefront-mcp",
|
|
3
|
-
"version": "1.31.
|
|
3
|
+
"version": "1.31.10",
|
|
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",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"smoke": "node dist/smoke.js",
|
|
33
33
|
"prepare": "npm run build",
|
|
34
34
|
"prepublishOnly": "npm run build && npm run smoke",
|
|
35
|
-
"test": "node --test test
|
|
35
|
+
"test": "node --test \"test/*.test.mjs\""
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@modelcontextprotocol/sdk": "^1.12.1",
|