webcake-storefront-mcp 1.31.6 → 1.31.7

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.
@@ -89,20 +89,36 @@ export function stackChildren(container, children, opts = {}) {
89
89
  };
90
90
  children.forEach((child, i) => {
91
91
  child.runtime = child.runtime || {};
92
+ const cc = child.runtime.config || {};
92
93
  child.runtime.config = {
93
- ...(child.runtime.config || {}),
94
+ ...cc,
94
95
  columnStart: colStart,
95
96
  columnEnd: colEnd,
96
97
  rowStart: i + 1,
97
98
  rowEnd: i + 2,
98
99
  constraintX: defaultConstraintX(child),
99
- constraintY: (child.runtime.config && child.runtime.config.constraintY) || ["top"],
100
+ constraintY: cc.constraintY || ["top"],
101
+ // Width the template-native way: every element declares a width % of its cell
102
+ // (config.widthUnit + relWidth). Without these the renderer emits an invalid
103
+ // `width: %;` (the "width is 0" bug). Default to filling the cell; respect an
104
+ // element that opted into "auto" (content-sized, e.g. buttons) or "px".
105
+ ...sizeDefaults(cc),
100
106
  loaded: true,
101
107
  };
102
108
  });
103
109
  container.children = children;
104
110
  return container;
105
111
  }
112
+ /** Template-native width defaults: fill the grid cell (widthUnit "%", relWidth 100) unless
113
+ * the element already chose a unit. The storefront's build_position reads these (NOT
114
+ * style.width, except when widthUnit==="px"). */
115
+ function sizeDefaults(cfg) {
116
+ const widthUnit = cfg.widthUnit || "%";
117
+ const out = { widthUnit };
118
+ if (widthUnit !== "auto")
119
+ out.relWidth = cfg.relWidth != null ? cfg.relWidth : 100;
120
+ return out;
121
+ }
106
122
  const ROW_PLACEHOLDER_ROW = () => ({ unit: "min/max", min: { unit: "px", absValue: 50 }, max: { unit: "max-c" } });
107
123
  /** Default responsive collapse for a multi-column row: full columns on desktop/laptop,
108
124
  * 2 columns on tablet, 1 column on mobile. `null` = keep the full column count. */
@@ -137,14 +153,16 @@ export function rowChildren(container, children, opts = {}) {
137
153
  };
138
154
  children.forEach((child, i) => {
139
155
  child.runtime = child.runtime || {};
156
+ const cc = child.runtime.config || {};
140
157
  child.runtime.config = {
141
- ...(child.runtime.config || {}),
158
+ ...cc,
142
159
  columnStart: i + 1,
143
160
  columnEnd: i + 2,
144
161
  rowStart: 1,
145
162
  rowEnd: 2,
146
163
  constraintX: defaultConstraintX(child),
147
- constraintY: (child.runtime.config && child.runtime.config.constraintY) || ["top"],
164
+ constraintY: cc.constraintY || ["top"],
165
+ ...sizeDefaults(cc),
148
166
  loaded: true,
149
167
  __cell: { index: i, ...meta },
150
168
  };
@@ -182,14 +200,27 @@ export function buildSection(childSpecs = [], sectionOpts = {}) {
182
200
  contentColEnd: SECTION_CONTENT_COL_END,
183
201
  rowGap: sectionOpts.rowGap,
184
202
  });
185
- // Give sections vertical breathing room by default (a plain edge-to-edge stack looks
186
- // unfinished). Caller can override via sectionOpts.style.
203
+ // Section vertical padding the TEMPLATE-NATIVE way: a fixed-height spacer GRID ROW at the
204
+ // top and bottom, content rows in between (real templates do exactly this — the storefront
205
+ // ignores CSS `padding` on a section, build_section only emits the grid). Default 64px;
206
+ // pass sectionOpts.padY (0 disables, e.g. a full-bleed hero).
207
+ const padY = sectionOpts.padY != null ? sectionOpts.padY : 64;
187
208
  section.runtime = section.runtime || {};
188
- section.runtime.style = {
189
- paddingTop: 56,
190
- paddingBottom: 56,
191
- ...(sectionOpts.style || {}),
192
- };
209
+ const cfg = section.runtime.config || (section.runtime.config = {});
210
+ if (padY > 0 && Array.isArray(cfg.rows)) {
211
+ const spacer = () => ({ unit: "min/max", min: { unit: "px", absValue: padY }, max: { unit: "max-c" } });
212
+ cfg.rows = [spacer(), ...cfg.rows, spacer()];
213
+ cfg.grid = `3x${cfg.rows.length}`;
214
+ for (const c of children) {
215
+ const cc = c.runtime && c.runtime.config;
216
+ if (cc) {
217
+ cc.rowStart = (cc.rowStart || 1) + 1;
218
+ cc.rowEnd = (cc.rowEnd || 2) + 1;
219
+ }
220
+ }
221
+ }
222
+ // Section style carries background only (padding is done via the spacer rows above).
223
+ section.runtime.style = { ...(sectionOpts.style || {}) };
193
224
  return section;
194
225
  }
