webcake-landing-mcp 1.0.1

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.
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Port of assets/editor/factory.js `createElement`.
3
+ * Produces a valid default element node (skeleton) for a given `type`, matching
4
+ * the sizes / specials the real editor seeds. Used by the `new_element` tool so
5
+ * Claude always starts from a structurally-correct node.
6
+ */
7
+ const ALNUM = "abcdefghijklmnopqrstuvwxyz0123456789";
8
+ export function randomId(len = 8) {
9
+ let s = "";
10
+ for (let i = 0; i < len; i++)
11
+ s += ALNUM[Math.floor(Math.random() * ALNUM.length)];
12
+ return s;
13
+ }
14
+ /**
15
+ * Placeholder image URL. There is no image API yet, so generated image elements
16
+ * get a visible placeholder (sized to the element) instead of an empty src —
17
+ * otherwise the page renders blank where images should be. Users swap these later.
18
+ */
19
+ export function imgPlaceholder(w = 600, h = 400, label = "Image") {
20
+ return `https://placehold.co/${Math.round(w)}x${Math.round(h)}?text=${encodeURIComponent(label)}`;
21
+ }
22
+ /** Types that carry a `children` array. */
23
+ export const CONTAINER_TYPES = new Set([
24
+ "section",
25
+ "dynamic_page",
26
+ "group",
27
+ "grid",
28
+ "grid-item",
29
+ "carousel",
30
+ "slide",
31
+ "popup",
32
+ "form",
33
+ "gallery",
34
+ "checkbox-group",
35
+ "radio",
36
+ "group-select",
37
+ ]);
38
+ /** Form input types that require a unique specials.field_name. */
39
+ export const FIELD_TYPES = new Set([
40
+ "input",
41
+ "textarea",
42
+ "select",
43
+ "checkbox",
44
+ "checkbox-group",
45
+ "radio",
46
+ "address",
47
+ "country-select",
48
+ "quantity_input",
49
+ "input-datetime",
50
+ "input-file",
51
+ "signature",
52
+ "verify-code",
53
+ "group-select-item",
54
+ ]);
55
+ /** Default per-breakpoint animation block (matches real page_source). */
56
+ export function defaultAnimation() {
57
+ return { name: "none", delay: 0, duration: 3, repeat: null };
58
+ }
59
+ function base() {
60
+ return {
61
+ id: randomId(),
62
+ type: "",
63
+ properties: { movable: true, sync: true },
64
+ responsive: {
65
+ desktop: { config: { notloaded: false, animation: defaultAnimation() }, styles: {} },
66
+ mobile: { config: { notloaded: false, animation: defaultAnimation() }, styles: {} },
67
+ },
68
+ specials: {},
69
+ runtime: {},
70
+ events: [],
71
+ };
72
+ }
73
+ /** Set the same style key on both breakpoints. */
74
+ function setStyle(el, key, value) {
75
+ el.responsive.desktop.styles[key] = value;
76
+ el.responsive.mobile.styles[key] = value;
77
+ }
78
+ /** Set width+height on both breakpoints. */
79
+ function setBox(el, w, h) {
80
+ if (w != null)
81
+ setStyle(el, "width", w);
82
+ if (h != null)
83
+ setStyle(el, "height", h);
84
+ }
85
+ /** Seed top/left = 0 on both breakpoints (absolute-positioned leaf inside a section). */
86
+ function seedPosition(el) {
87
+ setStyle(el, "top", 0);
88
+ setStyle(el, "left", 0);
89
+ }
90
+ /**
91
+ * Create a default element node for `type`. Mirrors editor factory defaults.
92
+ * `overrides.name` sets properties.name; unknown types still produce a valid node.
93
+ */
94
+ export function createElement(type, overrides = {}) {
95
+ const el = base();
96
+ el.type = type;
97
+ el.properties.name = overrides.name ?? defaultName(type);
98
+ switch (type) {
99
+ case "section":
100
+ el.properties.movable = false;
101
+ setStyle(el, "position", "relative");
102
+ setBox(el, undefined, 800);
103
+ el.children = [];
104
+ el.specials.imageCompression = true;
105
+ break;
106
+ case "dynamic_page":
107
+ el.properties.movable = false;
108
+ setStyle(el, "position", "relative");
109
+ setStyle(el, "height", 800);
110
+ el.responsive.desktop.styles.width = 960;
111
+ el.responsive.mobile.styles.width = 420;
112
+ el.children = [];
113
+ el.specials.imageCompression = true;
114
+ break;
115
+ case "text-block":
116
+ seedPosition(el);
117
+ setBox(el, 200);
118
+ el.specials.text = "hello world";
119
+ el.specials.tag = "p";
120
+ break;
121
+ case "list-paragraph":
122
+ setBox(el, 400);
123
+ el.responsive.desktop.config = { ...el.responsive.desktop.config, iconSize: 12, iconTop: 5, linePaddingLeft: 23 };
124
+ el.responsive.mobile.config = { ...el.responsive.mobile.config, iconSize: 12, iconTop: 5, linePaddingLeft: 23 };
125
+ el.specials.text = "<li>List Paragraph.</li><li>List Paragraph.</li><li>List Paragraph.</li>";
126
+ break;
127
+ case "group":
128
+ seedPosition(el);
129
+ setStyle(el, "position", "absolute");
130
+ el.children = [];
131
+ break;
132
+ case "rectangle":
133
+ seedPosition(el);
134
+ setBox(el, 100, 100);
135
+ break;
136
+ case "line":
137
+ setBox(el, 236);
138
+ break;
139
+ case "image-block":
140
+ seedPosition(el);
141
+ setBox(el, 110, 80);
142
+ setStyle(el, "position", "absolute");
143
+ el.specials.imageCompression = true;
144
+ el.specials.src = imgPlaceholder(600, 400);
145
+ break;
146
+ case "button":
147
+ seedPosition(el);
148
+ setBox(el, 150, 36);
149
+ el.specials.text = "Button";
150
+ break;
151
+ case "video":
152
+ seedPosition(el);
153
+ setBox(el, 350, 200);
154
+ el.specials.imageCompression = true;
155
+ el.specials.img = imgPlaceholder(640, 360, "Video");
156
+ break;
157
+ case "gallery":
158
+ seedPosition(el);
159
+ setBox(el, 350, 400);
160
+ el.specials.media = [imgPlaceholder(600, 400, "1"), imgPlaceholder(600, 400, "2"), imgPlaceholder(600, 400, "3")];
161
+ el.children = [];
162
+ break;
163
+ case "popup":
164
+ el.properties.movable = false;
165
+ setBox(el, 400, 250);
166
+ el.children = [];
167
+ break;
168
+ case "form":
169
+ seedPosition(el);
170
+ setBox(el, 400, 250);
171
+ el.children = [];
172
+ el.specials.fb_event_type = "CompleteRegistration";
173
+ el.specials.fb_conversion_value = "10000";
174
+ el.specials.fb_tracking_currency = "VND";
175
+ el.specials.tiktok_conversion_value = "10000";
176
+ el.specials.tiktok_tracking_currency = "VND";
177
+ break;
178
+ case "input":
179
+ case "input-datetime":
180
+ case "input-file":
181
+ case "country-select":
182
+ case "checkbox":
183
+ case "address":
184
+ case "quantity_input":
185
+ case "select":
186
+ case "cart-quantity":
187
+ seedPosition(el);
188
+ setBox(el, 150, 36);
189
+ if (FIELD_TYPES.has(type))
190
+ el.specials.field_name = `${type.replace(/-/g, "_")}_${el.id}`;
191
+ break;
192
+ case "signature":
193
+ seedPosition(el);
194
+ setBox(el, 150, 100);
195
+ el.specials.field_name = `signature_${el.id}`;
196
+ break;
197
+ case "verify-code":
198
+ seedPosition(el);
199
+ setBox(el, 150, 36);
200
+ el.specials.field_name = `verify_${el.id}`;
201
+ break;
202
+ case "radio":
203
+ seedPosition(el);
204
+ setBox(el, 150);
205
+ el.children = [];
206
+ el.specials.field_name = `radio_${el.id}`;
207
+ break;
208
+ case "checkbox-group":
209
+ seedPosition(el);
210
+ el.children = [];
211
+ el.specials.field_name = `checkbox_${el.id}`;
212
+ break;
213
+ case "textarea":
214
+ seedPosition(el);
215
+ setBox(el, 150, 50);
216
+ el.specials.field_name = `textarea_${el.id}`;
217
+ break;
218
+ case "notify":
219
+ seedPosition(el);
220
+ setBox(el, 300, 62);
221
+ break;
222
+ case "countdown":
223
+ seedPosition(el);
224
+ setBox(el, 300, 80);
225
+ setStyle(el, "color", "rgba(255, 255, 255, 1)");
226
+ setStyle(el, "background", "rgba(0, 0, 0, 1)");
227
+ setStyle(el, "fontSize", 20);
228
+ el.specials = { type: "minute", duration: "60", showDay: true, showSecond: true, showText: true };
229
+ break;
230
+ case "timegroup":
231
+ seedPosition(el);
232
+ setBox(el, 240, 25);
233
+ break;
234
+ case "auto-number":
235
+ case "random-number":
236
+ seedPosition(el);
237
+ setBox(el, 60, 80);
238
+ break;
239
+ case "editor-blog":
240
+ el.responsive.mobile.styles.width = 340;
241
+ el.responsive.mobile.styles.height = 303;
242
+ el.responsive.desktop.styles.width = 800;
243
+ el.responsive.desktop.styles.height = 124;
244
+ el.responsive.mobile.styles.top = 0;
245
+ el.responsive.mobile.styles.left = 0;
246
+ el.responsive.desktop.styles.top = 0;
247
+ el.responsive.desktop.styles.left = 0;
248
+ break;
249
+ case "html-box":
250
+ seedPosition(el);
251
+ setBox(el, 280, 310);
252
+ break;
253
+ case "spin-wheel":
254
+ seedPosition(el);
255
+ setBox(el, 400, 400);
256
+ setStyle(el, "color", "rgba(255, 255, 255, 1)");
257
+ break;
258
+ case "carousel":
259
+ seedPosition(el);
260
+ el.responsive.desktop.config.slideWidth = 350;
261
+ el.responsive.mobile.config.slideWidth = 350;
262
+ setBox(el, 350, 400);
263
+ el.children = [];
264
+ break;
265
+ case "slide":
266
+ setBox(el, 350);
267
+ el.properties.movable = false;
268
+ el.children = [];
269
+ break;
270
+ case "grid":
271
+ seedPosition(el);
272
+ setBox(el, 400, 450);
273
+ el.responsive.desktop.config.column = 2;
274
+ el.responsive.desktop.config.row = 2;
275
+ el.responsive.mobile.config.column = 2;
276
+ el.responsive.mobile.config.row = 2;
277
+ el.children = [];
278
+ break;
279
+ case "grid-item":
280
+ setBox(el, 197, 222);
281
+ el.properties.movable = false;
282
+ el.children = [];
283
+ break;
284
+ case "table":
285
+ seedPosition(el);
286
+ setBox(el, 400, 210);
287
+ break;
288
+ case "cart-items":
289
+ seedPosition(el);
290
+ setBox(el, 233, 80);
291
+ break;
292
+ case "list-product":
293
+ seedPosition(el);
294
+ setBox(el, 400, 162);
295
+ el.specials.format_title = "sku";
296
+ el.specials.numerical_order = true;
297
+ break;
298
+ case "search-list-product":
299
+ seedPosition(el);
300
+ setBox(el, 400, 40);
301
+ setStyle(el, "background", "rgba(246, 4, 87, 1)");
302
+ setStyle(el, "color", "rgba(255,255,255,1)");
303
+ break;
304
+ case "group-select":
305
+ el.children = [];
306
+ break;
307
+ case "group-select-item":
308
+ el.specials.field_name = `gs_${el.id}`;
309
+ break;
310
+ case "survey":
311
+ seedPosition(el);
312
+ setBox(el, 300, undefined);
313
+ setStyle(el, "textAlign", "center");
314
+ el.specials = {
315
+ imageHeight: 100,
316
+ imageWidth: 100,
317
+ multiOption: false,
318
+ alignment: "center",
319
+ options: [
320
+ { id: randomId(), image: "", title: "Option 1", value: "value1", field_name: `sv_${el.id}_1` },
321
+ { id: randomId(), image: "", title: "Option 2", value: "value2", field_name: `sv_${el.id}_2` },
322
+ ],
323
+ type: "text-image",
324
+ };
325
+ break;
326
+ default:
327
+ // Unknown / niche type: keep a generic, still-valid skeleton.
328
+ seedPosition(el);
329
+ setBox(el, 200, 100);
330
+ if (CONTAINER_TYPES.has(type))
331
+ el.children = [];
332
+ break;
333
+ }
334
+ return el;
335
+ }
336
+ function defaultName(type) {
337
+ const names = {
338
+ section: "Section",
339
+ "dynamic_page": "Dynamic page",
340
+ "text-block": "Text",
341
+ "list-paragraph": "ListParagraph",
342
+ group: "Group",
343
+ rectangle: "Rectangle",
344
+ line: "Line",
345
+ "image-block": "Image Block",
346
+ button: "Button",
347
+ video: "Video",
348
+ gallery: "Gallery",
349
+ popup: "Popup",
350
+ form: "Form",
351
+ input: "Input",
352
+ "input-datetime": "Input datetime",
353
+ "input-file": "Upload",
354
+ "country-select": "Country select",
355
+ checkbox: "Checkbox",
356
+ "checkbox-group": "Checkbox Group",
357
+ radio: "Radio",
358
+ textarea: "Textarea",
359
+ address: "Address",
360
+ "quantity_input": "Quantity",
361
+ select: "Select",
362
+ signature: "Signature",
363
+ "verify-code": "Verify code",
364
+ notify: "Notify",
365
+ countdown: "Count Down",
366
+ timegroup: "Time Group",
367
+ "auto-number": "Auto Number",
368
+ "random-number": "Random Number",
369
+ "editor-blog": "Editor blog",
370
+ "html-box": "HTML Box",
371
+ "spin-wheel": "Spin Wheel",
372
+ carousel: "Carousel",
373
+ slide: "Slide",
374
+ grid: "Grid",
375
+ "grid-item": "GridItem",
376
+ table: "Table",
377
+ "cart-items": "CartItems",
378
+ "cart-quantity": "Cart Quantity",
379
+ "list-product": "ListProduct",
380
+ "search-list-product": "SearchListProduct",
381
+ "group-select": "Group Select",
382
+ "group-select-item": "Group Select Item",
383
+ survey: "Survey",
384
+ };
385
+ return names[type] ?? type;
386
+ }
387
+ /** Default page-level settings (subset of the ~40 real keys; covers the essentials). */
388
+ export function defaultSettings(overrides = {}) {
389
+ return {
390
+ title: "",
391
+ description: "",
392
+ keywords: "",
393
+ favicon: "",
394
+ thumbnail: "",
395
+ fontGeneral: "'Roboto', sans-serif",
396
+ width_section: { desktop: 960, mobile: 420 },
397
+ country: "84",
398
+ fb_tracking_code: "",
399
+ tiktok_script: "",
400
+ global_track_ids: [],
401
+ extra_css: "",
402
+ extra_script: "",
403
+ auto_save_draft: true,
404
+ auto_save_info_user: false,
405
+ send_info_to_thank_page: true,
406
+ ...overrides,
407
+ };
408
+ }
409
+ /**
410
+ * Build a complete, empty top-level page source matching the real editor shape:
411
+ * { page, popup, settings, options, cartConfigs }. Fill `page` with sections.
412
+ */
413
+ export function createPageSource(opts = {}) {
414
+ return {
415
+ page: [],
416
+ popup: [],
417
+ settings: defaultSettings(opts.settings ?? {}),
418
+ options: { currency: "VND", mobileOnly: opts.mobileOnly ?? false, versionID: null },
419
+ cartConfigs: {},
420
+ };
421
+ }
package/dist/index.js ADDED
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Webcake Elements MCP server (stdio).
4
+ *
5
+ * Gives Claude the knowledge to build a complete Webcake landing-page source
6
+ * JSON from a requirement: element catalog, per-element usage hints + specials,
7
+ * the full page JSON Schema, valid element skeletons, and a page validator.
8
+ *
9
+ * Tools:
10
+ * - get_generation_guide : conventions, coordinate system, event vocab, workflow
11
+ * - list_elements : catalog of all element types (by category)
12
+ * - get_element : hints + key specials + default skeleton + example for one type
13
+ * - new_element : a structurally-valid default node for a type (optionally renamed)
14
+ * - get_page_schema : the full JSON Schema of a page source
15
+ * - validate_page : structural + semantic validation of a generated page
16
+ */
17
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
+ import { z } from "zod";
20
+ import { createElement, createPageSource } from "./factory.js";
21
+ import { LIBRARY, GENERATION_GUIDE, CANVAS, CLICK_ACTIONS, HOVER_ACTIONS, EVENT_TRIGGERS, } from "./library.js";
22
+ import { validatePage, coercePage, pageSchema } from "./validate.js";
23
+ import { readConfig, buildRequestRedacted, createPage, listOrganizations, listPages, getPageSource, updatePageSource, buildUpdateRequestRedacted, } from "./webcake.js";
24
+ const ALL_TYPES = Object.keys(LIBRARY);
25
+ function text(value) {
26
+ const body = typeof value === "string" ? value : JSON.stringify(value, null, 2);
27
+ return { content: [{ type: "text", text: body }] };
28
+ }
29
+ const INSTRUCTIONS = `webcake-landing builds and edits Webcake landing pages (the editor "page_source" JSON).
30
+
31
+ RULES (follow for every request):
32
+ - INTAKE FIRST: before generating a new page, ask the user 3–6 concrete questions (goal/page type, brand + tone + language, sections in order, primary CTA + destination, form fields, colors/logo URLs, desktop+mobile or mobile-only, which organization) and confirm a short outline. Do not assume.
33
+ - Never invent prices, phone numbers, addresses, or statistics — ask or leave a placeholder.
34
+ - ALWAYS call validate_page and fix every error before create_page / update_page.
35
+ - create_page and update_page DEFAULT to dry_run=true. Show the dry-run, then only send dry_run=false after the user confirms.
36
+ - EDIT existing pages surgically: get_page → change ONLY what was asked → keep every other element, its id, and coordinates → validate_page → update_page. Never regenerate the whole tree for a small change.
37
+ - Organizations: call list_organizations and ask which to use; default to the is_default org. Endpoints are owner-scoped (only the account's own pages).
38
+
39
+ MODEL (essentials):
40
+ - Top-level: { page:[sections], popup:[popups], settings:{}, options:{currency,mobileOnly,versionID}, cartConfigs:{} }. Popups are a SEPARATE top-level array, NOT inside page.
41
+ - Element: { id, type, properties, responsive:{desktop,mobile:{config,styles}}, specials, children, runtime, events }. Absolute canvas: children carry numeric top/left/width/height (px) per breakpoint (canvas width desktop=960, mobile=420); sections own a height.
42
+ - CENTERING (the #1 layout defect — do the math, don't eyeball): to center a box compute left = round((canvas - width)/2) — 960 desktop, 420 mobile. textAlign:center only centers text inside the box, not the box itself. For a row of N items, center the whole row block (startLeft = round((canvas - (N*item + (N-1)*gap))/2)). Keep 0 ≤ left and left+width ≤ canvas on each breakpoint.
43
+ - Visible content lives in specials (text, src, field_name…), never in styles. Colors as rgba(). Animation in config.animation={name,delay,duration,repeat}. Form inputs need a unique specials.field_name (use canonical keys: full_name, phone_number, email, address, quantity).
44
+ - IMAGES: include them (hero/product, feature icons, about photo). No image API yet → set image-block specials.src to a PLACEHOLDER sized to the box: https://placehold.co/<width>x<height> (gallery.media = array of these; video.specials.img = poster). NEVER leave src empty (renders blank). Ensure text contrasts with its section background.
45
+
46
+ Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, list_organizations, create_page, list_pages, get_page, update_page.`;
47
+ const server = new McpServer({ name: "webcake-landing", version: "1.0.0" }, { instructions: INSTRUCTIONS });
48
+ // 1) Generation guide ---------------------------------------------------------
49
+ server.tool("get_generation_guide", "Read this FIRST. Conventions for building a Webcake page source: output shape, the absolute-positioning coordinate system, event vocabulary, and the recommended workflow.", async () => text({
50
+ guide: GENERATION_GUIDE,
51
+ canvas: CANVAS,
52
+ event_triggers: EVENT_TRIGGERS,
53
+ click_actions: CLICK_ACTIONS,
54
+ hover_actions: HOVER_ACTIONS,
55
+ }));
56
+ // 2) List elements ------------------------------------------------------------
57
+ server.tool("list_elements", "List every supported element type, grouped by category, with a one-line summary and whether it is a container (can hold children).", async () => {
58
+ const byCategory = {};
59
+ for (const t of ALL_TYPES) {
60
+ const d = LIBRARY[t];
61
+ (byCategory[d.category] ||= []).push({
62
+ type: d.type,
63
+ container: d.container,
64
+ summary: d.summary,
65
+ useWhen: d.useWhen,
66
+ });
67
+ }
68
+ return text({ total: ALL_TYPES.length, categories: byCategory });
69
+ });
70
+ // 3) Get element --------------------------------------------------------------
71
+ server.tool("get_element", "Get detailed usage for one element type: when to use it, its key `specials` fields, a default skeleton node, and (for common types) a filled example. Call before emitting an element of an unfamiliar type.", { type: z.string().describe("Element type, e.g. 'section', 'text-block', 'button', 'form', 'input', 'countdown'.") }, async ({ type }) => {
72
+ const doc = LIBRARY[type];
73
+ if (!doc) {
74
+ return text({
75
+ error: `Unknown element type "${type}".`,
76
+ valid_types: ALL_TYPES,
77
+ });
78
+ }
79
+ return text({
80
+ type: doc.type,
81
+ category: doc.category,
82
+ container: doc.container,
83
+ summary: doc.summary,
84
+ useWhen: doc.useWhen,
85
+ keySpecials: doc.keySpecials,
86
+ skeleton: createElement(type),
87
+ example: doc.example ?? null,
88
+ });
89
+ });
90
+ // 4) New element --------------------------------------------------------------
91
+ server.tool("new_element", "Return a structurally-valid default element node for a type (correct properties/responsive/specials/sizes), with a fresh id. Fill in specials + top/left coordinates afterwards.", {
92
+ type: z.string().describe("Element type to create."),
93
+ name: z.string().optional().describe("Optional properties.name override (layer label)."),
94
+ }, async ({ type, name }) => {
95
+ if (!LIBRARY[type]) {
96
+ return text({ error: `Unknown element type "${type}".`, valid_types: ALL_TYPES });
97
+ }
98
+ return text(createElement(type, name ? { name } : {}));
99
+ });
100
+ // 5) Page schema --------------------------------------------------------------
101
+ server.tool("get_page_schema", "Return the full JSON Schema (Draft 2020-12) of a Webcake page source object { page: [...], settings: {...} }. Use it to understand the exact structure or for your own validation.", async () => text(pageSchema));
102
+ // 6) Validate page ------------------------------------------------------------
103
+ server.tool("validate_page", "Validate a generated page source against the schema + semantic rules (unique ids, dangling event targets, children only on containers, missing field_name, top-level types). Returns errors (must fix) and warnings. ALWAYS run before returning the final page.", {
104
+ page: z
105
+ .any()
106
+ .describe("The page source object { page:[...], settings:{} } OR a JSON string of it."),
107
+ }, async ({ page }) => {
108
+ const result = validatePage(page);
109
+ return text(result);
110
+ });
111
+ // 7) New page skeleton --------------------------------------------------------
112
+ server.tool("new_page_skeleton", "Return an empty but complete top-level page source { page:[], popup:[], settings:{...defaults}, options:{...}, cartConfigs:{} } matching the real editor shape. Fill `page` with sections (and `popup` with popups), then validate_page and create_page.", { mobileOnly: z.boolean().optional().describe("true if the page renders mobile-only.") }, async ({ mobileOnly }) => text(createPageSource({ mobileOnly: mobileOnly ?? false })));
113
+ // 8) List organizations ------------------------------------------------------
114
+ server.tool("list_organizations", "List the account's Webcake organizations (id, name, is_default). The default org (type===1, usually the personal workspace) is where pages normally go. Call this BEFORE create_page, show the options to the user and ask which org to use — defaulting to the is_default one. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {}, async () => {
115
+ const { config, missing } = readConfig();
116
+ if (!config) {
117
+ return text({
118
+ ok: false,
119
+ reason: "missing_env",
120
+ missing_env: missing,
121
+ hint: "Configure WEBCAKE_API_BASE and WEBCAKE_JWT in the MCP server env.",
122
+ });
123
+ }
124
+ return text(await listOrganizations(config));
125
+ });
126
+ // 9) Create page (persist) ----------------------------------------------------
127
+ server.tool("create_page", "Persist a generated page source to the configured Webcake backend: creates a NEW page and saves the source (source-only — opens in the editor where re-saving renders it). Validates first. DEFAULTS to dry_run=true (returns the HTTP request it WOULD send, token masked). Set dry_run=false to actually create — that needs WEBCAKE_API_BASE + WEBCAKE_JWT env vars. The page lands in `organization_id` if given (call list_organizations and ask the user; default to the is_default org). Without an org the page is personal (org=null).", {
128
+ source: z
129
+ .any()
130
+ .describe("Full page source { page, popup, settings, options, cartConfigs } (object or JSON string)."),
131
+ name: z.string().optional().describe("Page name (default 'AI Page')."),
132
+ organization_id: z
133
+ .union([z.string(), z.number()])
134
+ .optional()
135
+ .describe("Organization to create the page in (from list_organizations). Omit for a personal page; falls back to WEBCAKE_ORG_ID env if set."),
136
+ dry_run: z
137
+ .boolean()
138
+ .optional()
139
+ .describe("Default TRUE — preview the request without sending. Set false to actually create."),
140
+ }, async ({ source, name, organization_id, dry_run }) => {
141
+ const pageName = name ?? "AI Page";
142
+ const isDry = dry_run !== false; // default true (safe)
143
+ const orgId = organization_id != null ? `${organization_id}` : undefined;
144
+ const result = validatePage(source);
145
+ if (!result.valid) {
146
+ return text({
147
+ created: false,
148
+ reason: "validation_failed",
149
+ errors: result.errors,
150
+ warnings: result.warnings,
151
+ hint: "Fix the errors (run validate_page) before creating.",
152
+ });
153
+ }
154
+ const parsed = coercePage(source);
155
+ const { config, missing } = readConfig();
156
+ if (isDry) {
157
+ return text({
158
+ dry_run: true,
159
+ validation: { valid: true, warnings: result.warnings, stats: result.stats },
160
+ env_ready: missing.length === 0,
161
+ missing_env: missing,
162
+ target_organization_id: orgId ?? config?.orgId ?? null,
163
+ request: config
164
+ ? buildRequestRedacted(config, pageName, parsed, orgId)
165
+ : {
166
+ note: "Set WEBCAKE_API_BASE + WEBCAKE_JWT to enable real creation. Would POST to {WEBCAKE_API_BASE}/api/v1/ai/create_page_from_source.",
167
+ },
168
+ hint: "Re-run with dry_run=false to actually create the page.",
169
+ });
170
+ }
171
+ if (!config) {
172
+ return text({
173
+ created: false,
174
+ reason: "missing_env",
175
+ missing_env: missing,
176
+ hint: "Configure WEBCAKE_API_BASE and WEBCAKE_JWT in the MCP server env, then retry.",
177
+ });
178
+ }
179
+ const outcome = await createPage(config, pageName, parsed, orgId);
180
+ return text({ created: outcome.ok, ...outcome, warnings: result.warnings });
181
+ });
182
+ // 10) List pages --------------------------------------------------------------
183
+ server.tool("list_pages", "List the pages owned by the account (id, name, organization_id, updated_at), most-recent first. Use it to let the user pick a page to edit (then get_page → modify → update_page). Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {}, async () => {
184
+ const { config, missing } = readConfig();
185
+ if (!config)
186
+ return text({ ok: false, reason: "missing_env", missing_env: missing });
187
+ return text(await listPages(config));
188
+ });
189
+ // 11) Get page (read source) --------------------------------------------------
190
+ server.tool("get_page", "Fetch an existing page's decoded source tree { page, popup, settings, options, cartConfigs } so you can EDIT it. Returns name + organization_id too. Edit the returned `source`, then validate_page and update_page. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", { page_id: z.string().describe("The page id (from list_pages or a URL).") }, async ({ page_id }) => {
191
+ const { config, missing } = readConfig();
192
+ if (!config)
193
+ return text({ ok: false, reason: "missing_env", missing_env: missing });
194
+ return text(await getPageSource(config, page_id));
195
+ });
196
+ // 12) Update page (edit existing) ---------------------------------------------
197
+ server.tool("update_page", "Overwrite an EXISTING page's source with an edited tree (source-only; re-render in the editor for preview/publish). Validates first. DEFAULTS to dry_run=true (preview the request, token masked). Set dry_run=false to actually save. Typical flow: get_page → edit the source → validate_page → update_page. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
198
+ page_id: z.string().describe("The page id to update (must be owned by the account)."),
199
+ source: z
200
+ .any()
201
+ .describe("The full edited page source { page, popup, settings, options, cartConfigs } (object or JSON string)."),
202
+ dry_run: z.boolean().optional().describe("Default TRUE — preview without sending. Set false to actually save."),
203
+ }, async ({ page_id, source, dry_run }) => {
204
+ const isDry = dry_run !== false;
205
+ const result = validatePage(source);
206
+ if (!result.valid) {
207
+ return text({
208
+ updated: false,
209
+ reason: "validation_failed",
210
+ errors: result.errors,
211
+ warnings: result.warnings,
212
+ hint: "Fix the errors (run validate_page) before updating.",
213
+ });
214
+ }
215
+ const parsed = coercePage(source);
216
+ const { config, missing } = readConfig();
217
+ if (isDry) {
218
+ return text({
219
+ dry_run: true,
220
+ page_id,
221
+ validation: { valid: true, warnings: result.warnings, stats: result.stats },
222
+ env_ready: missing.length === 0,
223
+ missing_env: missing,
224
+ request: config
225
+ ? buildUpdateRequestRedacted(config, page_id, parsed)
226
+ : { note: "Set WEBCAKE_API_BASE + WEBCAKE_JWT to enable real updates." },
227
+ hint: "Re-run with dry_run=false to actually save the edit.",
228
+ });
229
+ }
230
+ if (!config)
231
+ return text({ updated: false, reason: "missing_env", missing_env: missing });
232
+ const outcome = await updatePageSource(config, page_id, parsed);
233
+ return text({ updated: outcome.ok, ...outcome, warnings: result.warnings });
234
+ });
235
+ async function main() {
236
+ const transport = new StdioServerTransport();
237
+ await server.connect(transport);
238
+ // stderr only — stdout is the MCP channel.
239
+ console.error("[webcake-elements] MCP server ready on stdio.");
240
+ }
241
+ main().catch((err) => {
242
+ console.error("[webcake-elements] fatal:", err);
243
+ process.exit(1);
244
+ });