webcake-storefront-mcp 1.31.7 → 1.31.9

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,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) {
@@ -157,6 +157,41 @@ export function buildElement(type, opts = {}) {
157
157
  }
158
158
  // Guarantee children-using factories never throw on a missing children array.
159
159
  const node = fn({ children: [], ...opts });
160
+ // Some factories (container, menu, menu-droppable, …) IGNORE opts.config / opts.style — they
161
+ // only wire children/specials. That silently dropped base layout keys like config.isHidden
162
+ // (e.g. a "hidden on desktop" mobile bar stayed visible). Backfill them into runtime so EVERY
163
+ // element honours opts.config/opts.style. Existing values from factories that DO handle them are
164
+ // kept (opts wins only where the factory left it unset) — idempotent for those.
165
+ if (opts.config && typeof opts.config === "object") {
166
+ node.runtime = node.runtime || {};
167
+ node.runtime.config = { ...opts.config, ...(node.runtime.config || {}) };
168
+ }
169
+ if (opts.style && typeof opts.style === "object") {
170
+ node.runtime = node.runtime || {};
171
+ node.runtime.style = { ...opts.style, ...(node.runtime.style || {}) };
172
+ }
173
+ // Generic horizontal ALIGNMENT control: opts.align maps to the renderer's constraintX so
174
+ // ANY element (button, text, image, container…) can be placed left / centre / right, or
175
+ // told to FILL its grid cell. Without this a content-sized element (e.g. a button) lands
176
+ // wherever the default constraint puts it; authors had no clean knob to centre or pin it.
177
+ // 'fill' also forces a 100%-width cell so the element truly spans the column.
178
+ if (opts.align) {
179
+ const ALIGN = {
180
+ left: ["left"], center: ["centerLeft"], centre: ["centerLeft"],
181
+ right: ["right"], fill: ["left", "right"], stretch: ["left", "right"],
182
+ };
183
+ const cx = ALIGN[String(opts.align).toLowerCase()];
184
+ if (cx) {
185
+ node.runtime = node.runtime || {};
186
+ const rc = (node.runtime.config = node.runtime.config || {});
187
+ rc.constraintX = cx;
188
+ if (cx.length === 2) {
189
+ rc.widthUnit = "%";
190
+ if (rc.relWidth == null)
191
+ rc.relWidth = 100;
192
+ }
193
+ }
194
+ }
160
195
  // Some factories ignore opts.bindings/events — attach them so any element the AI passes
161
196
  // them to gets them — then normalize so each binding/event has a valid id (+ name/eventName).
162
197
  if (opts.bindings && !node.bindings)
@@ -135,12 +135,26 @@ export const createButton = (opts = {}) => {
135
135
  button.id = 'BUTTON-' + randomString(8);
136
136
  button.type = 'button';
137
137
  button.runtime.style = {
138
- width: 142,
139
- height: 46,
138
+ height: 48,
140
139
  fontSize: '16px',
140
+ // Generous horizontal padding so the label never sits edge-to-edge — the #1 "ugly
141
+ // button" complaint when a button stretched full width. Centred text keeps it tidy
142
+ // whatever the width. Override any of these via opts.style.
143
+ paddingTop: '0px',
144
+ paddingBottom: '0px',
145
+ paddingLeft: '28px',
146
+ paddingRight: '28px',
147
+ textAlign: 'center',
141
148
  ...(opts.style || {}),
142
149
  };
143
150
  button.runtime.config = {
151
+ // A button sizes to its CONTENT (label + padding), NOT the full grid cell. Without
152
+ // this, stackChildren's sizeDefaults stretches it to widthUnit:'%'/relWidth:100 — i.e.
153
+ // an edge-to-edge bar with cramped text. Content-width + the default centred
154
+ // constraintX means a standalone CTA sits centred with breathing room instead of being
155
+ // slammed flush-left. To make a button FILL its container, pass opts.align:'fill' (or
156
+ // opts.config.widthUnit:'%').
157
+ widthUnit: 'auto',
144
158
  ...(opts.config || {})
145
159
  };
146
160
  button.specials.text = opts.text || 'Button';
@@ -725,9 +739,19 @@ export const createSubmitButton = (opts = {}) => {
725
739
  const button = cloneDeep(SKELETON);
726
740
  button.id = 'SUBMIT-BUTTON-' + randomString(8);
727
741
  button.type = 'submit-button';
728
- button.runtime.style = opts.style || {};
729
- button.runtime.config = opts.config || {};
742
+ // A form submit button stays full-width (the norm inside a form column) but gets a real
743
+ // height + side padding + centred label so it never renders as a cramped sliver.
744
+ button.runtime.style = {
745
+ height: 48,
746
+ fontSize: '16px',
747
+ paddingLeft: '24px',
748
+ paddingRight: '24px',
749
+ textAlign: 'center',
750
+ ...(opts.style || {}),
751
+ };
752
+ button.runtime.config = { ...(opts.config || {}) };
730
753
  button.specials = {
754
+ ...(opts.specials || {}),
731
755
  text: opts.specials?.text || 'Submit'
732
756
  };
733
757
  return button;
@@ -94,6 +94,20 @@ children with a grid:
94
94
  new_section does ALL of this for you: pass children and they are stacked one row each in
95
95
  the centre column.
96
96
 
97
+ ### ALIGNMENT inside a cell — how children line up (avoid the "snapped to a corner" look)
98
+ \`constraintX\`/\`constraintY\` decide where a child sits in its grid cell. The renderer maps:
99
+ - \`["left","right"]\` → justify-self: STRETCH — the element FILLS the cell width (this is what makes
100
+ columns, cards, and images line up edge-to-edge). This is the DEFAULT new_section/new_row give to
101
+ every width-filling element (text, image, container, repeaters), so siblings stay in straight rows.
102
+ - \`["centerLeft"]\` → justify-self: CENTER (shrinks a content-width element to its content and centres it),
103
+ \`["left"]\` → left, \`["right"]\` → right. \`constraintY\`: \`["top"]\` (default) / \`["bottom"]\` / \`["centerTop"]\` (middle).
104
+ DON'T hand-set \`["centerLeft"]\` on a full-width element (text/container/card) — that snaps it to its
105
+ content width and floats it, so it no longer lines up with its neighbours (the classic "cắn/lệch" bug).
106
+ Instead: leave the default stretch and control the LOOK with \`textAlign\` (for text) or, for a
107
+ content-width element like a button, with \`opts.align\` ('left'|'center'|'right'|'fill'). A button is
108
+ content-width + centred by DEFAULT; pass \`align:"left"\` so it lines up with left-aligned text above it,
109
+ or \`align:"fill"\` to span the column.
110
+
97
111
  ## Multi-column rows (cards side by side) — USE THIS, real pages are full of them
98
112
  A plain vertical stack looks like a blog post, not a designed page. Feature cards,
99
113
  category tiles, footer columns, a text+image hero — all are HORIZONTAL rows. Two ways:
@@ -147,6 +161,13 @@ A bare stack of default elements looks unfinished. Apply real styling:
147
161
  - BUTTONS HAVE NO DEFAULT COLOUR — you MUST style them or they look like plain text:
148
162
  \`{ type:"button", opts:{ text:"Mua ngay", style:{ background:"var(--color_24)", color:"var(--color_00)",
149
163
  borderRadius:"8px", fontWeight:"600", height:48 } } }\` (DARK brand background, white label — always readable).
164
+ - BUTTON WIDTH & ALIGNMENT (don't fight it): a \`button\` is CONTENT-SIZED by default (label + built-in
165
+ 28px side padding) and CENTRED in its cell — so a standalone CTA looks like a real button, never a
166
+ full-width bar with cramped text or one slammed flush-left. To place/size it, pass \`opts.align\`:
167
+ \`"left"\` | \`"center"\` (default) | \`"right"\` | \`"fill"\` (span the whole column, e.g. two side-by-side
168
+ Add-to-cart / Buy-now buttons, or a button inside a narrow summary card). \`align\` works on ANY element
169
+ (text/image/container too). A form \`submit-button\` stays full-width by design. Do NOT try to force a
170
+ button's width via \`style.width\` — the renderer ignores it (use \`align:"fill"\` instead).
150
171
  - HERO: build it as a section whose FIRST child is a full-width \`image\` element (the background photo,
151
172
  width:"100%", height ~480), then overlay the heading/sub/button on top. ⚠️ Do NOT rely on a CSS
152
173
  \`background:"linear-gradient(...), url(...)"\` SHORTHAND on the section — the storefront renderer
@@ -176,6 +197,11 @@ The four breakpoints (largest → smallest), keyed bp1..bp4, are:
176
197
  For NEW pages you author once in \`runtime\` (the bp1/desktop base); on save the build expands
177
198
  it into bp1..bp4. By default all four are the same (renders identically across devices), PLUS
178
199
  sections re-centre their grid per breakpoint and multi-column rows auto-collapse (4→2→1 cols).
200
+ AUTO-RESPONSIVE DEFAULTS (you get these for free, no diffs needed): on tablet/mobile the build
201
+ also shrinks oversized TYPOGRAPHY (any fontSize ≥22px scales ~0.86× on tablet / ~0.72× on mobile,
202
+ floored at 15px) and TALL images (height >320px shrinks on mobile) so hero headlines and big media
203
+ don't blow out small screens. Body text (<22px) is left alone. Pass \`opts.responsive\` only to
204
+ OVERRIDE this default for a specific node/breakpoint (your explicit diff always wins).
179
205
 
180
206
  RESPONSIVE CASCADE (reason about each breakpoint, don't hand-copy): to make a node look
181
207
  different on smaller screens, pass \`opts.responsive\` = SPARSE per-breakpoint diffs and the
@@ -275,6 +301,14 @@ Rule of thumb: if the page shows products, a cart, customer/order data, or blog
275
301
  set \`type\` accordingly so the binding source is turned on. A binding target like
276
302
  \`product::product_price\` REQUIRES its page to be the matching type.
277
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
+
278
312
  ## Build the WHOLE storefront — every page to the SAME standard (NOT just the home page)
279
313
  A shop is multi-page. Build EACH page to a real e-commerce standard with the same palette,
280
314
  spacing and header/footer — never leave the home page rich and the rest as bare stubs.
@@ -313,6 +347,27 @@ create the globals — they embed into each page's source. If you later overwrit
313
347
  (delete_global_section by the section NODE id, then create once) to re-embed cleanly. Edit a global
314
348
  later with update_global_section_element(s) and it updates on every page at once.
315
349
 
350
+ ### HEADER must be RESPONSIVE — add a MOBILE MENUBAR, don't just let nav stack
351
+ A header built as one horizontal row of nav links looks fine on desktop but on a phone the links
352
+ squash or collapse into an ugly vertical pile. Build a header that carries TWO navs and swap them
353
+ per breakpoint with the \`isHidden\` config (it cascades bp1→bp4 like any config key):
354
+ - DESKTOP nav — the inline row of links in the top bar. Keep it on desktop+laptop, hide from tablet
355
+ down: \`responsive:{ bp3:{ config:{ isHidden:true } } }\` (base visible).
356
+ - MOBILE MENUBAR — a SEPARATE container, a centred horizontal row of the same links placed as a
357
+ second row of the header section (below the logo/cart bar). Hide it on desktop+laptop, show from
358
+ tablet down: base \`config:{ isHidden:true }\` + \`responsive:{ bp3:{ config:{ isHidden:false } } }\`.
359
+ RELIABILITY NOTE (storefront-verified): this two-nav \`isHidden\`-SWAP is the dependable mobile
360
+ menubar. AVOID these — they do NOT render dependably from raw MCP data: the native \`menu\`
361
+ \`type:"hamburger"\` (its ☰ trigger paints as a 0-width empty box; an inline-SVG \`mask\` is ignored);
362
+ a \`toggle\`-revealed panel (the storefront only makes a toggle target's hidden state reactive when
363
+ it's wired in the builder UI, so a click can't show an \`isHidden\` element); and \`open_popup\` drawers
364
+ (popups are global_sources and the publish pipeline doesn't always emit them, so the popup isn't on
365
+ the page). A plain visible mobile nav row, shown via \`isHidden\` swap, always works.
366
+ Lay the bar as a 2-column row [logo | (desktop-nav + cart, aligned right)] and add the mobile nav row
367
+ as the section's 2nd child. Keep the logo + cart-icon visible on every breakpoint.
368
+ (If you do want a real collapsible hamburger, finish it in the WebCake builder UI, which wires the
369
+ toggle/menu reactively — the MCP can't reproduce that wiring from data alone.)
370
+
316
371
  ## Popups (newsletter / promo / age-gate)
317
372
  A popup is a GLOBAL SOURCE, not a page section. Compose it from elements (there is no scaffold
318
373
  shortcut), then save it as a "popup" global source:
@@ -10,6 +10,42 @@ import { validateEvents } from "./events.js";
10
10
  import { validateBindings } from "./bindings.js";
11
11
  import { BREAKPOINTS, genGridByBp, SECTION_CONTENT_COL_START, SECTION_CONTENT_COL_END, } from "./grid.js";
12
12
  const clone = (o) => structuredClone(o);
13
+ /** Parse a CSS px length ("52px") or a bare number (52) → number, else null. */
14
+ function pxToNum(v) {
15
+ if (typeof v === "number")
16
+ return Number.isFinite(v) ? v : null;
17
+ const m = /^(-?\d+(?:\.\d+)?)px$/.exec(String(v ?? "").trim());
18
+ return m ? parseFloat(m[1]) : null;
19
+ }
20
+ /** AUTO-RESPONSIVE down-scaling per breakpoint. Headings/large text and very tall media
21
+ * keep their desktop size on phones unless the author hand-writes a responsive diff —
22
+ * which makes generated pages look broken on mobile. These factors shrink oversized
23
+ * typography (and tall images) on tablet/mobile by DEFAULT. Applied only when the author
24
+ * did NOT already override the property at this breakpoint (resolved value === base). */
25
+ const AUTO_FONT_FACTOR = { bp1: 1, bp2: 1, bp3: 0.86, bp4: 0.72 };
26
+ const AUTO_IMG_HEIGHT_FACTOR = { bp1: 1, bp2: 1, bp3: 0.82, bp4: 0.58 };
27
+ /** Mutate a resolved per-breakpoint `style` in place: shrink big fonts (≥22px) and tall
28
+ * images (>320px) for smaller breakpoints. `baseStyle` is the bp1/authored style — we only
29
+ * auto-scale a property the author left untouched at this bp (resolved === base). */
30
+ function applyAutoResponsive(node, bp, baseStyle, style) {
31
+ const ff = AUTO_FONT_FACTOR[bp];
32
+ if (ff != null && ff < 1) {
33
+ const baseF = pxToNum(baseStyle.fontSize);
34
+ const curF = pxToNum(style.fontSize);
35
+ if (baseF != null && curF != null && curF === baseF && baseF >= 22) {
36
+ style.fontSize = Math.max(15, Math.round(baseF * ff)) + "px";
37
+ }
38
+ }
39
+ const hf = AUTO_IMG_HEIGHT_FACTOR[bp];
40
+ if (hf != null && hf < 1 && (node.type === "image" || node.type === "image-dataset")) {
41
+ const baseH = pxToNum(baseStyle.height);
42
+ const curH = pxToNum(style.height);
43
+ if (baseH != null && curH != null && curH === baseH && baseH > 320) {
44
+ const nh = Math.round(baseH * hf);
45
+ style.height = typeof baseStyle.height === "number" ? nh : nh + "px";
46
+ }
47
+ }
48
+ }
13
49
  /** Walk every node in a source tree (depth-first). Return false from fn to stop. */
14
50
  export function walk(source, fn) {
15
51
  const sections = source && Array.isArray(source.sections) ? source.sections : [];
@@ -64,11 +100,28 @@ const FILL_WIDTH_TYPES = new Set([
64
100
  "grid-category", "grid-blog", "product-gallery", "product-image-carousel",
65
101
  "custom-layout", "layout-dataset", "form",
66
102
  ]);
67
- /** Default horizontal constraint for a child: stretch for fill-width components,
68
- * else the builder's usual centred placement. Respects an explicit constraintX. */
103
+ /**
104
+ * Default horizontal constraint for a child in its grid cell. THE ALIGNMENT RULE:
105
+ * an element that FILLS its cell width (widthUnit "%", relWidth 100 — the default for
106
+ * text/image/container/repeaters) must STRETCH (`["left","right"]` → justify-self:stretch),
107
+ * NOT centre. The old default `["centerLeft"]` (justify-self:center) snapped such elements
108
+ * to their content width and floated them — so sibling columns/cards/images did NOT line up
109
+ * (the "cắn left-top / không thẳng hàng" bug). Stretch makes every full-width child span its
110
+ * cell edge-to-edge, so its OWN textAlign / inner stacking controls layout and rows align.
111
+ * Only a genuinely CONTENT-WIDTH element (widthUnit "auto", e.g. a button) gets a point
112
+ * constraint, defaulting to centre. An explicit constraintX (e.g. set by opts.align) always wins.
113
+ */
69
114
  function defaultConstraintX(child) {
70
- return ((child.runtime && child.runtime.config && child.runtime.config.constraintX) ||
71
- (FILL_WIDTH_TYPES.has(child.type) ? ["left", "right"] : ["centerLeft"]));
115
+ const cfg = (child.runtime && child.runtime.config) || {};
116
+ if (cfg.constraintX)
117
+ return cfg.constraintX; // explicit / from opts.align
118
+ if (FILL_WIDTH_TYPES.has(child.type))
119
+ return ["left", "right"];
120
+ // Content-sized elements (a button opts into widthUnit:"auto") centre by default; everything
121
+ // else fills its cell, so it must stretch to line up with its neighbours.
122
+ if (cfg.widthUnit === "auto")
123
+ return ["centerLeft"];
124
+ return ["left", "right"];
72
125
  }
73
126
  export function stackChildren(container, children, opts = {}) {
74
127
  const gridCols = opts.gridCols || 1;
@@ -340,6 +393,8 @@ function expandNodeToBreakpoints(node) {
340
393
  delete config.__row;
341
394
  delete config.__cell;
342
395
  delete config.responsive;
396
+ // Shrink oversized fonts / tall images on tablet & mobile by default (author diffs win).
397
+ applyAutoResponsive(node, bp, rt.style || {}, style);
343
398
  if (isSection) {
344
399
  const g = genGridByBp(minW);
345
400
  const sectionRows = baseConfig.rows && baseConfig.rows.length ? clone(baseConfig.rows) : clone(g.rows);
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.31.9",
4
+ "d": "07/09/2026",
5
+ "type": "Added",
6
+ "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…",
7
+ "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ó…"
8
+ },
9
+ {
10
+ "v": "1.31.8",
11
+ "d": "29/06/2026",
12
+ "type": "Fixed",
13
+ "en": "Elements placed by new_section, new_row, and build_page that fill their grid cell (text, image, container, columns, repeaters) now receive…",
14
+ "vi": "Các phần tử được đặt bởi new_section, new_row và build_page mà lấp đầy ô lưới (text, image, container, columns, repeaters) nay nhận constraintX:…"
15
+ },
2
16
  {
3
17
  "v": "1.31.7",
4
18
  "d": "26/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
41
  "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…",
28
42
  "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…"
29
- },
30
- {
31
- "v": "1.31.3",
32
- "d": "26/06/2026",
33
- "type": "Added",
34
- "en": "New update_collection_columns tool reads the current collection schema and PATCHes it with the system columns plus the provided custom columns,…",
35
- "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…"
36
- },
37
- {
38
- "v": "1.31.2",
39
- "d": "26/06/2026",
40
- "type": "Changed",
41
- "en": "The HTTP_FUNCTION_GUIDE embedded in get_http_function and get_site_custom_code now includes a \"Common patterns\" section with battle-tested…",
42
- "vi": "HTTP_FUNCTION_GUIDE được nhúng trong get_http_function và get_site_custom_code nay bổ sung phần \"Common patterns\" với các recipe đã được kiểm chứng…"
43
43
  }
44
44
  ]
@@ -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 = {}) {
@@ -83,7 +136,7 @@ export function registerBuilderTools(server, api, handle) {
83
136
  server.tool("list_bindings", "List every dynamic-data BINDING target: the datasets (product, cart_item, order, order_item, post, category, customer, customer_address, …) and their exact field names ('product::product_price', …), which page type each needs (store/member/blog), and how repeater children (grid-product, cart-items, post-list) bind per-item. Attach via new_element opts.bindings (ids auto-minted, e.g. opts.bindings=[{ target:'product::product_price' }]).", {}, () => handle(async () => describeBindingsCatalog()));
84
137
  server.tool("new_element", "Build a single structurally-valid element node from the real builder factory. Returns the node — edit its specials/style, then place it in a section's children.", {
85
138
  type: z.string().describe("Element type (see list_elements)"),
86
- opts: z.record(z.any()).optional().describe("Factory opts: { text, src, width, height, style, config, specials, events }"),
139
+ opts: z.record(z.any()).optional().describe("Factory opts: { text, src, width, height, style, config, specials, events, bindings, responsive, align }. align = horizontal placement in the grid cell: 'left'|'center'|'right'|'fill' (works on any element; buttons are content-width+centred by default — use align:'fill' to span the column)."),
87
140
  }, ({ type, opts }) => handle(async () => buildElement(type, opts || {})));
88
141
  server.tool("new_section", `Build a complete section node with children laid out in the builder's vertical grid.
89
142
  Pass an array of element specs; each child is stacked top-to-bottom. Nest containers via the child's own 'children'.
@@ -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: validation.valid
159
- ? `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.` : ""}`
160
- : "Fix the errors above before saving.",
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
  }
@@ -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 };
@@ -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.7",
3
+ "version": "1.31.9",
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",