195
226
  export function buildFromSpec(spec) {
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.31.7",
4
+ "d": "26/06/2026",
5
+ "type": "Fixed",
6
+ "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…",
7
+ "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…"
8
+ },
2
9
  {
3
10
  "v": "1.31.6",
4
11
  "d": "26/06/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Changed",
34
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…",
35
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…"
36
- },
37
- {
38
- "v": "1.31.1",
39
- "d": "26/06/2026",
40
- "type": "Fixed",
41
- "en": "The webcake-data SDK reference embedded in get_http_function and get_site_custom_code now documents the correct Mongoose-document API: filters are…",
42
- "vi": "Tài liệu tham chiếu SDK webcake-data được nhúng trong get_http_function và get_site_custom_code nay ghi lại đúng API kiểu Mongoose-document: bộ lọc…"
43
43
  }
44
44
  ]
package/dist/smoke.js CHANGED
@@ -50,8 +50,12 @@ console.log("== page: grid composition + validation ==");
50
50
  { type: "button", opts: { text: "Buy" } },
51
51
  ]);
52
52
  // A section uses the builder's centred 3-column grid; children sit in the centre column.
53
- check("section grid is 3xN", hero.runtime.config.grid === "3x2", hero.runtime.config.grid);
53
+ // padY (default 64) adds a top + bottom SPACER ROW, so a 2-child section is 3x4 and the
54
+ // children start at row 2 (past the top spacer) — template-native section padding.
55
+ check("section grid is 3x(N+2) with spacer rows", hero.runtime.config.grid === "3x4", hero.runtime.config.grid);
56
+ check("top row is a padY spacer", hero.runtime.config.rows[0].min.absValue === 64, hero.runtime.config.rows[0]);
54
57
  check("children placed in centre column", hero.children.every((c) => c.runtime.config.columnStart === 2));
58
+ check("children shifted past top spacer", hero.children[0].runtime.config.rowStart === 2, hero.children[0].runtime.config.rowStart);
55
59
  const src = newPageSkeleton();
56
60
  src.sections.push(hero);
57
61
  const v = validatePage(src);
@@ -62,7 +66,7 @@ console.log("== page: grid composition + validation ==");
62
66
  const sec0 = src.sections[0];
63
67
  check("finalize removes runtime", !("runtime" in sec0), Object.keys(sec0));
64
68
  check("finalize adds bp1..bp4", ["bp1", "bp2", "bp3", "bp4"].every((bp) => sec0[bp]?.config), Object.keys(sec0));
