webcake-storefront-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,32 @@
1
+ import { z } from "zod";
2
+ export function registerOrderTools(server, api, handle) {
3
+ server.tool("list_orders", "List orders of the site (metadata only). Use get_order for full details including items", {
4
+ page: z.number().optional().describe("Page number"),
5
+ limit: z.number().optional().describe("Items per page"),
6
+ status: z.number().optional().describe("Filter by status (0=pending, 50=confirmed, 100=shipping, 150=delivered, -1=cancelled)"),
7
+ }, ({ page, limit, status }) => handle(async () => {
8
+ const res = await api.listOrders({ page, limit, status });
9
+ const orders = (res && res.data) || res || [];
10
+ if (!Array.isArray(orders))
11
+ return res;
12
+ return {
13
+ data: orders.map((o) => ({
14
+ id: o.id,
15
+ bill_full_name: o.bill_full_name,
16
+ bill_phone_number: o.bill_phone_number,
17
+ status: o.status,
18
+ payment_status: o.payment_status,
19
+ payment_method: o.payment_method,
20
+ invoice_value: o.invoice_value || o.total,
21
+ items_count: (o.order_items || o.items || []).length,
22
+ created_at: o.created_at,
23
+ updated_at: o.updated_at,
24
+ })),
25
+ total: res.total || orders.length,
26
+ };
27
+ }));
28
+ server.tool("get_order", "Get full order details by ID: customer info, items, payment, shipping, discounts, etc.", {
29
+ id: z.string().describe("Order ID"),
30
+ }, ({ id }) => handle(() => api.getOrder(id)));
31
+ server.tool("count_orders_by_status", "Get order count grouped by status. Useful for dashboard overview", {}, () => handle(() => api.countOrdersByStatus()));
32
+ }
@@ -0,0 +1,621 @@
1
+ import { z } from "zod";
2
+ import { CUSTOM_CODE_GUIDE } from "../guides.js";
3
+ import { getConfirmMode } from "./context.js";
4
+ /**
5
+ * Page source utilities.
6
+ *
7
+ * Source structure (from builder):
8
+ * { sections: [{ id, type, style, config, specials, children: [...] }] }
9
+ *
10
+ * Rendered HTML uses:
11
+ * - Sections: <section id="SECTION-1" class="x-section {custom_class}">
12
+ * - Elements: <div id="TEXT-1" class="x-element {custom_class}">
13
+ * - custom_class comes from specials.custom_class (comma-separated)
14
+ * - custom_css comes from specials.custom_css (element-scoped CSS)
15
+ * - style object generates scoped CSS via #ELEMENT-ID { ... }
16
+ */
17
+ function parseSource(sourceJson) {
18
+ try {
19
+ return typeof sourceJson === "string" ? JSON.parse(sourceJson) : sourceJson;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** Walk all nodes in source tree, call fn(node). Return false from fn to stop early */
26
+ function walkSource(source, fn) {
27
+ if (!source || !source.sections)
28
+ return;
29
+ function walk(node) {
30
+ if (!node)
31
+ return true;
32
+ if (fn(node) === false)
33
+ return false;
34
+ const children = node.children || [];
35
+ for (const child of children) {
36
+ if (walk(child) === false)
37
+ return false;
38
+ }
39
+ return true;
40
+ }
41
+ for (const section of source.sections) {
42
+ if (walk(section) === false)
43
+ return;
44
+ }
45
+ }
46
+ /** Build overview: section count, element type counts, all custom_classes */
47
+ function buildOverview(source) {
48
+ const typeCounts = {};
49
+ const customClasses = new Set();
50
+ let total = 0;
51
+ walkSource(source, (node) => {
52
+ total++;
53
+ const type = node.type || "unknown";
54
+ typeCounts[type] = (typeCounts[type] || 0) + 1;
55
+ const cc = node.specials && node.specials.custom_class;
56
+ if (cc)
57
+ cc.split(",").map((s) => s.trim()).filter(Boolean).forEach((c) => customClasses.add(c));
58
+ });
59
+ return {
60
+ sections_count: (source.sections || []).length,
61
+ total_elements: total,
62
+ element_types: typeCounts,
63
+ custom_classes: [...customClasses].sort(),
64
+ };
65
+ }
66
+ /** Build full detail for a single node — all properties except children tree */
67
+ function nodeToDetail(node) {
68
+ const entry = { id: node.id || "", type: node.type || "unknown" };
69
+ // Core properties
70
+ if (node.style && Object.keys(node.style).length)
71
+ entry.style = node.style;
72
+ if (node.config && Object.keys(node.config).length)
73
+ entry.config = node.config;
74
+ if (node.specials && Object.keys(node.specials).length)
75
+ entry.specials = node.specials;
76
+ // Events (click, submit, mouseenter, etc.)
77
+ if (node.events && node.events.length)
78
+ entry.events = node.events;
79
+ // Data bindings (product, category, blog, etc.)
80
+ if (node.bindings && node.bindings.length)
81
+ entry.bindings = node.bindings;
82
+ // Responsive breakpoint data (bp1, bp2, bp3, ...)
83
+ for (const key of Object.keys(node)) {
84
+ if (/^bp\d+$/.test(key) && node[key] && typeof node[key] === "object") {
85
+ if (!entry.responsive)
86
+ entry.responsive = {};
87
+ entry.responsive[key] = node[key];
88
+ }
89
+ }
90
+ if (node.children && node.children.length)
91
+ entry.children_count = node.children.length;
92
+ return entry;
93
+ }
94
+ /** Find a node by ID in source tree, returns reference to the node */
95
+ function findNodeById(source, elementId) {
96
+ let found = null;
97
+ walkSource(source, (node) => {
98
+ if (node.id === elementId) {
99
+ found = node;
100
+ return false;
101
+ }
102
+ });
103
+ return found;
104
+ }
105
+ /** Deep-diff two nodes — returns only changed fields with before/after */
106
+ function computeNodeDiff(beforeNode, afterNode) {
107
+ const diff = {};
108
+ const objFields = ["style", "config", "specials"];
109
+ const arrFields = ["events", "bindings"];
110
+ for (const field of objFields) {
111
+ const b = beforeNode[field] || {};
112
+ const a = afterNode[field] || {};
113
+ if (JSON.stringify(b) === JSON.stringify(a))
114
+ continue;
115
+ const fieldDiff = {};
116
+ for (const k of new Set([...Object.keys(b), ...Object.keys(a)])) {
117
+ if (JSON.stringify(b[k]) !== JSON.stringify(a[k])) {
118
+ fieldDiff[k] = { before: b[k] ?? null, after: a[k] ?? null };
119
+ }
120
+ }
121
+ if (Object.keys(fieldDiff).length)
122
+ diff[field] = fieldDiff;
123
+ }
124
+ for (const field of arrFields) {
125
+ if (JSON.stringify(beforeNode[field] || null) !== JSON.stringify(afterNode[field] || null)) {
126
+ diff[field] = { before: beforeNode[field] || null, after: afterNode[field] || null };
127
+ }
128
+ }
129
+ for (const key of new Set([...Object.keys(beforeNode), ...Object.keys(afterNode)])) {
130
+ if (!/^bp\d+$/.test(key))
131
+ continue;
132
+ if (JSON.stringify(beforeNode[key] || null) !== JSON.stringify(afterNode[key] || null)) {
133
+ if (!diff.responsive)
134
+ diff.responsive = {};
135
+ diff.responsive[key] = { before: beforeNode[key] || null, after: afterNode[key] || null };
136
+ }
137
+ }
138
+ return Object.keys(diff).length ? diff : null;
139
+ }
140
+ /** Apply updates to a node in-place (shallow merge for objects, replace for arrays) */
141
+ function applyNodeUpdates(node, updates) {
142
+ if (updates.style)
143
+ node.style = { ...(node.style || {}), ...updates.style };
144
+ if (updates.config)
145
+ node.config = { ...(node.config || {}), ...updates.config };
146
+ if (updates.specials)
147
+ node.specials = { ...(node.specials || {}), ...updates.specials };
148
+ if (updates.events !== undefined)
149
+ node.events = updates.events;
150
+ if (updates.bindings !== undefined)
151
+ node.bindings = updates.bindings;
152
+ // Responsive breakpoints (bp1, bp2, ...)
153
+ if (updates.responsive) {
154
+ for (const [bp, val] of Object.entries(updates.responsive)) {
155
+ if (/^bp\d+$/.test(bp)) {
156
+ node[bp] = node[bp] ? { style: { ...node[bp].style, ...val.style }, config: { ...node[bp].config, ...val.config } } : val;
157
+ }
158
+ }
159
+ }
160
+ }
161
+ /** Search/filter elements in source by criteria */
162
+ function searchElements(source, filters) {
163
+ const results = [];
164
+ const limit = filters.limit || 50;
165
+ walkSource(source, (node) => {
166
+ if (results.length >= limit)
167
+ return false;
168
+ // Filter by type
169
+ if (filters.type && node.type !== filters.type)
170
+ return;
171
+ // Filter by id (substring match)
172
+ if (filters.id) {
173
+ const nodeId = (node.id || "").toLowerCase();
174
+ if (!nodeId.includes(filters.id.toLowerCase()))
175
+ return;
176
+ }
177
+ // Filter by custom_class
178
+ if (filters.custom_class) {
179
+ const cc = (node.specials && node.specials.custom_class) || "";
180
+ if (!cc.toLowerCase().includes(filters.custom_class.toLowerCase()))
181
+ return;
182
+ }
183
+ // Filter by text content (substring match)
184
+ if (filters.text) {
185
+ const text = (node.specials && node.specials.text) || "";
186
+ if (!text.toLowerCase().includes(filters.text.toLowerCase()))
187
+ return;
188
+ }
189
+ // Filter: has_custom_class — only elements with custom class
190
+ if (filters.has_custom_class) {
191
+ const cc = node.specials && node.specials.custom_class;
192
+ if (!cc)
193
+ return;
194
+ }
195
+ // Filter: has_bind — only data-bound elements
196
+ if (filters.has_bind) {
197
+ if (!(node.bindings && node.bindings.length) && !(node.specials && node.specials.bind))
198
+ return;
199
+ }
200
+ // Filter: has_events — only elements with events
201
+ if (filters.has_events) {
202
+ if (!(node.events && node.events.length))
203
+ return;
204
+ }
205
+ results.push(nodeToDetail(node));
206
+ });
207
+ return results;
208
+ }
209
+ /** Cache listPages for 30s to avoid repeated API calls within a session */
210
+ let _pagesCache = null;
211
+ let _pagesCacheTime = 0;
212
+ const CACHE_TTL = 30000;
213
+ async function getCachedPages(api) {
214
+ const now = Date.now();
215
+ if (_pagesCache && now - _pagesCacheTime < CACHE_TTL)
216
+ return _pagesCache;
217
+ const res = await api.listPages();
218
+ _pagesCache = (res && res.data) || res || [];
219
+ _pagesCacheTime = now;
220
+ return _pagesCache;
221
+ }
222
+ function invalidatePageCache() {
223
+ _pagesCache = null;
224
+ _pagesCacheTime = 0;
225
+ }
226
+ async function getPageWithSource(api, pageId) {
227
+ const pages = await getCachedPages(api);
228
+ if (!Array.isArray(pages))
229
+ return { error: "Failed to load pages" };
230
+ const page = pages.find((p) => p.id === pageId);
231
+ if (!page)
232
+ return { error: "Page not found" };
233
+ const source = parseSource(page.source && page.source.source);
234
+ return { page, source };
235
+ }
236
+ export function registerPageTools(server, api, handle) {
237
+ server.tool("list_pages", "List all pages of the site (metadata only, without source)", {}, () => handle(async () => {
238
+ const pages = await getCachedPages(api);
239
+ if (!Array.isArray(pages))
240
+ return pages;
241
+ return pages.map((p) => ({
242
+ id: p.id,
243
+ name: p.name,
244
+ slug: p.slug,
245
+ type: p.type,
246
+ is_homepage: p.is_homepage,
247
+ is_build: p.is_build,
248
+ updated_at: p.updated_at,
249
+ }));
250
+ }));
251
+ server.tool("get_page_source", "Get page source overview: section count, element type counts, and all custom CSS classes. Use this first, then use search_page_elements to find specific elements", {
252
+ page_id: z.string().describe("Page ID"),
253
+ }, ({ page_id }) => handle(async () => {
254
+ const { page, source, error } = await getPageWithSource(api, page_id);
255
+ if (error)
256
+ return { error };
257
+ const overview = source ? buildOverview(source) : null;
258
+ return {
259
+ page: { id: page.id, name: page.name, slug: page.slug, type: page.type },
260
+ custom_code: (page.source && page.source.custom_code) || null,
261
+ overview,
262
+ hint: "Use search_page_elements to query specific elements by type, class, text, etc. Target via CSS: #ELEMENT-ID or .custom-class. Sections have class 'x-section', elements have 'x-element'.",
263
+ };
264
+ }));
265
+ server.tool("search_page_elements", `Search/filter elements within a page source. Returns matching elements with full detail (id, type, style, text, classes, etc.).
266
+ Examples:
267
+ - Find all buttons: type="button"
268
+ - Find elements with custom class: custom_class="hero"
269
+ - Find text containing "subscribe": text="subscribe"
270
+ - Find all data-bound elements: has_bind=true
271
+ - Find all elements with events: has_events=true
272
+ - Find all elements with custom CSS classes: has_custom_class=true`, {
273
+ page_id: z.string().describe("Page ID"),
274
+ type: z.string().optional().describe("Filter by element type (e.g. 'text', 'button', 'image', 'container', 'section', 'form', 'input')"),
275
+ id: z.string().optional().describe("Filter by element ID substring (e.g. 'TEXT', 'BUTTON-3')"),
276
+ custom_class: z.string().optional().describe("Filter by custom class substring"),
277
+ text: z.string().optional().describe("Filter by text content substring"),
278
+ has_custom_class: z.boolean().optional().describe("Only elements that have a custom class"),
279
+ has_bind: z.boolean().optional().describe("Only elements with data bindings (product, category, blog)"),
280
+ has_events: z.boolean().optional().describe("Only elements with events (click, submit, mouseenter, etc.)"),
281
+ limit: z.number().default(50).describe("Max results (default 50)"),
282
+ }, ({ page_id, ...filters }) => handle(async () => {
283
+ const { source, error } = await getPageWithSource(api, page_id);
284
+ if (error)
285
+ return { error };
286
+ if (!source)
287
+ return { error: "Page has no source" };
288
+ const results = searchElements(source, filters);
289
+ return { page_id, matched: results.length, elements: results };
290
+ }));
291
+ server.tool("create_page", "Create a new page", {
292
+ name: z.string().describe("Page name"),
293
+ slug: z.string().describe("URL slug (e.g. '/about')"),
294
+ type: z.string().optional().describe("Page type"),
295
+ is_homepage: z.boolean().default(false).describe("Set as homepage"),
296
+ }, ({ name, slug, type, is_homepage }) => handle(async () => {
297
+ const res = await api.createPage({ name, slug, type, is_homepage });
298
+ invalidatePageCache();
299
+ return res;
300
+ }));
301
+ server.tool("update_page", "Update page properties (name, slug, settings, custom code)", {
302
+ page_id: z.string().describe("Page ID"),
303
+ name: z.string().optional().describe("New name"),
304
+ slug: z.string().optional().describe("New slug"),
305
+ is_homepage: z.boolean().optional().describe("Set as homepage"),
306
+ settings: z.record(z.any()).optional().describe("Page settings"),
307
+ }, ({ page_id, ...params }) => handle(async () => {
308
+ const res = await api.updatePage(page_id, params);
309
+ invalidatePageCache();
310
+ return res;
311
+ }));
312
+ server.tool("get_site_custom_code", `Get custom code of the site (CSS/JS). Two modes:
313
+ - Default: returns ALL 4 code fields (full content)
314
+ - With field filter: returns only the specified field(s) — saves tokens when you only need CSS or JS
315
+ Add include_guide=true on first call to get the coding guide`, {
316
+ fields: z.array(z.enum(["code_before_head", "code_before_body", "code_custom_css", "code_custom_javascript"]))
317
+ .optional()
318
+ .describe("Only return specific fields (e.g. ['code_custom_css']). Omit to get all 4 fields"),
319
+ include_guide: z.boolean().default(false).describe("Include the custom code coding guide (only needed on first call)"),
320
+ }, ({ fields, include_guide }) => handle(async () => {
321
+ const allFields = ["code_before_head", "code_before_body", "code_custom_css", "code_custom_javascript"];
322
+ const selected = fields && fields.length ? fields : allFields;
323
+ const values = await Promise.all(selected.map((f) => api.getSiteSettingField(f)));
324
+ const res = {};
325
+ for (let i = 0; i < selected.length; i++) {
326
+ res[selected[i]] = values[i] || "";
327
+ }
328
+ if (!fields || !fields.length) {
329
+ res._sizes = {};
330
+ for (const f of allFields) {
331
+ res._sizes[f] = (res[f] || "").length;
332
+ }
333
+ }
334
+ if (include_guide)
335
+ res.guide = CUSTOM_CODE_GUIDE;
336
+ return res;
337
+ }));
338
+ server.tool("update_site_custom_code", `Update custom code (CSS/JS) for the entire site. Only sends fields you specify — others remain unchanged.
339
+ IMPORTANT: Before calling, you MUST read existing code with get_site_custom_code first, then show the user what will change and get explicit confirmation. NEVER update without user approval.
340
+ - code_before_head: HTML/script inserted before </head>
341
+ - code_before_body: HTML/script inserted before </body>
342
+ - code_custom_css: Custom CSS (auto-wrapped in <style>)
343
+ - code_custom_javascript: Custom JavaScript`, {
344
+ code_before_head: z.string().optional().describe("HTML/script to insert in <head>"),
345
+ code_before_body: z.string().optional().describe("HTML/script to insert before </body>"),
346
+ code_custom_css: z.string().optional().describe("Custom CSS for the site"),
347
+ code_custom_javascript: z.string().optional().describe("Custom JavaScript for the site"),
348
+ }, (codes) => {
349
+ const settings = {};
350
+ for (const [k, v] of Object.entries(codes)) {
351
+ if (v != null)
352
+ settings[k] = v;
353
+ }
354
+ return handle(async () => {
355
+ const fieldsToUpdate = Object.keys(settings);
356
+ if (fieldsToUpdate.length === 0)
357
+ return { error: "No fields specified" };
358
+ // Safeguard: read existing values and compare before overwriting
359
+ const existingValues = await Promise.all(fieldsToUpdate.map((f) => api.getSiteSettingField(f)));
360
+ const comparison = {};
361
+ for (let i = 0; i < fieldsToUpdate.length; i++) {
362
+ const field = fieldsToUpdate[i];
363
+ const existing = existingValues[i] || "";
364
+ const newVal = settings[field] || "";
365
+ comparison[field] = { existing_length: existing.length, new_length: newVal.length };
366
+ // Block if existing content is substantial and new content is much smaller (likely data loss)
367
+ if (existing.length > 100 && newVal.length < existing.length * 0.5) {
368
+ return {
369
+ error: `BLOCKED: Field "${field}" would shrink from ${existing.length} to ${newVal.length} chars (${Math.round((newVal.length / existing.length) * 100)}% of original). This may indicate data loss. Read existing content with get_site_custom_code first, then merge your changes.`,
370
+ existing_preview: existing.substring(0, 500),
371
+ comparison,
372
+ };
373
+ }
374
+ }
375
+ await api.updateSiteSettings(settings);
376
+ return { success: true, comparison };
377
+ });
378
+ });
379
+ server.tool("append_site_custom_code", `Append or prepend code to a custom code field WITHOUT reading the existing content first.
380
+ Use this when you need to ADD new CSS rules, JS code, or script tags — no need to read first.
381
+ For full rewrites, use update_site_custom_code instead.`, {
382
+ field: z.enum(["code_before_head", "code_before_body", "code_custom_css", "code_custom_javascript"])
383
+ .describe("Which code field to modify"),
384
+ code: z.string().describe("Code to add"),
385
+ position: z.enum(["append", "prepend"]).default("append").describe("Add to end (append) or beginning (prepend)"),
386
+ }, ({ field, code, position }) => handle(async () => {
387
+ const existing = await api.getSiteSettingField(field);
388
+ let content = existing || "";
389
+ if (position === "prepend") {
390
+ content = code.trimEnd() + "\n\n" + content.trimStart();
391
+ }
392
+ else {
393
+ content = content.trimEnd() + "\n\n" + code.trimEnd() + "\n";
394
+ }
395
+ await api.updateSiteSettings({ [field]: content });
396
+ return { success: true, field, position, new_length: content.length };
397
+ }));
398
+ server.tool("delete_page", "Delete a page", {
399
+ page_id: z.string().describe("Page ID to delete"),
400
+ }, ({ page_id }) => handle(async () => {
401
+ const res = await api.deletePage({ page_id });
402
+ invalidatePageCache();
403
+ return res;
404
+ }));
405
+ server.tool("get_page_versions", "View version history of a page", {
406
+ page_id: z.string().describe("Page ID"),
407
+ }, ({ page_id }) => handle(() => api.getPageVersions(page_id)));
408
+ server.tool("list_page_contents", "List multi-language contents of a page", {
409
+ page_id: z.string().optional().describe("Filter by Page ID"),
410
+ }, ({ page_id }) => handle(() => api.listPageContents({ page_id })));
411
+ server.tool("update_page_content", `Create/update page content for a specific language.
412
+ IMPORTANT: Before calling, you MUST read existing content with list_page_contents first, then show the user what will change and get explicit confirmation. NEVER update without user approval.`, {
413
+ page_id: z.string().describe("Page ID"),
414
+ language_code: z.string().describe("Language code (e.g. 'en', 'vi')"),
415
+ content: z.record(z.any()).describe("Page content"),
416
+ meta_tags: z.array(z.record(z.any())).optional().describe("SEO meta tags"),
417
+ }, ({ page_id, language_code, content, meta_tags }) => handle(async () => {
418
+ // Safeguard: read existing content and block if new content is suspiciously smaller
419
+ const existingRes = await api.listPageContents({ page_id });
420
+ const existingContents = (existingRes && existingRes.data) || existingRes || [];
421
+ if (Array.isArray(existingContents)) {
422
+ const existing = existingContents.find((c) => c.language_code === language_code);
423
+ if (existing && existing.content) {
424
+ const existingStr = JSON.stringify(existing.content);
425
+ const newStr = JSON.stringify(content);
426
+ if (existingStr.length > 200 && newStr.length < existingStr.length * 0.5) {
427
+ return {
428
+ error: `BLOCKED: New content (${newStr.length} chars) is much smaller than existing (${existingStr.length} chars) — ${Math.round((newStr.length / existingStr.length) * 100)}% of original. This may indicate fabricated data or data loss. Read existing content with list_page_contents first, then merge your changes.`,
429
+ existing_length: existingStr.length,
430
+ new_length: newStr.length,
431
+ };
432
+ }
433
+ }
434
+ }
435
+ return api.updatePageContent({ page_id, language_code, content, meta_tags });
436
+ }));
437
+ server.tool("list_global_sections", "List reusable global sections", {}, () => handle(() => api.listGlobalSections()));
438
+ // ── Element interaction tools ──
439
+ server.tool("get_page_element", "Get full detail of a single element by its ID (e.g. 'TEXT-3', 'BUTTON-1', 'SECTION-2'). Returns style, config, specials, events, bindings, responsive, and children IDs", {
440
+ page_id: z.string().describe("Page ID"),
441
+ element_id: z.string().describe("Element ID (e.g. 'TEXT-3', 'BUTTON-1')"),
442
+ }, ({ page_id, element_id }) => handle(async () => {
443
+ const { source, error } = await getPageWithSource(api, page_id);
444
+ if (error)
445
+ return { error };
446
+ if (!source)
447
+ return { error: "Page has no source" };
448
+ const node = findNodeById(source, element_id);
449
+ if (!node)
450
+ return { error: `Element "${element_id}" not found` };
451
+ const detail = nodeToDetail(node);
452
+ if (node.children && node.children.length) {
453
+ detail.children = node.children.map((c) => ({ id: c.id, type: c.type }));
454
+ }
455
+ return detail;
456
+ }));
457
+ server.tool("update_page_element", `Update properties of a specific element in page source. Two-step process:
458
+ STEP 1: Call with dry_run=true (default) → returns diff of what will change.
459
+ STEP 2: Show the diff to the user and ask for confirmation. NEVER proceed without explicit user approval.
460
+ STEP 3: Only after user confirms, call again with dry_run=false to apply.
461
+ IMPORTANT: You MUST show the diff to the user and get explicit "yes/ok/confirm" before calling with dry_run=false. Skipping confirmation risks data loss.
462
+ Merge rules: style/config/specials = shallow merge, events/bindings = replace array, responsive = merge by bp key.`, {
463
+ page_id: z.string().describe("Page ID"),
464
+ element_id: z.string().describe("Element ID to update (e.g. 'TEXT-3', 'BUTTON-1')"),
465
+ dry_run: z.boolean().optional().describe("Preview only (true) or apply changes (false). Defaults to confirm_mode setting. Use toggle_confirm_mode to change default."),
466
+ style: z.record(z.any()).optional().describe("CSS style properties to merge (e.g. {color: '#fff', 'font-size': '16px'})"),
467
+ config: z.record(z.any()).optional().describe("Config properties to merge"),
468
+ specials: z.record(z.any()).optional().describe("Specials to merge (text, custom_class, custom_css, etc.)"),
469
+ events: z.array(z.record(z.any())).optional().describe("Complete events array (replaces existing)"),
470
+ bindings: z.array(z.record(z.any())).optional().describe("Complete bindings array (replaces existing)"),
471
+ responsive: z.record(z.any()).optional().describe("Responsive breakpoint overrides (e.g. {bp1: {style: {...}, config: {...}}})"),
472
+ }, ({ page_id, element_id, dry_run: dryRunParam, ...updates }) => handle(async () => {
473
+ const dry_run = dryRunParam !== undefined ? dryRunParam : getConfirmMode() === "always_confirm";
474
+ invalidatePageCache();
475
+ const { page, source, error } = await getPageWithSource(api, page_id);
476
+ if (error)
477
+ return { error };
478
+ if (!source)
479
+ return { error: "Page has no source" };
480
+ const node = findNodeById(source, element_id);
481
+ if (!node)
482
+ return { error: `Element "${element_id}" not found` };
483
+ const beforeNode = JSON.parse(JSON.stringify(node));
484
+ applyNodeUpdates(node, updates);
485
+ const diff = computeNodeDiff(beforeNode, node);
486
+ if (!diff)
487
+ return { info: "No changes detected", element_id };
488
+ if (dry_run) {
489
+ return {
490
+ dry_run: true,
491
+ element_id,
492
+ diff,
493
+ hint: "Review the changes above. Call again with dry_run=false to apply.",
494
+ };
495
+ }
496
+ // Actual save — send source as OBJECT (not string) because backend does Jason.encode!
497
+ const newLen = JSON.stringify(source).length;
498
+ const existingLen = JSON.stringify(parseSource(page.source && page.source.source)).length;
499
+ if (existingLen > 200 && newLen < existingLen * 0.5) {
500
+ return {
501
+ error: `BLOCKED: Page source would shrink from ${existingLen} to ${newLen} chars. This indicates data loss.`,
502
+ existing_length: existingLen,
503
+ new_length: newLen,
504
+ };
505
+ }
506
+ const res = await api.updatePageSource(page_id, { source });
507
+ invalidatePageCache();
508
+ const saved = res && res.data;
509
+ if (!saved)
510
+ return { error: "Backend returned empty response — update may not have persisted", sent_length: newLen };
511
+ return { success: true, element_id, diff, page_source_id: saved.id, source_length: newLen };
512
+ }));
513
+ server.tool("update_page_elements", `Batch update multiple elements in one page. Two-step process:
514
+ STEP 1: Call with dry_run=true (default) → returns per-element diff.
515
+ STEP 2: Show all diffs to the user and ask for confirmation. NEVER proceed without explicit user approval.
516
+ STEP 3: Only after user confirms, call again with dry_run=false to apply.
517
+ IMPORTANT: You MUST show the diff to the user and get explicit "yes/ok/confirm" before calling with dry_run=false. Skipping confirmation risks data loss.
518
+ Same merge rules: style/config/specials = shallow merge, events/bindings = replace.`, {
519
+ page_id: z.string().describe("Page ID"),
520
+ dry_run: z.boolean().optional().describe("Preview only (true) or apply changes (false). Defaults to confirm_mode setting."),
521
+ updates: z.array(z.object({
522
+ element_id: z.string().describe("Element ID"),
523
+ style: z.record(z.any()).optional(),
524
+ config: z.record(z.any()).optional(),
525
+ specials: z.record(z.any()).optional(),
526
+ events: z.array(z.record(z.any())).optional(),
527
+ bindings: z.array(z.record(z.any())).optional(),
528
+ responsive: z.record(z.any()).optional(),
529
+ })).describe("Array of element updates"),
530
+ }, ({ page_id, dry_run: dryRunParam, updates: elementUpdates }) => handle(async () => {
531
+ const dry_run = dryRunParam !== undefined ? dryRunParam : getConfirmMode() === "always_confirm";
532
+ invalidatePageCache();
533
+ const { page, source, error } = await getPageWithSource(api, page_id);
534
+ if (error)
535
+ return { error };
536
+ if (!source)
537
+ return { error: "Page has no source" };
538
+ const results = [];
539
+ for (const upd of elementUpdates) {
540
+ const node = findNodeById(source, upd.element_id);
541
+ if (!node) {
542
+ results.push({ element_id: upd.element_id, error: "Not found" });
543
+ continue;
544
+ }
545
+ const beforeNode = JSON.parse(JSON.stringify(node));
546
+ const { element_id, ...updates } = upd;
547
+ applyNodeUpdates(node, updates);
548
+ const diff = computeNodeDiff(beforeNode, node);
549
+ results.push({ element_id, diff: diff || "no changes" });
550
+ }
551
+ if (dry_run) {
552
+ return {
553
+ dry_run: true,
554
+ elements: results,
555
+ hint: "Review the changes above. Call again with dry_run=false to apply.",
556
+ };
557
+ }
558
+ // Actual save — send source as OBJECT (not string) because backend does Jason.encode!
559
+ const newLen = JSON.stringify(source).length;
560
+ const existingLen = JSON.stringify(parseSource(page.source && page.source.source)).length;
561
+ if (existingLen > 200 && newLen < existingLen * 0.5) {
562
+ return {
563
+ error: `BLOCKED: Page source would shrink from ${existingLen} to ${newLen} chars. This indicates data loss.`,
564
+ existing_length: existingLen,
565
+ new_length: newLen,
566
+ };
567
+ }
568
+ const res = await api.updatePageSource(page_id, { source });
569
+ invalidatePageCache();
570
+ const saved = res && res.data;
571
+ if (!saved)
572
+ return { error: "Backend returned empty response — update may not have persisted", sent_length: newLen };
573
+ return { success: true, elements: results, page_source_id: saved.id, source_length: newLen };
574
+ }));
575
+ server.tool("update_page_source", `Directly update the full page source JSON.
576
+ IMPORTANT: Before calling this tool, you MUST:
577
+ 1. Read existing source with get_page_source first
578
+ 2. Show the user what will change and get explicit confirmation
579
+ 3. NEVER call without user approval — this replaces the ENTIRE page source
580
+ Safeguarded: blocks if new source is <50% of existing size.`, {
581
+ page_id: z.string().describe("Page ID"),
582
+ source: z.any().describe("Full page source object (sections tree) or JSON string"),
583
+ custom_code: z.string().optional().describe("Custom code (CSS/JS) for this page"),
584
+ }, ({ page_id, source, custom_code }) => handle(async () => {
585
+ const body = {};
586
+ if (source != null) {
587
+ // Ensure source is an object — backend does Jason.encode! so we must send object, not string
588
+ body.source = typeof source === "string" ? JSON.parse(source) : source;
589
+ }
590
+ if (custom_code != null) {
591
+ body.custom_code = custom_code;
592
+ }
593
+ // Safeguard: force fresh read and block if new source is suspiciously smaller
594
+ if (body.source) {
595
+ invalidatePageCache();
596
+ const { source: existingSource, error } = await getPageWithSource(api, page_id);
597
+ if (!error && existingSource) {
598
+ const existingLen = JSON.stringify(existingSource).length;
599
+ const newLen = JSON.stringify(body.source).length;
600
+ if (existingLen > 200 && newLen < existingLen * 0.5) {
601
+ return {
602
+ error: `BLOCKED: New source (${newLen} chars) is much smaller than existing (${existingLen} chars) — ${Math.round((newLen / existingLen) * 100)}% of original. This may indicate fabricated data or data loss. Read the page source with get_page_source/get_page_element first, then apply changes.`,
603
+ existing_length: existingLen,
604
+ new_length: newLen,
605
+ };
606
+ }
607
+ }
608
+ }
609
+ const res = await api.updatePageSource(page_id, body);
610
+ invalidatePageCache();
611
+ const saved = res && res.data;
612
+ if (!saved)
613
+ return { error: "Backend returned empty response — update may not have persisted" };
614
+ return {
615
+ success: true,
616
+ page_source_id: saved.id,
617
+ page_id: saved.page_id,
618
+ source_length: (saved.source || "").length,
619
+ };
620
+ }));
621
+ }