mcp-magento-cms 1.0.4 → 1.2.0

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/src/index.js CHANGED
@@ -5,8 +5,8 @@
5
5
  * (standard MCP stdio transport, used by Cursor); auto-switches to
6
6
  * Content-Length framing if the client sends framed messages.
7
7
  *
8
- * Tools: magento_health, get/create/update cms page & block
9
- * Page Builder: Row → Text
8
+ * Tools: magento_health, compose/validate/repair Page Builder, CMS CRUD
9
+ * Page Builder: all Magento_PageBuilder native content types via compose_pagebuilder
10
10
  *
11
11
  * Env: MAGENTO_BASE_URL, MAGENTO_ACCESS_TOKEN (or tools/mcp-magento-cms/.env)
12
12
  */
@@ -16,17 +16,33 @@ import path from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import {
18
18
  composeFromFields,
19
+ composeDocument,
19
20
  generateIdentifier,
20
21
  validatePageBuilderHtml,
22
+ PAGEBUILDER_ELEMENT_TYPES,
23
+ describeElements,
21
24
  } from './pageBuilder.js';
25
+ import { validatePageBuilderContent } from './pageBuilderValidate.js';
26
+ import { repairPageBuilderContent } from './pageBuilderRepair.js';
27
+ import {
28
+ PAGEBUILDER_RULES_SUMMARY,
29
+ CMS_THEME_CONVENTIONS_SUMMARY,
30
+ SHARED_RULES_SUMMARY,
31
+ SHARED_RULES_DETAIL,
32
+ } from './pageBuilderRules.js';
33
+ import { loadProjectRules } from './projectRules.js';
22
34
  import { MagentoClient, loadDotEnv } from './magentoClient.js';
35
+ import {
36
+ assignCmsStores,
37
+ resolveStoreIds,
38
+ } from './assignStores.js';
23
39
 
24
40
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
41
  loadDotEnv();
26
42
 
27
43
  const SERVER_INFO = {
28
44
  name: 'magento-cms',
29
- version: '1.0.0',
45
+ version: '1.2.0',
30
46
  };
31
47
 
32
48
  // ---------------------------------------------------------------------------
@@ -86,21 +102,73 @@ function client() {
86
102
  return MagentoClient.fromEnv();
87
103
  }
88
104
 