65
- check("section bp4 is mobile grid", sec0.bp4.config.grid === "3x2" && sec0.bp4.config.columns[0].absValue === 5, sec0.bp4.config.columns?.[0]);
69
+ check("section bp4 is mobile grid", sec0.bp4.config.grid === "3x4" && sec0.bp4.config.columns[0].absValue === 5, sec0.bp4.config.columns?.[0]);
66
70
  check("child bp1 keeps centre column", sec0.children[0].bp1.config.columnStart === 2, sec0.children[0].bp1?.config);
67
71
  check("finalize is idempotent", (finalizeForRender(src), !("runtime" in sec0)));
68
72
  // duplicate ids must fail validation
@@ -61,6 +61,17 @@ export function buildPageSeo(seo = {}) {
61
61
  out.og_image = seo.thumbnail;
62
62
  return out;
63
63
  }
64
+ /** Normalize a page slug to what the storefront matches on. The storefront routes by the
65
+ * RAW path segment (e.g. "/cart" → path ["cart"]) and looks up `page.slug == "cart"`, so a
66
+ * stored slug WITH a leading "/" (e.g. "/cart") never matches and the page 404s. The homepage
67
+ * is matched by `is_nil(slug)`, so an empty/"/" slug must become "no slug" (undefined) — never
68
+ * stored as "". Strips leading/trailing slashes; returns undefined for the homepage/blank case. */
69
+ export function normalizeSlug(slug) {
70
+ if (!slug)
71
+ return undefined;
72
+ const s = String(slug).trim().replace(/^\/+/, "").replace(/\/+$/, "");
73
+ return s.length ? s : undefined;
74
+ }
64
75
  export function registerBuilderTools(server, api, handle) {
65
76
  server.tool("get_build_guide", "Get the BuilderX page authoring guide: page shape, the grid layout model, styling, breakpoints, forms/data, and the build workflow. Read this before building or heavily editing a page.", {}, () => handle(async () => ({ guide: BUILD_GUIDE })));
66
77
  server.tool("get_page_schema", "Get the authoritative JSON Schema (Draft 2020-12) for a page source `{ sections: [...] }` — the structural contract for every node (id/type/specials/runtime{style,config}/children/events/bindings) in the CSS-grid model. Use it as the shape to emit; validate_page enforces the semantic rules.", {}, () => handle(async () => PAGE_SCHEMA));
@@ -109,7 +120,7 @@ Example children: [{ "type":"container", "children":[{"type":"image","opts":{...
109
120
  Two-step safety: call with dry_run=true (default) to validate and preview, then dry_run=false to actually create + save.
110
121
  The source must be { sections: [...] } — build sections with new_section. Validation errors block the real save.`, {
111
122
  name: z.string().describe("Page name"),
112
- slug: z.string().describe("URL slug, e.g. '/landing' or '/about'"),
123
+ 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)."),
113
124
  source: z.any().describe("Full page source { sections: [...] } (object or JSON string)"),
114
125
  type: z
115
126
  .enum(PAGE_KINDS)
@@ -134,11 +145,14 @@ The source must be { sections: [...] } — build sections with new_section. Vali
134
145
  const kind = type || (is_homepage ? "main" : undefined);
135
146
  const typeNum = kind ? PAGE_TYPE_NUM[kind] : undefined;
136
147
  const requiredFlag = kind ? PAGE_TYPE_FLAG[kind] : undefined;
148
+ // Strip a leading "/" — the storefront matches `page.slug == "<segment>"` (no slash),
149
+ // so "/cart" would 404. Homepage (blank/"/") → undefined (matched by is_nil(slug)).
150
+ const cleanSlug = normalizeSlug(slug);
137
151
  if (dry_run) {
138
152
  return {
139
153
  dry_run: true,
140
154
  validation,
141
- request: { name, slug, type: kind ?? null, page_type_num: typeNum ?? null, is_homepage, sections: (parsed && parsed.sections || []).length },
155
+ request: { name, slug: cleanSlug ?? null, type: kind ?? null, page_type_num: typeNum ?? null, is_homepage, sections: (parsed && parsed.sections || []).length },
142
156
  will_enable_feature: requiredFlag ?? null,
143
157
  renders_at_breakpoints: ["bp1", "bp2", "bp3", "bp4"],
144
158
  hint: validation.valid
@@ -170,10 +184,10 @@ The source must be { sections: [...] } — build sections with new_section. Vali
170
184
  }
171
185
  // slug / homepage / SEO are not applied at create — set them via update_page.
172
186
  const seoBlock = seo ? buildPageSeo(seo) : null;
173
- if (slug || is_homepage || (seoBlock && Object.keys(seoBlock).length)) {
187
+ if (cleanSlug || is_homepage || (seoBlock && Object.keys(seoBlock).length)) {
174
188
  await api
175
189
  .updatePage(pageId, {
176
- ...(slug ? { slug } : {}),
190
+ ...(cleanSlug ? { slug: cleanSlug } : {}),
177
191
  ...(is_homepage ? { is_homepage: true } : {}),
178
192
  ...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
179
193
  })
@@ -183,7 +197,7 @@ The source must be { sections: [...] } — build sections with new_section. Vali
183
197
  success: true,
184
198
  page_id: pageId,
185
199
  name,
186
- slug,
200
+ slug: cleanSlug ?? null,
187
201
  page_type: kind ?? null,
188
202
  ...(feature ? { data_source: { flag: feature.flag, newly_enabled: feature.changed } } : {}),
189
203
  stats: validation.stats,
@@ -18,6 +18,18 @@ const variationSpec = z.object({
18
18
  .optional()
19
19
  .describe("Attribute values for this variation, e.g. [{name:'Color',value:'Đen'},{name:'Size',value:'M'}]"),
20
20
  });
21
+ /** Normalize an attribute value to its keyword key (DEN for "Đen", TRANG for "Trắng", S for "S") —
22
+ * the form the storefront variation selector uses. Strips Vietnamese diacritics + uppercases. */
23
+ function attrKeyValue(v) {
24
+ return String(v)
25
+ .normalize("NFD").replace(/[̀-ͯ]/g, "")
26
+ .replace(/đ/g, "d").replace(/Đ/g, "D")
27
+ .toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
28
+ }
29
+ /** Cartesian product of N lists — used to expand attribute axes into one variation per combo. */
30
+ function cartesian(lists) {
31
+ return lists.reduce((acc, cur) => acc.flatMap((a) => cur.map((b) => [...a, b])), [[]]);
32
+ }
21
33
  export function registerCatalogWriteTools(server, api, handle) {
22
34
  server.tool("create_product", `Create a product so the storefront has real merchandise (grid-product / slider-product bindings need this).
23
35
  Simple use: pass name + price (+ images, category_ids). One default variation with the price/stock is created for you.
@@ -39,23 +51,49 @@ Images must be HOSTED URLs — get them from search_images or upload_images firs
39
51
  variations: z.array(variationSpec).optional().describe("Explicit per-SKU variations. Omit to auto-build one from price/stock/sku."),
40
52
  }, ({ name, price, original_price, stock, sku, images, description, short_description, category_ids, attributes, variations }) => handle(async () => {
41
53
  let vars = variations;
54
+ // Enrich attribute axes with the id + keyword shape the storefront variation selector
55
+ // needs (a raw {name,values} declares the axis but attaches it to no variation).
56
+ const attrDefs = (attributes || []).map((a) => ({
57
+ id: randomUUID(),
58
+ name: a.name,
59
+ values: a.values,
60
+ keyword: (a.values || []).map((v) => ({ keyValue: attrKeyValue(v), value: v })),
61
+ }));
42
62
  if (!vars || !vars.length) {
43
63
  if (price == null)
44
64
  throw new Error("Provide `price` (or explicit `variations`) to create a product.");
45
- vars = [
46
- {
47
- custom_id: sku || `SKU-${randomUUID().slice(0, 8)}`,
65
+ if (attrDefs.length) {
66
+ // Expand the attribute axes into ONE variation per value-combination, each carrying
67
+ // `fields` otherwise the axis is declared but bound to nothing (not selectable).
68
+ const axisLists = attrDefs.map((a) => a.values.map((v) => ({ name: a.name, value: v })));
69
+ const combos = cartesian(axisLists);
70
+ vars = combos.map((combo) => ({
71
+ custom_id: `SKU-${randomUUID().slice(0, 8)}`,
48
72
  retail_price: price,
49
73
  original_price: original_price ?? price,
50
74
  remain_quantity: stock ?? 100,
51
75
  images: images || [],
52
76
  weight: 0,
53
- fields: [],
54
- },
55
- ];
77
+ fields: combo.map((c) => ({ id: randomUUID(), name: c.name, value: c.value })),
78
+ is_hidden: false,
79
+ }));
80
+ }
81
+ else {
82
+ vars = [
83
+ {
84
+ custom_id: sku || `SKU-${randomUUID().slice(0, 8)}`,
85
+ retail_price: price,
86
+ original_price: original_price ?? price,
87
+ remain_quantity: stock ?? 100,
88
+ images: images || [],
89
+ weight: 0,
90
+ fields: [],
91
+ },
92
+ ];
93
+ }
56
94
  }
57
95
  else {
58
- // Normalise: fill SKU / original_price / stock defaults per variation.
96
+ // Normalise: fill SKU / original_price / stock defaults per variation; mint field ids.
59
97
  vars = vars.map((v) => ({
60
98
  custom_id: v.custom_id || `SKU-${randomUUID().slice(0, 8)}`,
61
99
  retail_price: v.retail_price,
@@ -63,7 +101,7 @@ Images must be HOSTED URLs — get them from search_images or upload_images firs
63
101
  remain_quantity: v.remain_quantity ?? 100,
64
102
  images: v.images || [],
65
103
  weight: v.weight ?? 0,
66
- fields: v.fields || [],
104
+ fields: (v.fields || []).map((f) => ({ id: f.id || randomUUID(), name: f.name, value: f.value })),
67
105
  is_hidden: false,
68
106
  }));
69
107
  }
@@ -75,7 +113,7 @@ Images must be HOSTED URLs — get them from search_images or upload_images firs
75
113
  // endpoint, so it must be [] — never omitted — even for a no-variation product.)
76
114
  categories: category_ids || [],
77
115
  ribbons: [],
78
- product_attributes: attributes || [],
116
+ product_attributes: attrDefs,
79
117
  ...(description ? { description } : {}),
80
118
  // short_description is an ARRAY of {description} blocks on the real product shape.
81
119
  ...(short_description ? { short_description: [{ description: short_description }] } : {}),
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { PAGE_TYPE_NUM, PAGE_TYPE_FLAG, PAGE_KINDS, buildPageSeo } from "./builder.js";
2
+ import { PAGE_TYPE_NUM, PAGE_TYPE_FLAG, PAGE_KINDS, buildPageSeo, normalizeSlug } 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).
@@ -27,7 +27,7 @@ function newPageId(res) {
27
27
  export function registerPageDraftTools(server, api, handle) {
28
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
29
  name: z.string().describe("Page name"),
30
- slug: z.string().describe("URL slug, e.g. '/landing' or '/about'"),
30
+ 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
31
  type: z
32
32
  .enum(PAGE_KINDS)
33
33
  .optional()
@@ -140,11 +140,13 @@ RESUMABLE: if a request fails mid-commit, the draft keeps its page_id + committe
140
140
  await setDraft(draft);
141
141
  }
142
142
  // All sections committed → apply slug / homepage / SEO, then drop the draft.
143
+ // Strip a leading "/" so the storefront's bare-segment match resolves (else 404).
144
+ const cleanSlug = normalizeSlug(draft.meta.slug);
143
145
  const seoBlock = draft.meta.seo ? buildPageSeo(draft.meta.seo) : null;
144
- if (draft.meta.slug || draft.meta.is_homepage || (seoBlock && Object.keys(seoBlock).length)) {
146
+ if (cleanSlug || draft.meta.is_homepage || (seoBlock && Object.keys(seoBlock).length)) {
145
147
  await api
146
148
  .updatePage(draft.page_id, {
147
- ...(draft.meta.slug ? { slug: draft.meta.slug } : {}),
149
+ ...(cleanSlug ? { slug: cleanSlug } : {}),
148
150
  ...(draft.meta.is_homepage ? { is_homepage: true } : {}),
149
151
  ...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
150
152
  })
@@ -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 } from "./builder.js";
6
+ import { PAGE_TYPE_NUM, PAGE_KINDS, buildPageSeo, normalizeSlug } from "./builder.js";
7
7
  /**
8
8
  * Page source utilities.
9
9
  *
@@ -296,7 +296,7 @@ Examples:
296
296
  }));
297
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.", {
298
298
  name: z.string().describe("Page name"),
299
- slug: z.string().describe("URL slug (e.g. '/about')"),
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
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."),
301
301
  is_homepage: z.boolean().default(false).describe("Set as homepage"),
302
302
  seo: z
@@ -304,30 +304,39 @@ Examples:
304
304
  .optional()
305
305
  .describe("SEO → settings.seo (title/description/keyword/favicon/thumbnail). Tokens {{name_page}}/{{name_site}} allowed."),
306
306
  }, ({ name, slug, type, is_homepage, seo }) => handle(async () => {
307
+ const cleanSlug = normalizeSlug(slug);
307
308
  const typeNum = type ? PAGE_TYPE_NUM[type] : undefined;
308
309
  const created = await api.createPage({ name, ...(typeNum != null ? { type: typeNum } : {}) });
309
310
  invalidatePageCache();
310
311
  const pageId = (created && (created.id || created.data?.id || created.page?.id)) || null;
311
312
  const seoBlock = seo ? buildPageSeo(seo) : null;
312
- if (pageId && (slug || is_homepage || (seoBlock && Object.keys(seoBlock).length))) {
313
+ if (pageId && (cleanSlug || is_homepage || (seoBlock && Object.keys(seoBlock).length))) {
313
314
  await api
314
315
  .updatePage(pageId, {
315
- ...(slug ? { slug } : {}),
316
+ ...(cleanSlug ? { slug: cleanSlug } : {}),
316
317
  ...(is_homepage ? { is_homepage: true } : {}),
317
318
  ...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
318
319
  })
319
320
  .catch(() => { });
320
321
  invalidatePageCache();
321
322
  }
322
- return { success: true, page_id: pageId, name, slug, type: type ?? null, raw: pageId ? undefined : created };
323
+ return { success: true, page_id: pageId, name, slug: cleanSlug ?? null, type: type ?? null, raw: pageId ? undefined : created };
323
324
  }));
324
325
  server.tool("update_page", "Update page properties (name, slug, settings, custom code)", {
325
326
  page_id: z.string().describe("Page ID"),
326
327
  name: z.string().optional().describe("New name"),
327
- slug: z.string().optional().describe("New slug"),
328
+ 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."),
328
329
  is_homepage: z.boolean().optional().describe("Set as homepage"),
329
330
  settings: z.record(z.any()).optional().describe("Page settings"),
330
331
  }, ({ page_id, ...params }) => handle(async () => {
332
+ // Normalize slug (strip leading "/") so the storefront's bare-segment match resolves.
333
+ if (params.slug !== undefined) {
334
+ const clean = normalizeSlug(params.slug);
335
+ if (clean)
336
+ params.slug = clean;
337
+ else
338
+ delete params.slug;
339
+ }
331
340
  const res = await api.updatePage(page_id, params);
332
341
  invalidatePageCache();
333
342
  return res;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-storefront-mcp",
3
- "version": "1.31.6",
3
+ "version": "1.31.7",
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",