89
- function summarizeEntity(entity, type, editUrl) {
105
+ /** Magento 2.4.8 cmsPage REST returns `active`; older docs use `is_active`. */
106
+ function entityIsActive(entity) {
107
+ if (entity?.active !== undefined && entity?.active !== null) {
108
+ return entity.active;
109
+ }
110
+ if (entity?.is_active !== undefined && entity?.is_active !== null) {
111
+ return entity.is_active;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function summarizeEntity(entity, type, editUrl, extras = {}) {
117
+ const content = String(entity?.content || '');
90
118
  return {
91
119
  type,
92
120
  id: entity?.id ?? entity?.page_id ?? entity?.block_id ?? null,
93
121
  title: entity?.title ?? null,
94
122
  identifier: entity?.identifier ?? null,
95
- is_active: entity?.is_active ?? null,
96
- content_preview: String(entity?.content || '').slice(0, 280),
97
- has_pagebuilder:
98
- typeof entity?.content === 'string' &&
99
- entity.content.includes('data-content-type="row"'),
123
+ is_active: entityIsActive(entity),
124
+ content_preview: content.slice(0, 280),
125
+ content_length: content.length,
126
+ has_pagebuilder: content.includes('data-content-type="row"'),
100
127
  admin_edit_url: editUrl,
128
+ ...extras,
101
129
  };
102
130
  }
103
131
 
132
+
133
+ /**
134
+ * Resolve CMS content from args:
135
+ * 1) sections[] → composeDocument (full Page Builder tree)
136
+ * 2) html → raw master-format HTML
137
+ * 3) content (+headline) → simple Row→Text
138
+ */
139
+ function resolvePageBuilderContent(args = {}) {
140
+ if (Array.isArray(args.sections) && args.sections.length) {
141
+ const doc = composeDocument(args.sections);
142
+ return {
143
+ html: doc.html,
144
+ mode: 'sections',
145
+ used_types: doc.used_types,
146
+ allow_full_bleed: doc.allow_full_bleed,
147
+ };
148
+ }
149
+ if (args.html != null && String(args.html).trim()) {
150
+ return {
151
+ html: String(args.html),
152
+ mode: 'html',
153
+ used_types: [],
154
+ allow_full_bleed: args.allow_full_bleed === true,
155
+ };
156
+ }
157
+ if (args.content != null && String(args.content).length) {
158
+ return {
159
+ html: composeFromFields({
160
+ content: args.content,
161
+ headline: args.headline,
162
+ subheadline: args.subheadline,
163
+ }),
164
+ mode: 'simple_text',
165
+ used_types: ['row', 'text'],
166
+ allow_full_bleed: false,
167
+ };
168
+ }
169
+ return null;
170
+ }
171
+
104
172
  const TOOLS = [
105
173
  {
106
174
  name: 'magento_health',
@@ -108,152 +176,776 @@ const TOOLS = [
108
176
  'Check Magento REST connectivity and Integration token (reads CMS pages list).',
109
177
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
110
178
  },
179
+ {
180
+ name: 'pagebuilder_rules',
181
+ description:
182
+ 'Return Page Builder hard rules plus optional store project rules (media paths, theme tokens) from MAGENTO_CMS_PROJECT_RULES. Read before editing CMS HTML or media.',
183
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
184
+ },
185
+ {
186
+ name: 'list_section_types',
187
+ description:
188
+ 'List supported Page Builder composers and raw-HTML tools for CMS pages/blocks.',
189
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
190
+ },
191
+ {
192
+ name: 'search_cms',
193
+ description:
194
+ 'Search CMS pages and/or blocks by identifier (exact or like).',
195
+ inputSchema: {
196
+ type: 'object',
197
+ properties: {
198
+ identifier: { type: 'string', description: 'URL key / identifier' },
199
+ type: {
200
+ type: 'string',
201
+ description: 'page | block | both (default both)',
202
+ },
203
+ condition_type: {
204
+ type: 'string',
205
+ description: 'eq (default) or like',
206
+ },
207
+ },
208
+ required: ['identifier'],
209
+ },
210
+ },
111
211
  {
112
212
  name: 'get_cms_page',
113
- description: 'Get a Magento CMS page by page_id (numeric id).',
213
+ description:
214
+ 'Get a Magento CMS page by page_id. Optionally include full content and Page Builder validation.',
114
215
  inputSchema: {
115
216
  type: 'object',
116
217
  properties: {
117
- page_id: { type: 'number', description: 'CMS page ID, e.g. 8' },
218
+ page_id: { type: 'number', description: 'CMS page ID, e.g. 124' },
219
+ include_content: {
220
+ type: 'boolean',
221
+ description: 'If true, include full Page Builder HTML',
222
+ },
223
+ validate: {
224
+ type: 'boolean',
225
+ description: 'If true, run Page Builder stage-safety validation',
226
+ },
118
227
  },
119
228
  required: ['page_id'],
120
229
  },
121
230
  },
122
231
  {
232
+ name: 'get_cms_block',
233
+ description:
234
+ 'Get a Magento CMS block by block_id. Optionally include full content and validation.',
235
+ inputSchema: {
236
+ type: 'object',
237
+ properties: {
238
+ block_id: { type: 'number' },
239
+ include_content: { type: 'boolean' },
240
+ validate: { type: 'boolean' },
241
+ },
242
+ required: ['block_id'],
243
+ },
244
+ },
245
+ {
246
+ name: 'validate_pagebuilder',
247
+ description:
248
+ 'Validate Page Builder HTML for admin stage safety (orphan styles, full-bleed, missing inners/widths). Pass html, or page_id / block_id to load from Magento. Use allow_full_bleed=true for intentional edge-to-edge heroes.',
249
+ inputSchema: {
250
+ type: 'object',
251
+ properties: {
252
+ html: { type: 'string', description: 'Raw Page Builder HTML' },
253
+ page_id: { type: 'number' },
254
+ block_id: { type: 'number' },
255
+ allow_full_bleed: {
256
+ type: 'boolean',
257
+ description: 'Treat full-bleed as warning instead of error (default false)',
258
+ },
259
+ },
260
+ },
261
+ },
262
+ {
263
+ name: 'repair_pagebuilder',
264
+ description:
265
+ 'Repair Page Builder HTML so admin stage can open: remove orphan styles, fix full-bleed→full-width with inner, add column widths, strip SVG. Pass html to preview, or page_id/block_id with apply=true to save.',
266
+ inputSchema: {
267
+ type: 'object',
268
+ properties: {
269
+ html: { type: 'string' },
270
+ page_id: { type: 'number' },
271
+ block_id: { type: 'number' },
272
+ apply: {
273
+ type: 'boolean',
274
+ description: 'If true and page_id/block_id set, save repaired HTML to Magento',
275
+ },
276
+ strip_svg: { type: 'boolean', description: 'Default true' },
277
+ fix_full_bleed: { type: 'boolean', description: 'Default true' },
278
+ },
279
+ },
280
+ },
281
+ {
282
+ name: 'set_pagebuilder_html',
283
+ description:
284
+ 'Save RAW Page Builder HTML to a CMS page or block WITHOUT rebuilding as simple_text. Use for complex layouts (Banner+button, columns, full-bleed hero). Validates before save unless force=true. For intentional full-bleed heroes set allow_full_bleed=true (and repair_first keeps bleed).',
285
+ inputSchema: {
286
+ type: 'object',
287
+ properties: {
288
+ page_id: { type: 'number' },
289
+ block_id: { type: 'number' },
290
+ html: { type: 'string', description: 'Full Page Builder master-format HTML' },
291
+ force: {
292
+ type: 'boolean',
293
+ description: 'Save even if validation reports errors (default false)',
294
+ },
295
+ repair_first: {
296
+ type: 'boolean',
297
+ description: 'Run repair_pagebuilder before save (default true)',
298
+ },
299
+ allow_full_bleed: {
300
+ type: 'boolean',
301
+ description:
302
+ 'When true, full-bleed rows are warnings not errors, and repair will NOT convert them to full-width (default false)',
303
+ },
304
+ },
305
+ required: ['html'],
306
+ },
307
+ },
308
+ {
309
+ name: 'compose_pagebuilder',
310
+ description:
311
+ 'Compose Magento Page Builder master-format HTML from a structured sections[] tree covering ALL native content types (row, columns, text, heading, image, buttons, banner, html, divider, video, map, tabs, slider, products, block). Does not save unless page_id/block_id provided. Prefer this over project PHP composers.',
312
+ inputSchema: {
313
+ type: 'object',
314
+ properties: {
315
+ sections: {
316
+ type: 'array',
317
+ description:
318
+ 'Tree of {type, ...props, children?}. Root items are usually row. See list_section_types.',
319
+ },
320
+ page_id: {
321
+ type: 'number',
322
+ description: 'Optional: save composed HTML to this CMS page',
323
+ },
324
+ block_id: {
325
+ type: 'number',
326
+ description: 'Optional: save composed HTML to this CMS block',
327
+ },
328
+ validate: {
329
+ type: 'boolean',
330
+ description: 'Validate stage-safety (default true)',
331
+ },
332
+ repair_first: {
333
+ type: 'boolean',
334
+ description: 'When saving, run repair first (default true)',
335
+ },
336
+ allow_full_bleed: {
337
+ type: 'boolean',
338
+ description:
339
+ 'Allow full-bleed rows on validate/save. Auto-true if sections include full-bleed row unless set false.',
340
+ },
341
+ force: {
342
+ type: 'boolean',
343
+ description: 'Save even if validation fails (default false)',
344
+ },
345
+ },
346
+ required: ['sections'],
347
+ },
348
+ },
349
+ {
123
350
  name: 'create_cms_page',
124
351
  description:
125
- 'Create a Magento CMS page with Page Builder content (Row → Text) from simple text.',
352
+ 'Create a Magento CMS page. Pass sections[] for full Page Builder trees, html for raw master HTML, or content for simple Row→Text. Defaults: page_layout cms-full-width (Page — Full Width), All Store Views (store_id 0). Pass page_layout / store_ids only when the user asks for something different.',
126
353
  inputSchema: {
127
354
  type: 'object',
128
355
  properties: {
129
356
  title: { type: 'string', description: 'Page title' },
357
+ sections: {
358
+ type: 'array',
359
+ description: 'Page Builder element tree (preferred for complex pages)',
360
+ },
361
+ html: {
362
+ type: 'string',
363
+ description: 'Raw Page Builder master-format HTML',
364
+ },
130
365
  content: {
131
366
  type: 'string',
132
- description: 'Plain text or simple HTML body',
367
+ description: 'Plain text or simple HTML → Row→Text (legacy)',
133
368
  },
134
369
  identifier: {
135
370
  type: 'string',
136
371
  description: 'URL key; auto from title if omitted',
137
372
  },
138
- headline: { type: 'string', description: 'Optional H2 above body' },
373
+ headline: { type: 'string', description: 'Optional H2 when using content' },
139
374
  is_active: { type: 'boolean', description: 'Default true' },
140
- page_layout: { type: 'string', description: 'Default 1column' },
375
+ page_layout: { type: 'string', description: 'Default cms-full-width (Page — Full Width); pass page_layout only when the user specifies a different layout' },
376
+ allow_full_bleed: { type: 'boolean' },
377
+ store_ids: {
378
+ type: 'array',
379
+ description:
380
+ 'Store view IDs to assign. Default [0] = All Store Views. Pass specific IDs only when the user asks for particular store(s).',
381
+ items: { type: 'number' },
382
+ },
141
383
  },
142
- required: ['title', 'content'],
384
+ required: ['title'],
143
385
  },
144
386
  },
145
387
  {
146
388
  name: 'update_cms_page',
147
389
  description:
148
- 'Update an existing Magento CMS page by page_id. Rebuilds Page Builder Row → Text when content is provided.',
390
+ 'Update CMS page metadata and/or content. Prefer sections[] or html for complex layouts (same as compose_pagebuilder / set_pagebuilder_html). content alone still rebuilds as Row→Text.',
149
391
  inputSchema: {
150
392
  type: 'object',
151
393
  properties: {
152
394
  page_id: { type: 'number', description: 'CMS page ID to update' },
153
395
  title: { type: 'string' },
396
+ sections: { type: 'array' },
397
+ html: { type: 'string' },
154
398
  content: { type: 'string' },
155
399
  headline: { type: 'string' },
156
400
  identifier: { type: 'string' },
157
401
  is_active: { type: 'boolean' },
158
- page_layout: { type: 'string' },
402
+ page_layout: { type: 'string', description: 'Omit to keep existing; on create default is cms-full-width — only set when the user asks for a different layout' },
403
+ allow_full_bleed: { type: 'boolean' },
404
+ store_ids: {
405
+ type: 'array',
406
+ description:
407
+ 'Optional store view IDs. Omit to leave assignment unchanged; pass [0] for All Store Views or specific IDs when the user asks.',
408
+ items: { type: 'number' },
409
+ },
159
410
  },
160
411
  required: ['page_id'],
161
412
  },
162
413
  },
163
- {
164
- name: 'get_cms_block',
165
- description: 'Get a Magento CMS block by block_id.',
166
- inputSchema: {
167
- type: 'object',
168
- properties: {
169
- block_id: { type: 'number' },
170
- },
171
- required: ['block_id'],
172
- },
173
- },
174
414
  {
175
415
  name: 'create_cms_block',
176
416
  description:
177
- 'Create a Magento CMS block with Page Builder Row → Text from simple text.',
417
+ 'Create a Magento CMS block. Pass sections[] (full PB tree), html, or content (Row→Text).',
178
418
  inputSchema: {
179
419
  type: 'object',
180
420
  properties: {
181
421
  title: { type: 'string' },
422
+ sections: { type: 'array' },
423
+ html: { type: 'string' },
182
424
  content: { type: 'string' },
183
425
  identifier: { type: 'string' },
184
426
  headline: { type: 'string' },
185
427
  is_active: { type: 'boolean' },
428
+ allow_full_bleed: { type: 'boolean' },
429
+ store_ids: {
430
+ type: 'array',
431
+ description:
432
+ 'Store view IDs to assign. Default [0] = All Store Views. Pass specific IDs only when the user asks for particular store(s).',
433
+ items: { type: 'number' },
434
+ },
186
435
  },
187
- required: ['title', 'content'],
436
+ required: ['title'],
188
437
  },
189
438
  },
190
439
  {
191
440
  name: 'update_cms_block',
192
441
  description:
193
- 'Update Magento CMS block by block_id. Rebuilds Page Builder when content is set.',
442
+ 'Update CMS block metadata and/or content via sections[], html, or content (Row→Text).',
194
443
  inputSchema: {
195
444
  type: 'object',
196
445
  properties: {
197
446
  block_id: { type: 'number' },
198
447
  title: { type: 'string' },
448
+ sections: { type: 'array' },
449
+ html: { type: 'string' },
199
450
  content: { type: 'string' },
200
451
  headline: { type: 'string' },
201
452
  identifier: { type: 'string' },
202
453
  is_active: { type: 'boolean' },
454
+ allow_full_bleed: { type: 'boolean' },
455
+ store_ids: {
456
+ type: 'array',
457
+ description:
458
+ 'Optional store view IDs. Omit to leave assignment unchanged; pass [0] for All Store Views or specific IDs when the user asks.',
459
+ items: { type: 'number' },
460
+ },
203
461
  },
204
462
  required: ['block_id'],
205
463
  },
206
464
  },
207
- {
208
- name: 'list_section_types',
209
- description:
210
- 'List supported Page Builder section types (MVP: simple_text).',
211
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
212
- },
213
465
  ];
214
466
 
467
+ async function loadHtmlFromArgs(magento, args) {
468
+ if (args.html !== undefined && String(args.html || '').length) {
469
+ return { html: String(args.html), source: 'html' };
470
+ }
471
+ if (args.page_id) {
472
+ const page = await magento.getPage(args.page_id);
473
+ return {
474
+ html: String(page.content || ''),
475
+ source: 'page',
476
+ entity: page,
477
+ };
478
+ }
479
+ if (args.block_id) {
480
+ const block = await magento.getBlock(args.block_id);
481
+ return {
482
+ html: String(block.content || ''),
483
+ source: 'block',
484
+ entity: block,
485
+ };
486
+ }
487
+ throw new Error('Provide html, page_id, or block_id');
488
+ }
489
+
215
490
  async function callTool(name, args = {}) {
216
491
  switch (name) {
217
492
  case 'magento_health': {
218
493
  const magento = client();
219
494
  const result = await magento.health();
495
+ const project = loadProjectRules();
220
496
  return toolOk({
221
497
  ...result,
222
- message: 'Magento REST OK. You can create/update CMS pages and blocks.',
498
+ message:
499
+ 'Magento REST OK. CMS content: use this MCP only (no PHP compose/deploy). Call pagebuilder_rules first, then compose_pagebuilder / create_* / set_pagebuilder_html + validate/repair.',
500
+ pagebuilder_rules: PAGEBUILDER_RULES_SUMMARY,
501
+ cms_theme_conventions: CMS_THEME_CONVENTIONS_SUMMARY,
502
+ project_rules: project.rules,
503
+ project_rules_source: project.source,
504
+ });
505
+ }
506
+ case 'pagebuilder_rules': {
507
+ const project = loadProjectRules();
508
+ return toolOk({
509
+ rules: [...SHARED_RULES_SUMMARY, ...project.rules],
510
+ pagebuilder_rules: PAGEBUILDER_RULES_SUMMARY,
511
+ cms_theme_conventions: CMS_THEME_CONVENTIONS_SUMMARY,
512
+ project_rules: project.rules,
513
+ project_rules_source: project.source,
514
+ detail: {
515
+ ...SHARED_RULES_DETAIL,
516
+ ...project.detail,
517
+ },
223
518
  });
224
519
  }
520
+ case 'list_section_types':
521
+ return toolOk({
522
+ elements: describeElements(),
523
+ element_types: PAGEBUILDER_ELEMENT_TYPES,
524
+ section_types: [
525
+ {
526
+ type: 'sections_tree',
527
+ description:
528
+ 'Preferred: pass sections[] to compose_pagebuilder / create_cms_page / create_cms_block. Nested {type, children} using any Magento_PageBuilder native type.',
529
+ params: ['sections[]'],
530
+ },
531
+ {
532
+ type: 'simple_text',
533
+ description:
534
+ 'Legacy: Page Builder Row (contained) → Text via content/headline fields.',
535
+ params: ['content', 'headline?', 'subheadline?'],
536
+ },
537
+ {
538
+ type: 'raw_pagebuilder_html',
539
+ description:
540
+ 'Pass full master-format HTML via set_pagebuilder_html or html= on create/update.',
541
+ params: [
542
+ 'html',
543
+ 'page_id|block_id',
544
+ 'repair_first?',
545
+ 'force?',
546
+ 'allow_full_bleed?',
547
+ ],
548
+ },
549
+ {
550
+ type: 'banner_poster_cta',
551
+ description:
552
+ 'sections pattern: row → banner (poster, show_button=always, link_url, background_image, content_html).',
553
+ params: [
554
+ 'background_image',
555
+ 'content_html',
556
+ 'button_text',
557
+ 'link_url',
558
+ 'show_button=always',
559
+ 'allow_full_bleed?',
560
+ ],
561
+ },
562
+ {
563
+ type: 'hero_plus_body',
564
+ description:
565
+ 'sections pattern: row(full-bleed|full-width)+banner hero, then row(contained|full-width) body with heading/text/columns/image/buttons.',
566
+ params: [
567
+ 'hero_image',
568
+ 'content_html',
569
+ 'button_text?',
570
+ 'link_url?',
571
+ 'body children',
572
+ 'css_classes?',
573
+ ],
574
+ },
575
+ {
576
+ type: 'column_cards',
577
+ description:
578
+ 'sections pattern: columns → each column Image+Text(+Buttons). Prefer Image over SVG-in-Text.',
579
+ params: ['column_count', 'cards[]'],
580
+ },
581
+ ],
582
+ maintenance_tools: [
583
+ 'compose_pagebuilder',
584
+ 'validate_pagebuilder',
585
+ 'repair_pagebuilder',
586
+ 'pagebuilder_rules',
587
+ 'search_cms',
588
+ ],
589
+ note: PAGEBUILDER_RULES_SUMMARY.join(' '),
590
+ });
591
+ case 'search_cms': {
592
+ const magento = client();
593
+ const type = (args.type || 'both').toLowerCase();
594
+ const condition = (args.condition_type || 'eq').toLowerCase();
595
+ const out = { identifier: args.identifier, pages: [], blocks: [] };
596
+ if (type === 'page' || type === 'both') {
597
+ const res = await magento.searchPagesByIdentifier(
598
+ args.identifier,
599
+ condition
600
+ );
601
+ out.pages = (res.items || []).map((p) =>
602
+ summarizeEntity(p, 'page', magento.pageEditUrl(p.id))
603
+ );
604
+ }
605
+ if (type === 'block' || type === 'both') {
606
+ const res = await magento.searchBlocksByIdentifier(
607
+ args.identifier,
608
+ condition
609
+ );
610
+ out.blocks = (res.items || []).map((b) =>
611
+ summarizeEntity(b, 'block', magento.blockEditUrl(b.id))
612
+ );
613
+ }
614
+ return toolOk(out);
615
+ }
225
616
  case 'get_cms_page': {
226
617
  const magento = client();
227
618
  const page = await magento.getPage(args.page_id);
619
+ const extras = {};
620
+ if (args.include_content) extras.content = page.content;
621
+ if (args.validate) {
622
+ extras.validation = validatePageBuilderContent(String(page.content || ''));
623
+ }
228
624
  return toolOk(
229
- summarizeEntity(page, 'page', magento.pageEditUrl(page.id || args.page_id))
625
+ summarizeEntity(
626
+ page,
627
+ 'page',
628
+ magento.pageEditUrl(page.id || args.page_id),
629
+ extras
630
+ )
230
631
  );
231
632
  }
633
+ case 'get_cms_block': {
634
+ const magento = client();
635
+ const block = await magento.getBlock(args.block_id);
636
+ const extras = {};
637
+ if (args.include_content) extras.content = block.content;
638
+ if (args.validate) {
639
+ extras.validation = validatePageBuilderContent(
640
+ String(block.content || '')
641
+ );
642
+ }
643
+ return toolOk(
644
+ summarizeEntity(
645
+ block,
646
+ 'block',
647
+ magento.blockEditUrl(block.id || args.block_id),
648
+ extras
649
+ )
650
+ );
651
+ }
652
+ case 'validate_pagebuilder': {
653
+ const magento = client();
654
+ const loaded = await loadHtmlFromArgs(magento, args);
655
+ const validation = validatePageBuilderContent(loaded.html, {
656
+ allowFullBleed: args.allow_full_bleed === true,
657
+ });
658
+ return toolOk({
659
+ source: loaded.source,
660
+ page_id: args.page_id || null,
661
+ block_id: args.block_id || null,
662
+ identifier: loaded.entity?.identifier || null,
663
+ allow_full_bleed: args.allow_full_bleed === true,
664
+ ...validation,
665
+ });
666
+ }
667
+ case 'repair_pagebuilder': {
668
+ const magento = client();
669
+ const loaded = await loadHtmlFromArgs(magento, args);
670
+ const repaired = repairPageBuilderContent(loaded.html, {
671
+ stripSvg: args.strip_svg,
672
+ fixFullBleed: args.fix_full_bleed,
673
+ });
674
+
675
+ let saved = null;
676
+ if (args.apply) {
677
+ if (args.page_id) {
678
+ const existing = loaded.entity || (await magento.getPage(args.page_id));
679
+ await magento.updatePage(args.page_id, {
680
+ title: existing.title,
681
+ identifier: existing.identifier,
682
+ content: repaired.content,
683
+ content_heading: existing.content_heading ?? '',
684
+ page_layout: existing.page_layout || 'cms-full-width',
685
+ is_active: entityIsActive(existing),
686
+ meta_title: existing.meta_title || existing.title,
687
+ meta_description: existing.meta_description || '',
688
+ });
689
+ saved = { type: 'page', id: args.page_id };
690
+ } else if (args.block_id) {
691
+ const existing =
692
+ loaded.entity || (await magento.getBlock(args.block_id));
693
+ await magento.updateBlock(args.block_id, {
694
+ title: existing.title,
695
+ identifier: existing.identifier,
696
+ content: repaired.content,
697
+ is_active: entityIsActive(existing),
698
+ });
699
+ saved = { type: 'block', id: args.block_id };
700
+ } else {
701
+ throw new Error('apply=true requires page_id or block_id');
702
+ }
703
+ }
704
+
705
+ return toolOk({
706
+ changed: repaired.changed,
707
+ actions: repaired.actions,
708
+ before_ok: repaired.before.ok,
709
+ after_ok: repaired.after.ok,
710
+ before_errors: repaired.before.errors,
711
+ after_errors: repaired.after.errors,
712
+ after_warnings: repaired.after.warnings,
713
+ saved,
714
+ // Only return repaired HTML when not applied (preview) or explicitly small
715
+ content_length: repaired.content.length,
716
+ content_preview: repaired.content.slice(0, 400),
717
+ content: args.apply ? undefined : repaired.content,
718
+ });
719
+ }
720
+ case 'set_pagebuilder_html': {
721
+ const magento = client();
722
+ if (!args.page_id && !args.block_id) {
723
+ throw new Error('set_pagebuilder_html requires page_id or block_id');
724
+ }
725
+ let html = String(args.html || '');
726
+ const repairFirst = args.repair_first !== false;
727
+ const allowFullBleed = args.allow_full_bleed === true;
728
+ let repairMeta = null;
729
+ if (repairFirst) {
730
+ const repaired = repairPageBuilderContent(html, {
731
+ stripSvg: true,
732
+ // Keep intentional full-bleed heroes (edge-to-edge Banner)
733
+ fixFullBleed: allowFullBleed ? false : true,
734
+ });
735
+ html = repaired.content;
736
+ repairMeta = {
737
+ actions: repaired.actions,
738
+ after_ok: repaired.after.ok,
739
+ after_errors: repaired.after.errors,
740
+ fix_full_bleed: allowFullBleed ? false : true,
741
+ };
742
+ }
743
+ const validation = validatePageBuilderContent(html, {
744
+ allowFullBleed,
745
+ });
746
+ if (!validation.ok && !args.force) {
747
+ throw new Error(
748
+ `Page Builder validation failed (pass force=true to save anyway): ${validation.errors.join(
749
+ ' | '
750
+ )}`
751
+ );
752
+ }
753
+ if (args.page_id) {
754
+ const existing = await magento.getPage(args.page_id);
755
+ await magento.updatePage(args.page_id, {
756
+ title: existing.title,
757
+ identifier: existing.identifier,
758
+ content: html,
759
+ content_heading: existing.content_heading ?? '',
760
+ page_layout: existing.page_layout || 'cms-full-width',
761
+ is_active: entityIsActive(existing),
762
+ meta_title: existing.meta_title || existing.title,
763
+ meta_description: existing.meta_description || '',
764
+ });
765
+ return toolOk({
766
+ result: 'saved',
767
+ type: 'page',
768
+ id: args.page_id,
769
+ content_length: html.length,
770
+ allow_full_bleed: allowFullBleed,
771
+ validation,
772
+ repair: repairMeta,
773
+ admin_edit_url: magento.pageEditUrl(args.page_id),
774
+ });
775
+ }
776
+ const existing = await magento.getBlock(args.block_id);
777
+ await magento.updateBlock(args.block_id, {
778
+ title: existing.title,
779
+ identifier: existing.identifier,
780
+ content: html,
781
+ is_active: entityIsActive(existing),
782
+ });
783
+ return toolOk({
784
+ result: 'saved',
785
+ type: 'block',
786
+ id: args.block_id,
787
+ content_length: html.length,
788
+ allow_full_bleed: allowFullBleed,
789
+ validation,
790
+ repair: repairMeta,
791
+ admin_edit_url: magento.blockEditUrl(args.block_id),
792
+ });
793
+ }
794
+ case 'compose_pagebuilder': {
795
+ const doc = composeDocument(args.sections);
796
+ let html = doc.html;
797
+ const allowFullBleed =
798
+ args.allow_full_bleed === false
799
+ ? false
800
+ : args.allow_full_bleed === true || doc.allow_full_bleed;
801
+ const doValidate = args.validate !== false;
802
+ let repairMeta = null;
803
+ if (args.page_id || args.block_id) {
804
+ const repairFirst = args.repair_first !== false;
805
+ if (repairFirst) {
806
+ const repaired = repairPageBuilderContent(html, {
807
+ stripSvg: true,
808
+ fixFullBleed: allowFullBleed ? false : true,
809
+ });
810
+ html = repaired.content;
811
+ repairMeta = {
812
+ actions: repaired.actions,
813
+ after_ok: repaired.after.ok,
814
+ after_errors: repaired.after.errors,
815
+ };
816
+ }
817
+ }
818
+ const validation = doValidate
819
+ ? validatePageBuilderContent(html, { allowFullBleed })
820
+ : null;
821
+ if (doValidate && validation && !validation.ok && !args.force) {
822
+ if (!args.page_id && !args.block_id) {
823
+ return toolOk({
824
+ result: 'composed',
825
+ saved: false,
826
+ used_types: doc.used_types,
827
+ allow_full_bleed: allowFullBleed,
828
+ validation,
829
+ content_length: html.length,
830
+ html,
831
+ note: 'Validation reported errors — HTML still returned. Pass force=true with page_id/block_id to save anyway.',
832
+ });
833
+ }
834
+ throw new Error(
835
+ `Page Builder validation failed (pass force=true to save anyway): ${validation.errors.join(
836
+ ' | '
837
+ )}`
838
+ );
839
+ }
840
+
841
+ if (args.page_id || args.block_id) {
842
+ const magento = client();
843
+ if (args.page_id) {
844
+ const existing = await magento.getPage(args.page_id);
845
+ await magento.updatePage(args.page_id, {
846
+ title: existing.title,
847
+ identifier: existing.identifier,
848
+ content: html,
849
+ content_heading: existing.content_heading ?? '',
850
+ page_layout: existing.page_layout || 'cms-full-width',
851
+ is_active: entityIsActive(existing),
852
+ meta_title: existing.meta_title || existing.title,
853
+ meta_description: existing.meta_description || '',
854
+ });
855
+ return toolOk({
856
+ result: 'saved',
857
+ type: 'page',
858
+ id: args.page_id,
859
+ used_types: doc.used_types,
860
+ allow_full_bleed: allowFullBleed,
861
+ validation,
862
+ repair: repairMeta,
863
+ content_length: html.length,
864
+ admin_edit_url: magento.pageEditUrl(args.page_id),
865
+ });
866
+ }
867
+ const existing = await magento.getBlock(args.block_id);
868
+ await magento.updateBlock(args.block_id, {
869
+ title: existing.title,
870
+ identifier: existing.identifier,
871
+ content: html,
872
+ is_active: entityIsActive(existing),
873
+ });
874
+ return toolOk({
875
+ result: 'saved',
876
+ type: 'block',
877
+ id: args.block_id,
878
+ used_types: doc.used_types,
879
+ allow_full_bleed: allowFullBleed,
880
+ validation,
881
+ repair: repairMeta,
882
+ content_length: html.length,
883
+ admin_edit_url: magento.blockEditUrl(args.block_id),
884
+ });
885
+ }
886
+
887
+ return toolOk({
888
+ result: 'composed',
889
+ saved: false,
890
+ used_types: doc.used_types,
891
+ allow_full_bleed: allowFullBleed,
892
+ validation,
893
+ content_length: html.length,
894
+ html,
895
+ });
896
+ }
232
897
  case 'create_cms_page': {
233
898
  const magento = client();
234
899
  const identifier =
235
900
  (args.identifier || '').trim() || generateIdentifier(args.title);
236
- const pbHtml = composeFromFields({
237
- content: args.content,
238
- headline: args.headline,
901
+ const resolved = resolvePageBuilderContent(args);
902
+ if (!resolved) {
903
+ throw new Error(
904
+ 'Provide sections[], html, or content to create a CMS page'
905
+ );
906
+ }
907
+ let pbHtml = resolved.html;
908
+ const allowFullBleed =
909
+ args.allow_full_bleed === true || resolved.allow_full_bleed === true;
910
+ if (resolved.mode === 'sections' || resolved.mode === 'html') {
911
+ const repaired = repairPageBuilderContent(pbHtml, {
912
+ stripSvg: true,
913
+ fixFullBleed: allowFullBleed ? false : true,
914
+ });
915
+ pbHtml = repaired.content;
916
+ }
917
+ const validation = validatePageBuilderContent(pbHtml, {
918
+ allowFullBleed,
239
919
  });
920
+ if (!validation.ok) {
921
+ throw new Error(
922
+ `Page Builder validation failed: ${validation.errors.join(' | ')}`
923
+ );
924
+ }
240
925
  if (!validatePageBuilderHtml(pbHtml)) {
241
- throw new Error('Generated Page Builder HTML failed validation');
926
+ throw new Error('Generated Page Builder HTML failed basic check');
242
927
  }
243
928
  const page = await magento.createPage({
244
929
  title: args.title,
245
930
  identifier,
246
931
  content: pbHtml,
247
932
  content_heading: '',
248
- page_layout: args.page_layout || '1column',
933
+ page_layout: args.page_layout || 'cms-full-width',
249
934
  is_active: args.is_active !== false,
250
- store_id: [0],
251
935
  meta_title: args.title,
252
936
  meta_description: '',
253
937
  });
254
938
  const id = page?.id ?? page?.page_id;
939
+ const storeIds = resolveStoreIds(args);
940
+ const storeAssign = assignCmsStores('page', Number(id), storeIds);
255
941
  return toolOk({
256
942
  result: 'created',
943
+ mode: resolved.mode,
944
+ used_types: resolved.used_types,
945
+ allow_full_bleed: allowFullBleed,
946
+ validation,
947
+ store_ids: storeIds,
948
+ store_assign: storeAssign,
257
949
  ...summarizeEntity(page, 'page', magento.pageEditUrl(id)),
258
950
  full_content_length: pbHtml.length,
259
951
  });
@@ -268,13 +960,31 @@ async function callTool(name, args = {}) {
268
960
  generateIdentifier(title);
269
961
 
270
962
  let content = existing.content;
271
- if (args.content !== undefined && String(args.content || '').length) {
272
- content = composeFromFields({
273
- content: args.content,
274
- headline: args.headline,
275
- });
963
+ let mode = 'metadata_only';
964
+ let used_types = [];
965
+ let validation = null;
966
+ const resolved = resolvePageBuilderContent(args);
967
+ if (resolved) {
968
+ mode = resolved.mode;
969
+ used_types = resolved.used_types;
970
+ content = resolved.html;
971
+ const allowFullBleed =
972
+ args.allow_full_bleed === true || resolved.allow_full_bleed === true;
973
+ if (mode === 'sections' || mode === 'html') {
974
+ const repaired = repairPageBuilderContent(content, {
975
+ stripSvg: true,
976
+ fixFullBleed: allowFullBleed ? false : true,
977
+ });
978
+ content = repaired.content;
979
+ }
980
+ validation = validatePageBuilderContent(content, { allowFullBleed });
981
+ if (!validation.ok) {
982
+ throw new Error(
983
+ `Page Builder validation failed: ${validation.errors.join(' | ')}`
984
+ );
985
+ }
276
986
  if (!validatePageBuilderHtml(content)) {
277
- throw new Error('Generated Page Builder HTML failed validation');
987
+ throw new Error('Generated Page Builder HTML failed basic check');
278
988
  }
279
989
  }
280
990
 
@@ -283,48 +993,76 @@ async function callTool(name, args = {}) {
283
993
  identifier,
284
994
  content,
285
995
  content_heading: existing.content_heading ?? '',
286
- page_layout: args.page_layout || existing.page_layout || '1column',
996
+ page_layout: args.page_layout || existing.page_layout || 'cms-full-width',
287
997
  is_active:
288
- args.is_active !== undefined ? args.is_active : existing.is_active,
998
+ args.is_active !== undefined
999
+ ? args.is_active
1000
+ : entityIsActive(existing),
289
1001
  meta_title: existing.meta_title || title,
290
1002
  meta_description: existing.meta_description || '',
291
- store_id: existing.store_id || [0],
292
1003
  });
293
1004
  const id = page?.id ?? args.page_id;
1005
+ let storeAssign = null;
1006
+ let storeIds = null;
1007
+ if (args.store_ids !== undefined) {
1008
+ storeIds = resolveStoreIds(args);
1009
+ storeAssign = assignCmsStores('page', Number(id), storeIds);
1010
+ }
294
1011
  return toolOk({
295
1012
  result: 'updated',
1013
+ mode,
1014
+ used_types,
1015
+ validation,
1016
+ store_ids: storeIds,
1017
+ store_assign: storeAssign,
296
1018
  ...summarizeEntity(page, 'page', magento.pageEditUrl(id)),
297
1019
  full_content_length: String(content || '').length,
298
1020
  });
299
1021
  }
300
- case 'get_cms_block': {
301
- const magento = client();
302
- const block = await magento.getBlock(args.block_id);
303
- return toolOk(
304
- summarizeEntity(block, 'block', magento.blockEditUrl(block.id || args.block_id))
305
- );
306
- }
307
1022
  case 'create_cms_block': {
308
1023
  const magento = client();
309
1024
  const identifier =
310
1025
  (args.identifier || '').trim() || generateIdentifier(args.title);
311
- const pbHtml = composeFromFields({
312
- content: args.content,
313
- headline: args.headline,
1026
+ const resolved = resolvePageBuilderContent(args);
1027
+ if (!resolved) {
1028
+ throw new Error(
1029
+ 'Provide sections[], html, or content to create a CMS block'
1030
+ );
1031
+ }
1032
+ let pbHtml = resolved.html;
1033
+ const allowFullBleed =
1034
+ args.allow_full_bleed === true || resolved.allow_full_bleed === true;
1035
+ if (resolved.mode === 'sections' || resolved.mode === 'html') {
1036
+ const repaired = repairPageBuilderContent(pbHtml, {
1037
+ stripSvg: true,
1038
+ fixFullBleed: allowFullBleed ? false : true,
1039
+ });
1040
+ pbHtml = repaired.content;
1041
+ }
1042
+ const validation = validatePageBuilderContent(pbHtml, {
1043
+ allowFullBleed,
314
1044
  });
315
- if (!validatePageBuilderHtml(pbHtml)) {
316
- throw new Error('Generated Page Builder HTML failed validation');
1045
+ if (!validation.ok) {
1046
+ throw new Error(
1047
+ `Page Builder validation failed: ${validation.errors.join(' | ')}`
1048
+ );
317
1049
  }
318
1050
  const block = await magento.createBlock({
319
1051
  title: args.title,
320
1052
  identifier,
321
1053
  content: pbHtml,
322
1054
  is_active: args.is_active !== false,
323
- store_id: [0],
324
1055
  });
325
1056
  const id = block?.id ?? block?.block_id;
1057
+ const storeIds = resolveStoreIds(args);
1058
+ const storeAssign = assignCmsStores('block', Number(id), storeIds);
326
1059
  return toolOk({
327
1060
  result: 'created',
1061
+ mode: resolved.mode,
1062
+ used_types: resolved.used_types,
1063
+ validation,
1064
+ store_ids: storeIds,
1065
+ store_assign: storeAssign,
328
1066
  ...summarizeEntity(block, 'block', magento.blockEditUrl(id)),
329
1067
  });
330
1068
  }
@@ -338,13 +1076,28 @@ async function callTool(name, args = {}) {
338
1076
  generateIdentifier(title);
339
1077
 
340
1078
  let content = existing.content;
341
- if (args.content !== undefined && String(args.content || '').length) {
342
- content = composeFromFields({
343
- content: args.content,
344
- headline: args.headline,
345
- });
346
- if (!validatePageBuilderHtml(content)) {
347
- throw new Error('Generated Page Builder HTML failed validation');
1079
+ let mode = 'metadata_only';
1080
+ let used_types = [];
1081
+ let validation = null;
1082
+ const resolved = resolvePageBuilderContent(args);
1083
+ if (resolved) {
1084
+ mode = resolved.mode;
1085
+ used_types = resolved.used_types;
1086
+ content = resolved.html;
1087
+ const allowFullBleed =
1088
+ args.allow_full_bleed === true || resolved.allow_full_bleed === true;
1089
+ if (mode === 'sections' || mode === 'html') {
1090
+ const repaired = repairPageBuilderContent(content, {
1091
+ stripSvg: true,
1092
+ fixFullBleed: allowFullBleed ? false : true,
1093
+ });
1094
+ content = repaired.content;
1095
+ }
1096
+ validation = validatePageBuilderContent(content, { allowFullBleed });
1097
+ if (!validation.ok) {
1098
+ throw new Error(
1099
+ `Page Builder validation failed: ${validation.errors.join(' | ')}`
1100
+ );
348
1101
  }
349
1102
  }
350
1103
 
@@ -353,27 +1106,25 @@ async function callTool(name, args = {}) {
353
1106
  identifier,
354
1107
  content,
355
1108
  is_active:
356
- args.is_active !== undefined ? args.is_active : existing.is_active,
357
- store_id: existing.store_id || [0],
1109
+ args.is_active !== undefined ? args.is_active : entityIsActive(existing),
358
1110
  });
359
1111
  const id = block?.id ?? args.block_id;
1112
+ let storeAssign = null;
1113
+ let storeIds = null;
1114
+ if (args.store_ids !== undefined) {
1115
+ storeIds = resolveStoreIds(args);
1116
+ storeAssign = assignCmsStores('block', Number(id), storeIds);
1117
+ }
360
1118
  return toolOk({
361
1119
  result: 'updated',
1120
+ mode,
1121
+ used_types,
1122
+ validation,
1123
+ store_ids: storeIds,
1124
+ store_assign: storeAssign,
362
1125
  ...summarizeEntity(block, 'block', magento.blockEditUrl(id)),
363
1126
  });
364
1127
  }
365
- case 'list_section_types':
366
- return toolOk({
367
- section_types: [
368
- {
369
- type: 'simple_text',
370
- description:
371
- 'Page Builder Row (contained) → Text (WYSIWYG). Default for pasted content.',
372
- params: ['content', 'headline?', 'subheadline?'],
373
- },
374
- ],
375
- note: 'More types will be added as element templates are documented.',
376
- });
377
1128
  default:
378
1129
  throw new Error(`Unknown tool: ${name}`);
379
1130
  }