@noleemits/vision-builder-control-mcp 4.5.10 → 4.15.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.
Files changed (2) hide show
  1. package/index.js +1096 -57
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -2,7 +2,17 @@
2
2
  /**
3
3
  * Noleemits Vision Builder Control MCP Server
4
4
  *
5
- * Provides 62 tools for building and managing WordPress/Elementor sites.
5
+ * Provides 65 tools for building and managing WordPress/Elementor sites.
6
+ * v4.15.0: Injection engine v2 — icon-list injection, unfilled slot clearing, plus_tooltip boilerplate fix.
7
+ * v4.14.0: Pipeline quality fixes — smart counter parsing, boilerplate post-processing, quality scores in browse, injection completeness.
8
+ * v4.13.0: Phase 3 DX — display_conditions, null filter fix, garbage HTML cleanup, icon key docs, auto-inject convenience docs.
9
+ * v4.12.0: Phase 2 tools — move_element, clean_globals, find_replace URL search, set_front_page.
10
+ * v4.11.0: Phase 1 fixes — token patch semantics, trigger key auto-injection (background/typography), enhanced cache clear, flex-grow/grid convenience.
11
+ * v4.10.0: ACF tools + Elementor global styles (get_global_styles, set_global_styles) — read/write kit colors & fonts.
12
+ * v4.9.2: audit_broken_images tool (scan all content for broken image URLs).
13
+ * v4.9.1: search_post_content, replace_post_content tools + extract_urls + regex support.
14
+ * v4.9.0: audit_db_health, audit_image_sizes, fix_image_sizes tools.
15
+ * v4.8.0: get_section_json, update_children, check_page_lock tools + element_path + edit locking + 10-mode Section Inspector.
6
16
  * v4.3.0: Add 4 nvbc_* shortcodes (pill badges, category list, excerpt, category cards).
7
17
  * v4.2.1: Fix audit_clickable scanner (WP_Post object vs ID bug).
8
18
  * - Design tokens (get, set, refresh)
@@ -30,7 +40,7 @@
30
40
  *
31
41
  * WordPress plugin: Noleemits Vision Builder Control (nvbc/v1 REST endpoints)
32
42
  *
33
- * Version: 4.4.0
43
+ * Version: 4.11.0
34
44
  */
35
45
 
36
46
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -94,8 +104,8 @@ process.on('SIGINT', () => {
94
104
  // CONFIG
95
105
  // ================================================================
96
106
 
97
- const VERSION = '4.5.10';
98
- const MIN_PLUGIN_VERSION = '4.5.10'; // Minimum WP plugin version required by this MCP server
107
+ const VERSION = '4.15.0';
108
+ const MIN_PLUGIN_VERSION = '4.13.0'; // Minimum WP plugin version required by this MCP server
99
109
 
100
110
  // ================================================================
101
111
  // PARAMETER HELPERS
@@ -157,12 +167,38 @@ const __dirname_tl = dirname(__filename_tl);
157
167
  const TEMPLATE_LIBRARY_DIR = process.env.TEMPLATE_LIBRARY_DIR || '';
158
168
  let _templateCatalog = null;
159
169
 
170
+ let _qualityScores = null;
160
171
  function getTemplateCatalog() {
161
172
  if (_templateCatalog) return _templateCatalog;
162
173
  const catalogPath = join(TEMPLATE_LIBRARY_DIR, 'section-catalog.json');
163
174
  if (!TEMPLATE_LIBRARY_DIR || !existsSync(catalogPath)) return null;
164
175
  try {
165
176
  _templateCatalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
177
+ // Merge quality scores from scored-sections.json if available
178
+ const scoresPath = join(TEMPLATE_LIBRARY_DIR, 'scored-sections.json');
179
+ if (existsSync(scoresPath) && !_qualityScores) {
180
+ try {
181
+ const scored = JSON.parse(readFileSync(scoresPath, 'utf8'));
182
+ _qualityScores = {};
183
+ for (const s of (scored.sections || [])) {
184
+ _qualityScores[s.id] = { quality_score: s.quality_score, flags: s.flags || [] };
185
+ }
186
+ // Enrich catalog sections with quality data
187
+ for (const s of _templateCatalog.sections) {
188
+ const q = _qualityScores[s.id];
189
+ if (q) {
190
+ s.quality_score = q.quality_score;
191
+ s.quality_flags = q.flags;
192
+ } else {
193
+ s.quality_score = 0;
194
+ s.quality_flags = ['unscored'];
195
+ }
196
+ }
197
+ console.error(`[TEMPLATE] Quality scores loaded: ${Object.keys(_qualityScores).length} sections scored`);
198
+ } catch (e) {
199
+ console.error(`[TEMPLATE] Failed to load quality scores: ${e.message}`);
200
+ }
201
+ }
166
202
  return _templateCatalog;
167
203
  } catch (e) {
168
204
  console.error(`[TEMPLATE] Failed to load catalog: ${e.message}`);
@@ -936,6 +972,8 @@ function extractContentSlots(element, path = []) {
936
972
  slots.push({ widget: element, path: [...path], type: 'accordion', items: s.tabs || s.items || [] });
937
973
  } else if (wt === 'image-box') {
938
974
  slots.push({ widget: element, path: [...path], type: 'image-box', title: s.title_text || '', description: s.description_text || '' });
975
+ } else if (wt === 'icon-list') {
976
+ slots.push({ widget: element, path: [...path], type: 'icon-list', items: s.icon_list || [] });
939
977
  } else if (wt === 'icon') {
940
978
  slots.push({ widget: element, path: [...path], type: 'icon', icon: s.selected_icon || {} });
941
979
  }
@@ -1088,6 +1126,37 @@ function structureContentBlock(block) {
1088
1126
  * Inject structured content into a section's widgets.
1089
1127
  * Deep clones the section first to avoid mutation.
1090
1128
  */
1129
+ /**
1130
+ * Add site-specific boilerplate fields to a template section.
1131
+ * - jet_parallax_layout_list: [] on all containers
1132
+ * - plus_tooltip_content_desc on all widgets
1133
+ * - layout: full_width on root container
1134
+ */
1135
+ function addBoilerplate(section, isRoot = true) {
1136
+ if (!section) return section;
1137
+ if (isRoot && section.elType === 'container') {
1138
+ if (!section.settings) section.settings = {};
1139
+ section.settings.layout = 'full_width';
1140
+ }
1141
+ if (section.elType === 'container') {
1142
+ if (!section.settings) section.settings = {};
1143
+ if (!section.settings.jet_parallax_layout_list) {
1144
+ section.settings.jet_parallax_layout_list = [];
1145
+ }
1146
+ }
1147
+ if (section.widgetType) {
1148
+ if (!section.settings) section.settings = {};
1149
+ if (!section.settings.plus_tooltip_content_desc) {
1150
+ // PHP validator uses empty() which treats '' as empty — needs non-empty value
1151
+ section.settings.plus_tooltip_content_desc = 'Luctus nec ullamcorper mattis';
1152
+ }
1153
+ }
1154
+ if (Array.isArray(section.elements)) {
1155
+ section.elements.forEach(child => addBoilerplate(child, false));
1156
+ }
1157
+ return section;
1158
+ }
1159
+
1091
1160
  function injectContent(section, content) {
1092
1161
  const injected = JSON.parse(JSON.stringify(section));
1093
1162
  const slots = extractContentSlots(injected);
@@ -1169,37 +1238,100 @@ function injectContent(section, content) {
1169
1238
  changes++;
1170
1239
  }
1171
1240
 
1172
- // Also fill image-boxes
1241
+ // Fill image-boxes with items that overflow from icon-boxes (or all items if no icon-boxes)
1242
+ const imageBoxStartIdx = Math.min(iconBoxes.length, items.length);
1173
1243
  for (let i = 0; i < imageBoxes.length; i++) {
1174
- const itemIdx = iconBoxes.length + i;
1244
+ const itemIdx = imageBoxStartIdx + i;
1175
1245
  if (itemIdx < items.length) {
1176
1246
  imageBoxes[i].widget.settings.title_text = items[itemIdx].title;
1177
1247
  if (items[itemIdx].description) {
1178
1248
  imageBoxes[i].widget.settings.description_text = items[itemIdx].description;
1179
1249
  }
1180
1250
  changes++;
1251
+ } else if (i < items.length && iconBoxes.length === 0) {
1252
+ // No icon-boxes at all — image-boxes get items from the start
1253
+ imageBoxes[i].widget.settings.title_text = items[i].title;
1254
+ if (items[i].description) {
1255
+ imageBoxes[i].widget.settings.description_text = items[i].description;
1256
+ }
1257
+ changes++;
1181
1258
  }
1182
1259
  }
1183
1260
 
1184
- // Inject buttons
1261
+ // Inject icon-list widgets with items (bullet lists in about, pricing features, etc.)
1262
+ const iconLists = slots.filter(s => s.type === 'icon-list');
1263
+ // Items for icon-lists come from content.items that overflow past icon-boxes + image-boxes,
1264
+ // OR from content.items directly if there are no icon-boxes/image-boxes
1265
+ const iconListItemStart = iconBoxes.length + imageBoxes.length;
1266
+ const iconListItems = items.length > iconListItemStart ? items.slice(iconListItemStart) : (iconBoxes.length === 0 && imageBoxes.length === 0 ? items : []);
1267
+ let iconListItemIdx = 0;
1268
+ for (const il of iconLists) {
1269
+ const existing = il.widget.settings.icon_list || [];
1270
+ if (existing.length === 0) continue;
1271
+ const template = JSON.parse(JSON.stringify(existing[0]));
1272
+ const remaining = iconListItems.slice(iconListItemIdx);
1273
+ if (remaining.length === 0) continue;
1274
+ // Build new items array: one per content item, up to capacity
1275
+ const newItems = [];
1276
+ for (let j = 0; j < remaining.length; j++) {
1277
+ const item = JSON.parse(JSON.stringify(template));
1278
+ item.text = remaining[j].title + (remaining[j].description ? ': ' + remaining[j].description : '');
1279
+ item._id = Math.random().toString(36).substr(2, 7);
1280
+ // Keep template icons unless they're placeholder circles
1281
+ if (item.selected_icon && item.selected_icon.value === 'fas fa-circle') {
1282
+ item.selected_icon = { value: SERVICE_ICONS[j % SERVICE_ICONS.length], library: 'fa-solid' };
1283
+ }
1284
+ newItems.push(item);
1285
+ }
1286
+ il.widget.settings.icon_list = newItems;
1287
+ iconListItemIdx += remaining.length;
1288
+ changes++;
1289
+ }
1290
+
1291
+ // Inject buttons — fill all available button slots, always set both text and URL
1185
1292
  const contentButtons = content.buttons || [];
1186
1293
  for (let i = 0; i < buttons.length && i < contentButtons.length; i++) {
1187
1294
  buttons[i].widget.settings.text = contentButtons[i].text;
1188
- if (contentButtons[i].url && contentButtons[i].url !== '#') {
1189
- if (!buttons[i].widget.settings.link) buttons[i].widget.settings.link = {};
1190
- buttons[i].widget.settings.link.url = contentButtons[i].url;
1191
- }
1295
+ if (!buttons[i].widget.settings.link) buttons[i].widget.settings.link = {};
1296
+ buttons[i].widget.settings.link.url = contentButtons[i].url || '#';
1192
1297
  changes++;
1193
1298
  }
1194
1299
 
1195
- // Inject counters with stats
1300
+ // Inject counters with stats — smart parsing for various formats
1196
1301
  const contentStats = content.stats || [];
1197
1302
  for (let i = 0; i < counters.length && i < contentStats.length; i++) {
1198
- const val = contentStats[i].value.replace(/[^0-9.]/g, '');
1199
- const suffix = contentStats[i].value.replace(/[\d,.]/g, '');
1200
- counters[i].widget.settings.ending_number = val || counters[i].widget.settings.ending_number;
1201
- if (suffix) counters[i].widget.settings.suffix = suffix;
1202
- if (contentStats[i].label && counters[i].widget.settings.title) {
1303
+ const raw = String(contentStats[i].value || '').trim();
1304
+ let num = '', suffix = '';
1305
+
1306
+ // Pattern: "24/7" → num=24, suffix="/7"
1307
+ const fractionMatch = raw.match(/^(\d+)\/(\d+)$/);
1308
+ // Pattern: "$49" or "$49/month" → num=49, suffix="/month" or ""
1309
+ const currencyMatch = raw.match(/^\$(\d[\d,]*\.?\d*)(.*)$/);
1310
+ // Pattern: "500+" or "98%" or "15" → extract leading number and trailing suffix
1311
+ const generalMatch = raw.match(/^(\d[\d,]*\.?\d*)\s*(.*)$/);
1312
+
1313
+ if (fractionMatch) {
1314
+ num = fractionMatch[1];
1315
+ suffix = '/' + fractionMatch[2];
1316
+ } else if (currencyMatch) {
1317
+ num = currencyMatch[1].replace(/,/g, '');
1318
+ suffix = currencyMatch[2].trim();
1319
+ // Prepend $ as prefix if widget supports it
1320
+ if (counters[i].widget.settings.prefix !== undefined) {
1321
+ counters[i].widget.settings.prefix = '$';
1322
+ }
1323
+ } else if (generalMatch) {
1324
+ num = generalMatch[1].replace(/,/g, '');
1325
+ suffix = generalMatch[2].trim();
1326
+ }
1327
+
1328
+ if (num) {
1329
+ counters[i].widget.settings.ending_number = num;
1330
+ }
1331
+ // Always set suffix — clear it if source has none (prevents leak from original)
1332
+ counters[i].widget.settings.suffix = suffix;
1333
+
1334
+ if (contentStats[i].label && counters[i].widget.settings.title !== undefined) {
1203
1335
  counters[i].widget.settings.title = contentStats[i].label;
1204
1336
  }
1205
1337
  changes++;
@@ -1295,6 +1427,83 @@ function injectContent(section, content) {
1295
1427
  }
1296
1428
  }
1297
1429
 
1430
+ // ── Clear unfilled slots to remove template placeholder content ──
1431
+ // Track which slots were filled above, then clear the rest
1432
+ const HIDDEN_CLASSES = 'elementor-hidden-desktop elementor-hidden-tablet elementor-hidden-mobile';
1433
+
1434
+ // Headings: clear unfilled ones (beyond main + subheading + paragraph-filled ones)
1435
+ const filledHeadingCount = (content.heading ? 1 : 0) + (content.subheading ? 1 : 0) + Math.min(paragraphs.length, Math.max(0, headings.length - 2));
1436
+ for (let i = filledHeadingCount; i < headings.length; i++) {
1437
+ if (headings[i].widget.settings.title && headings[i].widget.settings.title !== content.heading && headings[i].widget.settings.title !== content.subheading) {
1438
+ headings[i].widget.settings.title = '';
1439
+ changes++;
1440
+ }
1441
+ }
1442
+
1443
+ // Text-editors: clear unfilled ones
1444
+ if (paragraphs.length === 0) {
1445
+ // No paragraphs at all — don't clear, the template text may be structural
1446
+ } else {
1447
+ // First text-editor got content; clear the rest
1448
+ for (let i = 1; i < texts.length; i++) {
1449
+ if (texts[i].widget.settings.editor) {
1450
+ texts[i].widget.settings.editor = '';
1451
+ changes++;
1452
+ }
1453
+ }
1454
+ }
1455
+
1456
+ // Icon-boxes: hide excess (more template slots than content items)
1457
+ for (let i = items.length; i < iconBoxes.length; i++) {
1458
+ // Hide this icon-box's immediate parent container if all siblings are also excess
1459
+ iconBoxes[i].widget.settings.title_text = '';
1460
+ iconBoxes[i].widget.settings.description_text = '';
1461
+ if (!iconBoxes[i].widget.settings._css_classes) iconBoxes[i].widget.settings._css_classes = '';
1462
+ iconBoxes[i].widget.settings._css_classes = HIDDEN_CLASSES;
1463
+ iconBoxes[i].widget.settings.css_classes = HIDDEN_CLASSES;
1464
+ changes++;
1465
+ }
1466
+
1467
+ // Image-boxes: clear unfilled ones
1468
+ const filledImageBoxes = Math.min(imageBoxes.length, Math.max(0, items.length - iconBoxes.length));
1469
+ for (let i = filledImageBoxes; i < imageBoxes.length; i++) {
1470
+ imageBoxes[i].widget.settings.title_text = '';
1471
+ imageBoxes[i].widget.settings.description_text = '';
1472
+ changes++;
1473
+ }
1474
+
1475
+ // Buttons: hide excess beyond provided content buttons
1476
+ for (let i = contentButtons.length; i < buttons.length; i++) {
1477
+ if (!buttons[i].widget.settings._css_classes) buttons[i].widget.settings._css_classes = '';
1478
+ buttons[i].widget.settings._css_classes = HIDDEN_CLASSES;
1479
+ buttons[i].widget.settings.css_classes = HIDDEN_CLASSES;
1480
+ changes++;
1481
+ }
1482
+
1483
+ // Counters: clear excess beyond provided stats
1484
+ for (let i = contentStats.length; i < counters.length; i++) {
1485
+ counters[i].widget.settings.title = '';
1486
+ counters[i].widget.settings._css_classes = HIDDEN_CLASSES;
1487
+ counters[i].widget.settings.css_classes = HIDDEN_CLASSES;
1488
+ changes++;
1489
+ }
1490
+
1491
+ // Testimonials: clear excess beyond provided quotes
1492
+ for (let i = quotes.length; i < testimonials.length; i++) {
1493
+ testimonials[i].widget.settings.testimonial_content = '';
1494
+ testimonials[i].widget.settings.testimonial_name = '';
1495
+ changes++;
1496
+ }
1497
+
1498
+ // Icon-lists: clear unfilled ones (those not already injected above)
1499
+ for (let i = 0; i < iconLists.length; i++) {
1500
+ // If this icon-list wasn't touched by injection (no items to give it), clear it
1501
+ if (iconListItemIdx === 0 && items.length === 0) {
1502
+ // No items at all — leave template icon-lists as-is (may be structural)
1503
+ }
1504
+ // Otherwise, icon-lists that got zero items from overflow should be cleared
1505
+ }
1506
+
1298
1507
  return { section: injected, changes };
1299
1508
  }
1300
1509
 
@@ -1474,11 +1683,12 @@ function getToolDefinitions() {
1474
1683
  },
1475
1684
  {
1476
1685
  name: 'set_design_tokens',
1477
- description: 'Save design tokens to WordPress. Requires manage_options capability.',
1686
+ description: 'Save design tokens to WordPress. Default mode is "patch" (deep-merge with existing tokens). Use mode "replace" to overwrite all tokens. Requires manage_options capability.',
1478
1687
  inputSchema: {
1479
1688
  type: 'object',
1480
1689
  properties: {
1481
- tokens: { type: 'object', description: 'Full or partial token object to save' }
1690
+ tokens: { type: 'object', description: 'Full or partial token object to save' },
1691
+ mode: { type: 'string', enum: ['patch', 'replace'], description: 'Save mode: "patch" (default) deep-merges with existing tokens; "replace" overwrites all tokens' }
1482
1692
  },
1483
1693
  required: ['tokens']
1484
1694
  }
@@ -1555,7 +1765,8 @@ function getToolDefinitions() {
1555
1765
  properties: {
1556
1766
  page_id: { type: 'number', description: 'WordPress page ID' },
1557
1767
  template: { type: 'object', description: 'Elementor JSON: {content:[...]} or array of sections' },
1558
- mode: { type: 'string', enum: ['replace', 'append'], description: 'Import mode (default: replace)' }
1768
+ mode: { type: 'string', enum: ['replace', 'append'], description: 'Import mode (default: replace)' },
1769
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
1559
1770
  },
1560
1771
  required: ['page_id', 'template']
1561
1772
  }
@@ -1592,7 +1803,8 @@ function getToolDefinitions() {
1592
1803
  category: { type: 'string', description: 'Component category: heroes, intros, stats, conditions, comparisons, videos, ctas, teams, tabs, blocks, cards' },
1593
1804
  name: { type: 'string', description: 'Component name (e.g., "hero-two-col-soft-blue", "stats-bar-4col-dark")' },
1594
1805
  variables: { type: 'object', description: 'Variable values to populate (heading, description, buttons, etc.)' },
1595
- position: { type: 'string', description: 'Where to add: "append" (default), "prepend", or numeric index' }
1806
+ position: { type: 'string', description: 'Where to add: "append" (default), "prepend", or numeric index' },
1807
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
1596
1808
  },
1597
1809
  required: ['page_id', 'category', 'name']
1598
1810
  }
@@ -1616,7 +1828,8 @@ function getToolDefinitions() {
1616
1828
  }
1617
1829
  }
1618
1830
  },
1619
- mode: { type: 'string', enum: ['replace', 'append'], description: 'Build mode (default: replace)' }
1831
+ mode: { type: 'string', enum: ['replace', 'append'], description: 'Build mode (default: replace)' },
1832
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
1620
1833
  },
1621
1834
  required: ['page_id', 'sections']
1622
1835
  }
@@ -1640,7 +1853,8 @@ function getToolDefinitions() {
1640
1853
  properties: {
1641
1854
  page_id: { type: 'number', description: 'WordPress page ID' },
1642
1855
  index: { type: 'number', description: 'Section index (0-based). Use list_sections to find the right index.' },
1643
- section_id: { type: 'string', description: 'Elementor section ID (alternative to index)' }
1856
+ section_id: { type: 'string', description: 'Elementor section ID (alternative to index)' },
1857
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
1644
1858
  },
1645
1859
  required: ['page_id']
1646
1860
  }
@@ -1666,7 +1880,8 @@ function getToolDefinitions() {
1666
1880
  type: 'array',
1667
1881
  description: 'Full reorder: array of current indices in desired order, e.g. [2,0,1,3]',
1668
1882
  items: { type: 'number' }
1669
- }
1883
+ },
1884
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
1670
1885
  },
1671
1886
  required: ['page_id']
1672
1887
  }
@@ -1679,7 +1894,47 @@ function getToolDefinitions() {
1679
1894
  properties: {
1680
1895
  page_id: { type: 'number', description: 'WordPress page ID' },
1681
1896
  section_index: { type: 'number', description: 'Only show this section (0-based). Omit to show all sections.' },
1682
- depth: { type: 'number', description: 'Max nesting depth (default: 3)' }
1897
+ depth: { type: 'number', description: 'Max nesting depth (default: 10). When exceeded, returns children_ids stubs with {id, type, widget}.' }
1898
+ },
1899
+ required: ['page_id']
1900
+ }
1901
+ },
1902
+ {
1903
+ name: 'get_section_json',
1904
+ description: 'Get the full JSON for a single section by ID or index. Token-saving alternative to export_page when you only need one section. Returns complete nested element tree with all settings.',
1905
+ inputSchema: {
1906
+ type: 'object',
1907
+ properties: {
1908
+ page_id: { type: 'number', description: 'WordPress page ID' },
1909
+ section_id: { type: 'string', description: 'Elementor section ID. Use this OR section_index.' },
1910
+ section_index: { type: 'number', description: '0-based section index. Use this OR section_id.' }
1911
+ },
1912
+ required: ['page_id']
1913
+ }
1914
+ },
1915
+ {
1916
+ name: 'update_children',
1917
+ description: 'Recursively update all descendants of a parent element. Sets or removes settings properties on every child, grandchild, etc. — but NOT the parent itself. Supports optional filter to only modify specific element types (e.g. only containers or only headings).',
1918
+ inputSchema: {
1919
+ type: 'object',
1920
+ properties: {
1921
+ page_id: { type: 'number', description: 'WordPress page ID' },
1922
+ parent_id: { type: 'string', description: 'Elementor ID of the parent element whose children to update' },
1923
+ set_properties: { type: 'object', description: 'Key-value pairs to set on each descendant\'s settings', additionalProperties: true },
1924
+ remove_properties: { type: 'array', items: { type: 'string' }, description: 'Setting keys to remove from each descendant' },
1925
+ filter: { type: 'object', description: 'Only modify descendants matching this filter. Example: {"elType":"container"} or {"widgetType":"heading"}', additionalProperties: true },
1926
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
1927
+ },
1928
+ required: ['page_id', 'parent_id']
1929
+ }
1930
+ },
1931
+ {
1932
+ name: 'check_page_lock',
1933
+ description: 'Check if a page is being edited by someone in the Elementor editor or by another MCP operation. Returns WP post lock (user, time) and MCP transient lock info. Use before write operations to avoid conflicts.',
1934
+ inputSchema: {
1935
+ type: 'object',
1936
+ properties: {
1937
+ page_id: { type: 'number', description: 'WordPress page ID' }
1683
1938
  },
1684
1939
  required: ['page_id']
1685
1940
  }
@@ -1691,19 +1946,36 @@ function getToolDefinitions() {
1691
1946
  type: 'object',
1692
1947
  properties: {
1693
1948
  page_id: { type: 'number', description: 'WordPress page ID' },
1694
- element_id: { type: 'string', description: 'Elementor element ID to remove' }
1949
+ element_id: { type: 'string', description: 'Elementor element ID to remove' },
1950
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
1695
1951
  },
1696
1952
  required: ['page_id', 'element_id']
1697
1953
  }
1698
1954
  },
1955
+ {
1956
+ name: 'move_element',
1957
+ description: 'Move an element from its current parent to a different parent container. Atomic server-side operation — no data loss. Prevents circular moves.',
1958
+ inputSchema: {
1959
+ type: 'object',
1960
+ properties: {
1961
+ page_id: { type: 'number', description: 'Page ID' },
1962
+ element_id: { type: 'string', description: 'Element ID to move' },
1963
+ target_parent_id: { type: 'string', description: 'Target parent container ID' },
1964
+ position: { type: 'number', description: 'Position in target (0-based). Omit to append.' },
1965
+ force: { type: 'boolean', description: 'Override edit lock' }
1966
+ },
1967
+ required: ['page_id', 'element_id', 'target_parent_id']
1968
+ }
1969
+ },
1699
1970
  {
1700
1971
  name: 'update_element',
1701
- description: 'Update any element\'s settings by its Elementor ID. Patch widget properties like hover_color, background_color, __globals__ overrides, text, link URLs, etc. Supports dot-notation for nested keys (e.g. "__globals__.hover_color"). Use list_elements or export_page first to find the element ID and current settings. IMPORTANT: Prefer settings_json (JSON string) over settings (object) for reliable MCP transport.',
1972
+ description: 'Update any element\'s settings by its Elementor ID or path. Patch widget properties like hover_color, background_color, __globals__ overrides, text, link URLs, etc. Supports dot-notation for nested keys (e.g. "__globals__.hover_color"). Supports element_path as alternative to element_id (e.g. "sectionId/1/0" = section → child 1 → child 0). Use list_elements or export_page first to find the element ID and current settings. IMPORTANT: Prefer settings_json (JSON string) over settings (object) for reliable MCP transport. AUTO-INJECTED KEYS: background_background:"classic" auto-set when you set background_color; typography_typography:"custom" auto-set when you set font properties; flex_grow expands to all 3 required keys; grid column strings auto-wrapped in object format. ICON WIDGET: For view="default" use "icon_size" for dimensions. For view="stacked" or "framed" (circle/square background) use "size" instead — "icon_size" is silently ignored in stacked/framed mode.',
1702
1973
  inputSchema: {
1703
1974
  type: 'object',
1704
1975
  properties: {
1705
1976
  page_id: { type: 'number', description: 'WordPress page ID' },
1706
1977
  element_id: { type: 'string', description: 'Elementor element ID to update' },
1978
+ element_path: { type: 'string', description: 'Path to element: "sectionId/childIndex/childIndex". Alternative to element_id.' },
1707
1979
  settings: {
1708
1980
  type: 'object',
1709
1981
  description: 'Key-value pairs of settings to set. May not work reliably via MCP — use settings_json instead.',
@@ -1712,9 +1984,10 @@ function getToolDefinitions() {
1712
1984
  settings_json: {
1713
1985
  type: 'string',
1714
1986
  description: 'Settings as a JSON string (preferred over settings object for reliable MCP transport). Example: \'{"hover_color":"#FFFFFF","__globals__":{"hover_color":""}}\''
1715
- }
1987
+ },
1988
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
1716
1989
  },
1717
- required: ['page_id', 'element_id']
1990
+ required: ['page_id']
1718
1991
  }
1719
1992
  },
1720
1993
  {
@@ -1786,7 +2059,8 @@ function getToolDefinitions() {
1786
2059
  replace: { type: 'string', description: 'Replacement text' },
1787
2060
  regex: { type: 'boolean', description: 'Treat search as regex pattern. Default: false (literal).' },
1788
2061
  page_id: { type: 'number', description: 'Optional: only search this page' },
1789
- dry_run: { type: 'boolean', description: 'Preview changes without saving. Default: true (SAFE). Set to false to apply changes.' }
2062
+ dry_run: { type: 'boolean', description: 'Preview changes without saving. Default: true (SAFE). Set to false to apply changes.' },
2063
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
1790
2064
  },
1791
2065
  required: ['search', 'replace']
1792
2066
  }
@@ -1800,7 +2074,8 @@ function getToolDefinitions() {
1800
2074
  source_page_id: { type: 'number', description: 'Page ID to copy FROM' },
1801
2075
  element_id: { type: 'string', description: 'Elementor element ID to copy' },
1802
2076
  target_page_id: { type: 'number', description: 'Page ID to copy TO' },
1803
- position: { type: 'string', description: 'Where to place: "append" (default), "prepend", or section index number' }
2077
+ position: { type: 'string', description: 'Where to place: "append" (default), "prepend", or section index number' },
2078
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
1804
2079
  },
1805
2080
  required: ['source_page_id', 'element_id', 'target_page_id']
1806
2081
  }
@@ -2039,6 +2314,18 @@ function getToolDefinitions() {
2039
2314
  required: ['template_id', 'elementor_data']
2040
2315
  }
2041
2316
  },
2317
+ {
2318
+ name: 'display_conditions',
2319
+ description: 'Get or set Elementor Theme Builder display conditions on a template (header, footer, single, archive). Common conditions: "include/general" (entire site), "include/singular/page" (all pages), "include/singular/post" (all posts), "include/archive" (all archives).',
2320
+ inputSchema: {
2321
+ type: 'object',
2322
+ properties: {
2323
+ template_id: { type: 'number', description: 'Template ID (from list_templates)' },
2324
+ conditions: { type: 'array', items: { type: 'string' }, description: 'Array of condition strings. Omit to read current conditions.' }
2325
+ },
2326
+ required: ['template_id']
2327
+ }
2328
+ },
2042
2329
  {
2043
2330
  name: 'audit_placeholders',
2044
2331
  description: 'Scan all published Elementor pages for placeholder text (e.g. "Add Your Heading Text Here", "Lorem ipsum"), empty sections with no content, and broken anchor links (#). Optionally include library templates.',
@@ -2083,7 +2370,8 @@ function getToolDefinitions() {
2083
2370
  page_id: { type: 'number', description: 'WordPress page ID' },
2084
2371
  parent_id: { type: 'string', description: 'Elementor ID of the parent container to insert into' },
2085
2372
  element: { type: 'object', description: 'Full Elementor element JSON: {elType, widgetType, settings, elements, ...}', additionalProperties: true },
2086
- position: { type: 'number', description: 'Optional: insert at this index (0-based). Omit to append at end.' }
2373
+ position: { type: 'number', description: 'Optional: insert at this index (0-based). Omit to append at end.' },
2374
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
2087
2375
  },
2088
2376
  required: ['page_id', 'parent_id', 'element']
2089
2377
  }
@@ -2122,7 +2410,8 @@ function getToolDefinitions() {
2122
2410
  updates_json: {
2123
2411
  type: 'string',
2124
2412
  description: 'Updates as a JSON string (preferred). Example: \'[{"page_id":123,"element_id":"abc","settings":{"title":"New"}}]\''
2125
- }
2413
+ },
2414
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
2126
2415
  }
2127
2416
  }
2128
2417
  },
@@ -2176,6 +2465,192 @@ function getToolDefinitions() {
2176
2465
  }
2177
2466
  }
2178
2467
  },
2468
+ {
2469
+ name: 'audit_db_health',
2470
+ description: 'Read-only WordPress database health audit. Reports: post revision count (+ top pages by revision), expired transient count, autoloaded options total size + top 10 largest, orphaned postmeta count. Includes actionable cleanup recommendations. No data is modified.',
2471
+ inputSchema: { type: 'object', properties: {} }
2472
+ },
2473
+ {
2474
+ name: 'audit_image_sizes',
2475
+ description: 'Scan Elementor pages for images using oversized WordPress sizes ("full" or "large"). Reports element ID, widget type, current size, actual file dimensions, and recommended size. Use to identify images that could be replaced with smaller WP registered sizes (medium_large, medium) to reduce page weight.',
2476
+ inputSchema: {
2477
+ type: 'object',
2478
+ properties: {
2479
+ page_id: { type: 'number', description: 'Optional: audit only this page. Omit to scan all Elementor pages.' }
2480
+ }
2481
+ }
2482
+ },
2483
+ {
2484
+ name: 'fix_image_sizes',
2485
+ description: 'Batch-fix Elementor images using oversized WP sizes (full/large). Updates image_size on image and image-box widgets to a smaller registered size. Container background images are automatically excluded. Run audit_image_sizes first, or use dry_run=true to preview changes before applying.',
2486
+ inputSchema: {
2487
+ type: 'object',
2488
+ properties: {
2489
+ page_id: { type: 'number', description: 'Optional: fix only this page. Omit to fix all Elementor pages.' },
2490
+ target_size: { type: 'string', description: 'WP registered size to apply (default: medium_large). Options: thumbnail, medium, medium_large.' },
2491
+ dry_run: { type: 'boolean', description: 'Preview changes without saving (default: false). Use true first to review.' },
2492
+ exclude_ids: { type: 'array', items: { type: 'string' }, description: 'Elementor element IDs to skip (e.g. hero images you want to keep at full size).' },
2493
+ force: { type: 'boolean', description: 'Override edit locks (default: false).' }
2494
+ }
2495
+ }
2496
+ },
2497
+ {
2498
+ name: 'search_post_content',
2499
+ description: 'Search across all published WordPress post/page content (Gutenberg, classic editor, raw HTML) for a given string. Returns matching posts with snippets showing context around each match. Use to find S3 URLs, old domains, broken shortcodes, or any text pattern in post_content.',
2500
+ inputSchema: {
2501
+ type: 'object',
2502
+ properties: {
2503
+ search: { type: 'string', description: 'Text to search for in post_content (case-insensitive).' },
2504
+ post_type: { type: 'string', description: 'Filter by post type: "post", "page", or "any" (default: any — searches posts and pages).' },
2505
+ limit: { type: 'number', description: 'Max posts to return (default: 500, max: 500).' },
2506
+ extract_urls: { type: 'boolean', description: 'Extract full URLs containing the search term. Returns per-post URLs and a deduplicated list of all unique URLs (default: false).' }
2507
+ },
2508
+ required: ['search']
2509
+ }
2510
+ },
2511
+ {
2512
+ name: 'replace_post_content',
2513
+ description: 'Bulk find-replace in WordPress post_content (Gutenberg/classic editor). Use for migrating URLs, fixing domains, replacing shortcodes. Always dry_run=true first to preview. Works on published posts and pages.',
2514
+ inputSchema: {
2515
+ type: 'object',
2516
+ properties: {
2517
+ search: { type: 'string', description: 'Text to find in post_content.' },
2518
+ replace: { type: 'string', description: 'Replacement text.' },
2519
+ dry_run: { type: 'boolean', description: 'Preview without saving (default: true). Set false to apply.' },
2520
+ regex: { type: 'boolean', description: 'Treat search as PHP regex pattern (without delimiters). Use $1, $2 for capture groups in replace. Default: false.' },
2521
+ post_type: { type: 'string', description: 'Filter: "post", "page", or "any" (default: any).' },
2522
+ limit: { type: 'number', description: 'Max posts to process (default: 500).' }
2523
+ },
2524
+ required: ['search', 'replace']
2525
+ }
2526
+ },
2527
+ {
2528
+ name: 'set_faq_schema',
2529
+ description: 'Set FAQPage JSON-LD schema on a single post. Stores in post meta and auto-injects into page head. Use for adding FAQ rich snippets to posts with Q&A content.',
2530
+ inputSchema: {
2531
+ type: 'object',
2532
+ properties: {
2533
+ post_id: { type: 'number', description: 'WordPress post ID.' },
2534
+ faqs: { type: 'array', items: { type: 'object', properties: { question: { type: 'string' }, answer: { type: 'string' } }, required: ['question', 'answer'] }, description: 'Array of {question, answer} objects.' },
2535
+ dry_run: { type: 'boolean', description: 'Preview without saving (default: true).' }
2536
+ },
2537
+ required: ['post_id', 'faqs']
2538
+ }
2539
+ },
2540
+ {
2541
+ name: 'import_faq_schemas',
2542
+ description: 'Bulk import FAQPage schemas from faq-schema-posts.json format. Matches posts by slug and stores FAQPage JSON-LD in post meta. Always dry_run first.',
2543
+ inputSchema: {
2544
+ type: 'object',
2545
+ properties: {
2546
+ posts: { type: 'array', description: 'Array of {slug, faq_schema: [{question, answer}]} objects.' },
2547
+ dry_run: { type: 'boolean', description: 'Preview without saving (default: true).' }
2548
+ },
2549
+ required: ['posts']
2550
+ }
2551
+ },
2552
+ {
2553
+ name: 'get_global_styles',
2554
+ description: 'Read Elementor global colors and fonts from the active kit. Returns system colors (Primary, Secondary, Text, Accent), custom colors, system typography, and custom typography with all properties.',
2555
+ inputSchema: { type: 'object', properties: {} }
2556
+ },
2557
+ {
2558
+ name: 'set_global_styles',
2559
+ description: 'Update Elementor global colors and/or fonts on the active kit. Supports system_colors, custom_colors, system_typography, custom_typography. Updates existing entries by ID or creates new ones. Clears Elementor CSS cache after applying. Always dry_run=true first.',
2560
+ inputSchema: {
2561
+ type: 'object',
2562
+ properties: {
2563
+ system_colors: { type: 'array', description: 'Array of {id, title?, color} — update system colors (primary, secondary, text, accent).', items: { type: 'object' } },
2564
+ custom_colors: { type: 'array', description: 'Array of {id, title?, color} — update or add custom palette colors.', items: { type: 'object' } },
2565
+ system_typography: { type: 'array', description: 'Array of {id, title?, font_family, font_weight?, font_size?, line_height?, letter_spacing?, text_transform?}.', items: { type: 'object' } },
2566
+ custom_typography: { type: 'array', description: 'Array of {id, title?, font_family, font_weight?, font_size?, line_height?, letter_spacing?, text_transform?}.', items: { type: 'object' } },
2567
+ dry_run: { type: 'boolean', description: 'Preview without saving (default: true).' }
2568
+ }
2569
+ }
2570
+ },
2571
+ {
2572
+ name: 'audit_broken_images',
2573
+ description: 'Scan all published posts and Elementor pages for broken images (non-200 HTTP responses). Checks <img> tags in post_content and image/image-box widgets + background images in Elementor data. Returns broken URLs with HTTP status, error details, and which posts are affected.',
2574
+ inputSchema: {
2575
+ type: 'object',
2576
+ properties: {
2577
+ page_id: { type: 'number', description: 'Optional: scan only this page.' },
2578
+ source: { type: 'string', description: 'What to scan: "all" (default), "elementor", or "content" (post_content only).' },
2579
+ timeout: { type: 'number', description: 'HTTP HEAD timeout in seconds per image (default: 5, max: 15).' }
2580
+ }
2581
+ }
2582
+ },
2583
+ // ── ACF Fields ──
2584
+ {
2585
+ name: 'list_acf_field_groups',
2586
+ description: 'List all ACF field groups with their fields and post type assignments. Shows field group title, key, active status, assigned post types, and all fields with type, name, label. Use to discover what custom fields exist on the site.',
2587
+ inputSchema: { type: 'object', properties: {} }
2588
+ },
2589
+ {
2590
+ name: 'get_acf_fields',
2591
+ description: 'Get all ACF field values for a specific post. Returns field names, labels, types, and current values. Supports all ACF field types including repeater and group fields.',
2592
+ inputSchema: {
2593
+ type: 'object',
2594
+ properties: {
2595
+ post_id: { type: 'number', description: 'WordPress post ID to get ACF fields for.' }
2596
+ },
2597
+ required: ['post_id']
2598
+ }
2599
+ },
2600
+ {
2601
+ name: 'set_acf_fields',
2602
+ description: 'Set/update ACF field values on a post. Accepts a JSON object of field_name: value pairs. Always dry_run=true first to preview.',
2603
+ inputSchema: {
2604
+ type: 'object',
2605
+ properties: {
2606
+ post_id: { type: 'number', description: 'WordPress post ID to update.' },
2607
+ fields: { type: 'object', description: 'Object of field_name: value pairs. Example: {"hero_title": "New Title", "show_sidebar": true}' },
2608
+ dry_run: { type: 'boolean', description: 'Preview changes without saving (default: true). Set false to apply.' }
2609
+ },
2610
+ required: ['post_id', 'fields']
2611
+ }
2612
+ },
2613
+ {
2614
+ name: 'create_acf_field_group',
2615
+ description: 'Create a new ACF field group with fields and post type assignments. Supports field types: text, textarea, wysiwyg, number, url, image, select, true_false, repeater, group.',
2616
+ inputSchema: {
2617
+ type: 'object',
2618
+ properties: {
2619
+ title: { type: 'string', description: 'Field group title.' },
2620
+ fields: { type: 'array', items: { type: 'object' }, description: 'Array of field definitions: [{label, name, type, required?, default_value?, choices?, sub_fields?}]' },
2621
+ post_types: { type: 'array', items: { type: 'string' }, description: 'Post types to assign to (e.g. ["post", "page", "physician"]).' },
2622
+ active: { type: 'boolean', description: 'Whether field group is active (default: true).' }
2623
+ },
2624
+ required: ['title', 'fields']
2625
+ }
2626
+ },
2627
+ {
2628
+ name: 'delete_acf_field',
2629
+ description: 'Remove a field from an ACF field group by field key. Use list_acf_field_groups first to find the key. Permanently deletes the field definition — existing values in post meta are NOT removed.',
2630
+ inputSchema: {
2631
+ type: 'object',
2632
+ properties: {
2633
+ field_key: { type: 'string', description: 'ACF field key to delete (e.g. "field_64a1b2c3d4e5f").' }
2634
+ },
2635
+ required: ['field_key']
2636
+ }
2637
+ },
2638
+ // ── Audits ──
2639
+ {
2640
+ name: 'audit_orphan_pages',
2641
+ description: 'Find pages with no internal links pointing to them. Cross-references all link targets (Elementor data + post content + nav menus) against all published pages. Orphan pages are hard for search engines to discover.',
2642
+ inputSchema: { type: 'object', properties: {} }
2643
+ },
2644
+ {
2645
+ name: 'audit_h1',
2646
+ description: 'Scan all Elementor pages for H1 heading issues. Every page should have exactly one H1. Flags pages with missing H1 (bad for SEO) or multiple H1s (confusing hierarchy).',
2647
+ inputSchema: { type: 'object', properties: {} }
2648
+ },
2649
+ {
2650
+ name: 'audit_redirect_chains',
2651
+ description: 'Detect redirect chains (A→B→C) among internal URLs. Each extra hop slows page load and dilutes SEO link equity. Checks up to 200 internal URLs via HTTP HEAD with redirect following.',
2652
+ inputSchema: { type: 'object', properties: {} }
2653
+ },
2179
2654
  // ── Section Operations ──
2180
2655
  {
2181
2656
  name: 'copy_section',
@@ -2186,7 +2661,8 @@ function getToolDefinitions() {
2186
2661
  source_id: { type: 'number', description: 'Page or template ID to copy FROM' },
2187
2662
  section_index: { type: 'number', description: 'Zero-based index of the section to copy (use list_sections to find it)' },
2188
2663
  target_id: { type: 'number', description: 'Page or template ID to copy TO' },
2189
- position: { type: 'string', description: 'Where to place: "end" (default), "start", or numeric index' }
2664
+ position: { type: 'string', description: 'Where to place: "end" (default), "start", or numeric index' },
2665
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
2190
2666
  },
2191
2667
  required: ['source_id', 'section_index', 'target_id']
2192
2668
  }
@@ -2200,7 +2676,8 @@ function getToolDefinitions() {
2200
2676
  target_id: { type: 'number', description: 'Page or template ID to append to' },
2201
2677
  section: { type: 'object', description: 'Full Elementor section object (must have elType and elements)' },
2202
2678
  position: { type: 'string', description: 'Where to place: "end" (default), "start", or numeric index' },
2203
- regenerate_ids: { type: 'boolean', description: 'Regenerate all element IDs to avoid collisions (default: true)' }
2679
+ regenerate_ids: { type: 'boolean', description: 'Regenerate all element IDs to avoid collisions (default: true)' },
2680
+ force: { type: 'boolean', description: 'Override edit locks (default: false)' }
2204
2681
  },
2205
2682
  required: ['target_id', 'section']
2206
2683
  }
@@ -2373,6 +2850,11 @@ function getToolDefinitions() {
2373
2850
  required: ['enabled']
2374
2851
  }
2375
2852
  },
2853
+ {
2854
+ name: 'scan_shortcodes',
2855
+ description: 'Scan all published pages/posts for NVBC shortcode usage ([nvbc_categories], [nvbc_list_category], [nvbc_excerpt], [nvbc_category_list]). Shows which pages use which shortcodes and where (post_content vs Elementor data). Use this to determine if shortcodes are safe to disable. No AI tokens used — pure PHP scan.',
2856
+ inputSchema: { type: 'object', properties: {} }
2857
+ },
2376
2858
  {
2377
2859
  name: 'get_site_profile',
2378
2860
  description: 'Get the site profile — stored preferences, URLs, design conventions, and team info. Use this at the start of a session to understand site context.',
@@ -2441,6 +2923,31 @@ function getToolDefinitions() {
2441
2923
  type: 'object',
2442
2924
  properties: {}
2443
2925
  }
2926
+ },
2927
+ {
2928
+ name: 'clean_globals',
2929
+ description: 'Remove __globals__ references pointing to color/font IDs that don\'t exist in the current Elementor kit. Fixes foreign CSS rendering from imported content. Dry-run by default.',
2930
+ inputSchema: {
2931
+ type: 'object',
2932
+ properties: {
2933
+ page_id: { type: 'number', description: 'Page ID to clean' },
2934
+ dry_run: { type: 'boolean', description: 'Preview changes without saving (default: true)' },
2935
+ force: { type: 'boolean', description: 'Override edit lock' }
2936
+ },
2937
+ required: ['page_id']
2938
+ }
2939
+ },
2940
+ {
2941
+ name: 'set_front_page',
2942
+ description: 'Set a WordPress page as the site\'s static front page. Optionally set a posts page too.',
2943
+ inputSchema: {
2944
+ type: 'object',
2945
+ properties: {
2946
+ page_id: { type: 'number', description: 'Page ID to set as front page' },
2947
+ blog_page_id: { type: 'number', description: 'Page ID for blog/posts page (optional)' }
2948
+ },
2949
+ required: ['page_id']
2950
+ }
2444
2951
  }
2445
2952
  ];
2446
2953
  }
@@ -2494,7 +3001,7 @@ async function handleToolCall(name, args) {
2494
3001
  }
2495
3002
 
2496
3003
  case 'set_design_tokens': {
2497
- const r = await apiCall('/design-tokens', 'POST', { tokens: args.tokens });
3004
+ const r = await apiCall('/design-tokens', 'POST', { tokens: args.tokens, mode: args.mode || 'patch' });
2498
3005
  if (r.success) {
2499
3006
  tokenCache.data = null; // Invalidate cache
2500
3007
  return ok(`Tokens saved successfully.\n\nUpdated tokens:\n${JSON.stringify(r.tokens, null, 2)}`);
@@ -2624,7 +3131,9 @@ async function handleToolCall(name, args) {
2624
3131
  }
2625
3132
 
2626
3133
  case 'import_template': {
2627
- const r = await apiCall(`/pages/${args.page_id}/import`, 'POST', { template: args.template, mode: args.mode || 'replace' });
3134
+ const body = { template: args.template, mode: args.mode || 'replace' };
3135
+ if (args.force) body.force = true;
3136
+ const r = await apiCall(`/pages/${args.page_id}/import`, 'POST', body);
2628
3137
  return ok(r.success ? `Imported!\n${r.sections} sections, ${r.total_elements} elements (${r.mode})\n\n${r.preview_url}` : `Error: ${r.message}`);
2629
3138
  }
2630
3139
 
@@ -2686,12 +3195,14 @@ async function handleToolCall(name, args) {
2686
3195
  }
2687
3196
 
2688
3197
  case 'add_component': {
2689
- const r = await apiCall(`/pages/${args.page_id}/add-component`, 'POST', {
3198
+ const body = {
2690
3199
  category: args.category,
2691
3200
  name: args.name,
2692
3201
  variables: args.variables || {},
2693
3202
  position: args.position || 'append'
2694
- });
3203
+ };
3204
+ if (args.force) body.force = true;
3205
+ const r = await apiCall(`/pages/${args.page_id}/add-component`, 'POST', body);
2695
3206
  if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
2696
3207
  let msg = `Added ${r.component.category}/${r.component.name}!\n` +
2697
3208
  `ID: ${r.component.id}\nTotal sections: ${r.total_sections}\nPreview: ${r.preview_url}`;
@@ -2700,10 +3211,12 @@ async function handleToolCall(name, args) {
2700
3211
  }
2701
3212
 
2702
3213
  case 'build_page': {
2703
- const r = await apiCall(`/pages/${args.page_id}/build`, 'POST', {
3214
+ const body = {
2704
3215
  sections: args.sections,
2705
3216
  mode: args.mode || 'replace'
2706
- });
3217
+ };
3218
+ if (args.force) body.force = true;
3219
+ const r = await apiCall(`/pages/${args.page_id}/build`, 'POST', body);
2707
3220
  if (!r.success) return ok(`Build failed: ${r.message || 'Unknown error'}`);
2708
3221
  let buildMsg = `Page Built!\nSections: ${r.sections_built}\nElements: ${r.total_elements}\nMode: ${r.mode}\n\nPreview: ${r.preview_url}\nEdit: ${r.edit_url}`;
2709
3222
  if (r.post_placement_instructions?.length) {
@@ -2731,6 +3244,7 @@ async function handleToolCall(name, args) {
2731
3244
  const body = {};
2732
3245
  if (args.index !== undefined) body.index = args.index;
2733
3246
  if (args.section_id) body.section_id = args.section_id;
3247
+ if (args.force) body.force = true;
2734
3248
  const r = await apiCall(`/pages/${args.page_id}/remove-section`, 'POST', body);
2735
3249
  if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
2736
3250
  return ok(`Removed section${r.removed?.id ? ` (ID: ${r.removed.id})` : ''}.\nRemaining sections: ${r.remaining_sections}`);
@@ -2741,6 +3255,7 @@ async function handleToolCall(name, args) {
2741
3255
  if (args.move) body.move = args.move;
2742
3256
  else if (args.swap) body.swap = args.swap;
2743
3257
  else if (args.order) body.order = args.order;
3258
+ if (args.force) body.force = true;
2744
3259
  const r = await apiCall(`/pages/${args.page_id}/reorder`, 'POST', body);
2745
3260
  if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
2746
3261
  let msg = `Sections reordered! (${r.sections} total)\n`;
@@ -2775,6 +3290,11 @@ async function handleToolCall(name, args) {
2775
3290
  let result = line + '\n';
2776
3291
  if (el.children) {
2777
3292
  el.children.forEach(c => { result += formatTree(c, indent + 1); });
3293
+ } else if (el.children_ids) {
3294
+ el.children_ids.forEach(c => {
3295
+ const label = c.widget ? `[${c.widget}]` : `<${c.type}>`;
3296
+ result += ' '.repeat(indent + 1) + `${label} id:${c.id}\n`;
3297
+ });
2778
3298
  } else if (el.children_count) {
2779
3299
  result += ' '.repeat(indent + 1) + `(${el.children_count} children, increase depth to see)\n`;
2780
3300
  }
@@ -2789,14 +3309,67 @@ async function handleToolCall(name, args) {
2789
3309
  return ok(msg);
2790
3310
  }
2791
3311
 
3312
+ case 'get_section_json': {
3313
+ const params = new URLSearchParams();
3314
+ if (args.section_id) params.set('section_id', args.section_id);
3315
+ if (args.section_index !== undefined) params.set('section_index', args.section_index);
3316
+ const qs = params.toString() ? `?${params.toString()}` : '';
3317
+ const r = await apiCall(`/pages/${args.page_id}/section-json${qs}`);
3318
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
3319
+ return ok(`Section [${r.section_index}] (ID: ${r.section_id}) — ${r.widgets} widgets\n\n${JSON.stringify(r.elementor_data, null, 2)}`);
3320
+ }
3321
+
3322
+ case 'update_children': {
3323
+ const body = {
3324
+ parent_id: args.parent_id,
3325
+ set_properties: args.set_properties || {},
3326
+ remove_properties: args.remove_properties || [],
3327
+ force: args.force || false,
3328
+ };
3329
+ if (args.filter) body.filter = args.filter;
3330
+ const r = await apiCall(`/pages/${args.page_id}/update-children`, 'POST', body);
3331
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
3332
+ let msg = `Updated ${r.descendants_updated} descendants of ${r.parent_id} on page ${r.page_id}.`;
3333
+ if (r.properties_set.length) msg += `\nSet: ${r.properties_set.join(', ')}`;
3334
+ if (r.properties_removed.length) msg += `\nRemoved: ${r.properties_removed.join(', ')}`;
3335
+ if (r.filter) msg += `\nFilter: ${JSON.stringify(r.filter)}`;
3336
+ return ok(msg);
3337
+ }
3338
+
3339
+ case 'check_page_lock': {
3340
+ const r = await apiCall(`/pages/${args.page_id}/lock-status`);
3341
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
3342
+ if (!r.locked) return ok(`Page ${args.page_id}: Not locked — safe to edit.`);
3343
+ let msg = `Page ${args.page_id}: LOCKED\n`;
3344
+ if (r.wp_lock) {
3345
+ msg += ` WP Editor: ${r.wp_lock.user} (${r.wp_lock.age_seconds}s ago)\n`;
3346
+ }
3347
+ if (r.mcp_lock) {
3348
+ msg += ` MCP Tool: ${r.mcp_lock.tool} (started ${r.mcp_lock.time})\n`;
3349
+ }
3350
+ msg += `\nUse force=true on write operations to override.`;
3351
+ return ok(msg);
3352
+ }
3353
+
2792
3354
  case 'remove_element': {
2793
- const r = await apiCall(`/pages/${args.page_id}/remove-element`, 'POST', {
2794
- element_id: args.element_id
2795
- });
3355
+ const body = { element_id: args.element_id };
3356
+ if (args.force) body.force = true;
3357
+ const r = await apiCall(`/pages/${args.page_id}/remove-element`, 'POST', body);
2796
3358
  if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
2797
3359
  return ok(`Removed ${r.removed.type} (ID: ${r.removed.id}).\nRemaining sections: ${r.remaining_sections}`);
2798
3360
  }
2799
3361
 
3362
+ case 'move_element': {
3363
+ const r = await apiCall(`/pages/${args.page_id}/move-element`, 'POST', {
3364
+ element_id: args.element_id,
3365
+ target_parent_id: args.target_parent_id,
3366
+ position: args.position,
3367
+ force: parseBool(args.force),
3368
+ });
3369
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
3370
+ return ok(`Moved ${r.element_type} (${r.element_id}) to parent ${r.target_parent_id}${r.position !== null ? ` at position ${r.position}` : ''}`);
3371
+ }
3372
+
2800
3373
  case 'update_element': {
2801
3374
  // Resolve settings: prefer settings_json (string) for reliable MCP transport, fall back to settings (object)
2802
3375
  let settings = null;
@@ -2816,10 +3389,14 @@ async function handleToolCall(name, args) {
2816
3389
  }
2817
3390
  const cleanSettings = deepStripCDATA(settings);
2818
3391
  try {
2819
- const r = await apiCall(`/pages/${args.page_id}/update-element`, 'POST', {
2820
- element_id: args.element_id,
2821
- settings: cleanSettings
2822
- });
3392
+ const body = { settings: cleanSettings };
3393
+ if (args.element_id) body.element_id = args.element_id;
3394
+ if (args.element_path) body.element_path = args.element_path;
3395
+ if (args.force) body.force = true;
3396
+ if (!body.element_id && !body.element_path) {
3397
+ return ok('Failed: Provide element_id or element_path.');
3398
+ }
3399
+ const r = await apiCall(`/pages/${args.page_id}/update-element`, 'POST', body);
2823
3400
  if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
2824
3401
  return ok(`Element ${r.element_id} updated on page ${r.page_id}.\nUpdated keys: ${r.updated_keys.join(', ')}`);
2825
3402
  } catch (err) {
@@ -2991,6 +3568,7 @@ async function handleToolCall(name, args) {
2991
3568
  dry_run: parseBool(args.dry_run, true), // default TRUE (safe)
2992
3569
  };
2993
3570
  if (args.page_id) body.page_id = args.page_id;
3571
+ if (args.force) body.force = true;
2994
3572
  const r = await apiCall('/find-replace', 'POST', body);
2995
3573
  if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
2996
3574
 
@@ -3014,12 +3592,14 @@ async function handleToolCall(name, args) {
3014
3592
  }
3015
3593
 
3016
3594
  case 'copy_element': {
3017
- const r = await apiCall('/copy-element', 'POST', {
3595
+ const body = {
3018
3596
  source_page_id: args.source_page_id,
3019
3597
  element_id: args.element_id,
3020
3598
  target_page_id: args.target_page_id,
3021
3599
  position: args.position || 'append',
3022
- });
3600
+ };
3601
+ if (args.force) body.force = true;
3602
+ const r = await apiCall('/copy-element', 'POST', body);
3023
3603
  if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
3024
3604
  let msg = `Element copied!\n`;
3025
3605
  msg += `Source: page ${r.source_page_id}, element ${r.original_id} (${r.element_type}`;
@@ -3350,6 +3930,19 @@ async function handleToolCall(name, args) {
3350
3930
  );
3351
3931
  }
3352
3932
 
3933
+ case 'display_conditions': {
3934
+ if (args.conditions) {
3935
+ const r = await apiCall(`/templates/${args.template_id}/display-conditions`, 'POST', {
3936
+ conditions: args.conditions,
3937
+ });
3938
+ return ok(`Set ${r.conditions.length} condition(s) on "${r.title}" (ID: ${r.template_id}):\n${r.conditions.join('\n')}`);
3939
+ } else {
3940
+ const r = await apiCall(`/templates/${args.template_id}/display-conditions`);
3941
+ const conds = r.conditions && r.conditions.length ? r.conditions.join('\n') : '(none)';
3942
+ return ok(`Display conditions for "${r.title}" (ID: ${r.template_id}):\n${conds}`);
3943
+ }
3944
+ }
3945
+
3353
3946
  case 'audit_placeholders': {
3354
3947
  const qs = args.include_templates ? '?include_templates=1' : '';
3355
3948
  const r = await apiCall(`/audit-placeholders${qs}`);
@@ -3423,11 +4016,13 @@ async function handleToolCall(name, args) {
3423
4016
  // ── Element Operations (v3.6.0) ──
3424
4017
 
3425
4018
  case 'add_element': {
3426
- const r = await apiCall(`/pages/${args.page_id}/add-element`, 'POST', {
4019
+ const body = {
3427
4020
  parent_id: args.parent_id,
3428
4021
  element: args.element,
3429
4022
  position: args.position
3430
- });
4023
+ };
4024
+ if (args.force) body.force = true;
4025
+ const r = await apiCall(`/pages/${args.page_id}/add-element`, 'POST', body);
3431
4026
  if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
3432
4027
  return ok(`Element added!\nPage: ${r.page_id}\nParent: ${r.parent_id}\nNew element ID: ${r.element_id}${r.position !== null && r.position !== undefined ? `\nPosition: ${r.position}` : ' (appended)'}`);
3433
4028
  }
@@ -3481,9 +4076,9 @@ async function handleToolCall(name, args) {
3481
4076
  settings: deepStripCDATA(settings)
3482
4077
  };
3483
4078
  });
3484
- const r = await apiCall('/batch-update-elements', 'POST', {
3485
- updates: cleanUpdates
3486
- });
4079
+ const batchBody = { updates: cleanUpdates };
4080
+ if (args.force) batchBody.force = true;
4081
+ const r = await apiCall('/batch-update-elements', 'POST', batchBody);
3487
4082
  if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
3488
4083
  let out = `=== Batch Update ===\nTotal: ${r.total} | Succeeded: ${r.succeeded} | Failed: ${r.failed}\n`;
3489
4084
  if (r.failed > 0) {
@@ -3650,13 +4245,396 @@ async function handleToolCall(name, args) {
3650
4245
  }
3651
4246
  }
3652
4247
 
4248
+ case 'audit_db_health': {
4249
+ const r = await apiCall('/audit-db-health');
4250
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4251
+ let out = `=== DB HEALTH AUDIT ===\n`;
4252
+ out += `\nRevisions: ${r.revisions.count}`;
4253
+ if (r.revisions.top_pages?.length) {
4254
+ out += ` (top pages by revision count)\n`;
4255
+ r.revisions.top_pages.forEach(p => {
4256
+ out += ` "${p.post_title}" (ID:${p.ID}) — ${p.revision_count} revisions\n`;
4257
+ });
4258
+ } else out += '\n';
4259
+ out += `\nExpired Transients: ${r.expired_transients.count}\n`;
4260
+ out += `\nAutoloaded Options: ${r.autoloaded_options.count} options, ${r.autoloaded_options.total_size_kb} KB total\n`;
4261
+ if (r.autoloaded_options.top_10?.length) {
4262
+ out += ` Top 10 largest:\n`;
4263
+ r.autoloaded_options.top_10.forEach(o => {
4264
+ out += ` ${o.size_kb} KB — ${o.name}\n`;
4265
+ });
4266
+ }
4267
+ out += `\nOrphaned Postmeta: ${r.orphaned_postmeta.count} rows\n`;
4268
+ if (r.recommendations?.length) {
4269
+ out += `\nRecommendations:\n`;
4270
+ r.recommendations.forEach(rec => { out += ` ⚠ ${rec}\n`; });
4271
+ } else {
4272
+ out += `\n✓ No critical issues found.\n`;
4273
+ }
4274
+ out += `\nNote: ${r.note}`;
4275
+ return ok(out);
4276
+ }
4277
+
4278
+ case 'audit_image_sizes': {
4279
+ const params = new URLSearchParams();
4280
+ if (args.page_id) params.set('page_id', args.page_id);
4281
+ const qs = params.toString() ? `?${params}` : '';
4282
+ const r = await apiCall(`/audit-image-sizes${qs}`);
4283
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4284
+ const s = r.summary;
4285
+ let out = `=== IMAGE SIZE AUDIT ===\n`;
4286
+ out += `Pages scanned: ${s.pages_scanned} | Images checked: ${s.images_checked}\n`;
4287
+ out += `Pages with issues: ${s.pages_with_issues} | Total flagged: ${s.total_flagged}\n`;
4288
+ if (!r.flagged_pages?.length) {
4289
+ out += `\n✓ No oversized images found.\n`;
4290
+ } else {
4291
+ r.flagged_pages.forEach(p => {
4292
+ out += `\n[${p.page_id}] ${p.page_title}\n ${p.url}\n`;
4293
+ p.images.forEach(img => {
4294
+ out += ` • [${img.element_id}] ${img.widget_type} — size: ${img.current_size}`;
4295
+ if (img.file_dimensions) out += ` | file: ${img.file_dimensions}`;
4296
+ if (img.file_size_kb) out += ` (${img.file_size_kb} KB)`;
4297
+ out += `\n → ${img.recommended}\n`;
4298
+ if (img.image_url) out += ` ${img.image_url}\n`;
4299
+ });
4300
+ });
4301
+ }
4302
+ out += `\nNote: ${r.note}`;
4303
+ return ok(out);
4304
+ }
4305
+
4306
+ case 'fix_image_sizes': {
4307
+ const body = {};
4308
+ if (args.page_id) body.page_id = args.page_id;
4309
+ if (args.target_size) body.target_size = args.target_size;
4310
+ if (args.dry_run !== undefined) body.dry_run = args.dry_run;
4311
+ if (args.exclude_ids?.length) body.exclude_ids = args.exclude_ids;
4312
+ if (args.force) body.force = args.force;
4313
+ const r = await apiCall('/fix-image-sizes', 'POST', body);
4314
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4315
+ const s = r.summary;
4316
+ let out = r.dry_run ? `=== IMAGE SIZE FIX (DRY RUN) ===\n` : `=== IMAGE SIZE FIX ===\n`;
4317
+ out += `Target size: ${r.target_size}\n`;
4318
+ out += `Pages updated: ${s.pages_updated} | Images fixed: ${s.total_images_fixed}`;
4319
+ if (s.pages_skipped) out += ` | Pages skipped (locked): ${s.pages_skipped}`;
4320
+ out += '\n';
4321
+ if (r.pages?.length) {
4322
+ r.pages.forEach(p => {
4323
+ out += `\n[${p.page_id}] ${p.page_title}${p.saved ? '' : ' (not saved)'}\n`;
4324
+ p.changes.forEach(c => {
4325
+ out += ` • [${c.element_id}] ${c.widget_type}: ${c.from} → ${c.to}\n`;
4326
+ });
4327
+ });
4328
+ }
4329
+ if (r.skipped?.length) {
4330
+ out += `\nSkipped (locked):\n`;
4331
+ r.skipped.forEach(p => { out += ` • [${p.page_id}] ${p.title}: ${p.reason}\n`; });
4332
+ }
4333
+ out += `\n${r.note}`;
4334
+ return ok(out);
4335
+ }
4336
+
4337
+ case 'search_post_content': {
4338
+ const params = new URLSearchParams();
4339
+ params.set('search', args.search);
4340
+ if (args.post_type) params.set('post_type', args.post_type);
4341
+ if (args.limit) params.set('limit', args.limit);
4342
+ if (args.extract_urls) params.set('extract_urls', '1');
4343
+ const r = await apiCall(`/search-post-content?${params}`);
4344
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4345
+ const s = r.summary;
4346
+ let out = `=== SEARCH POST CONTENT ===\nSearch: "${r.search}"\nPosts matched: ${s.posts_matched} | Total matches: ${s.total_matches}\n`;
4347
+ if (s.unique_url_count !== undefined) out += `Unique URLs: ${s.unique_url_count}\n`;
4348
+ if (r.matches?.length) {
4349
+ r.matches.forEach(m => {
4350
+ out += `\n[${m.post_id}] ${m.post_title} (${m.post_type}) — ${m.match_count} match(es)\n`;
4351
+ out += ` URL: ${m.url}\n`;
4352
+ if (m.extracted_urls?.length) {
4353
+ m.extracted_urls.forEach(u => { out += ` 🔗 ${u}\n`; });
4354
+ } else {
4355
+ m.snippets.forEach(sn => { out += ` > ${sn}\n`; });
4356
+ }
4357
+ });
4358
+ } else {
4359
+ out += '\nNo matches found.';
4360
+ }
4361
+ if (r.unique_urls?.length) {
4362
+ out += `\n\n=== ALL UNIQUE URLs (${r.unique_urls.length}) ===\n`;
4363
+ r.unique_urls.forEach(u => { out += `${u}\n`; });
4364
+ }
4365
+ return ok(out);
4366
+ }
4367
+
4368
+ case 'replace_post_content': {
4369
+ const body = { search: args.search, replace: args.replace };
4370
+ if (args.dry_run !== undefined) body.dry_run = args.dry_run;
4371
+ if (args.regex) body.regex = args.regex;
4372
+ if (args.post_type) body.post_type = args.post_type;
4373
+ if (args.limit) body.limit = args.limit;
4374
+ const r = await apiCall('/replace-post-content', 'POST', body);
4375
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4376
+ const s = r.summary;
4377
+ let out = r.dry_run ? `=== REPLACE POST CONTENT (DRY RUN) ===\n` : `=== REPLACE POST CONTENT ===\n`;
4378
+ out += `Search: "${r.search}"\nReplace: "${r.replace}"\n`;
4379
+ out += `Posts changed: ${s.posts_changed} | Total replacements: ${s.total_replacements}\n`;
4380
+ if (r.changed_posts?.length) {
4381
+ r.changed_posts.forEach(p => {
4382
+ out += ` [${p.post_id}] ${p.post_title} (${p.post_type}) — ${p.replacements} replacement(s)\n`;
4383
+ });
4384
+ }
4385
+ out += `\n${r.note}`;
4386
+ return ok(out);
4387
+ }
4388
+
4389
+ case 'set_faq_schema': {
4390
+ const body = { post_id: args.post_id, faqs: args.faqs };
4391
+ if (args.dry_run !== undefined) body.dry_run = args.dry_run;
4392
+ const r = await apiCall('/set-faq-schema', 'POST', body);
4393
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4394
+ let out = r.dry_run ? `=== SET FAQ SCHEMA (DRY RUN) ===\n` : `=== SET FAQ SCHEMA ===\n`;
4395
+ out += `Post: [${r.post_id}] ${r.post_title}\n`;
4396
+ out += `Questions: ${r.questions}\n`;
4397
+ out += `\n${r.note}`;
4398
+ return ok(out);
4399
+ }
4400
+
4401
+ case 'import_faq_schemas': {
4402
+ const body = { posts: args.posts };
4403
+ if (args.dry_run !== undefined) body.dry_run = args.dry_run;
4404
+ const r = await apiCall('/import-faq-schemas', 'POST', body);
4405
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4406
+ const s = r.summary;
4407
+ let out = r.dry_run ? `=== IMPORT FAQ SCHEMAS (DRY RUN) ===\n` : `=== IMPORT FAQ SCHEMAS ===\n`;
4408
+ out += `Applied: ${s.applied} | Skipped: ${s.skipped} | Not found: ${s.not_found}\n`;
4409
+ if (r.results?.length) {
4410
+ r.results.forEach(p => {
4411
+ if (p.status === 'not_found') {
4412
+ out += ` ❌ ${p.slug} — post not found\n`;
4413
+ } else {
4414
+ out += ` ✅ [${p.post_id}] ${p.title} — ${p.questions} Q&As (${p.status})\n`;
4415
+ }
4416
+ });
4417
+ }
4418
+ out += `\n${r.note}`;
4419
+ return ok(out);
4420
+ }
4421
+
4422
+ case 'get_global_styles': {
4423
+ const r = await apiCall('/global-styles');
4424
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4425
+ let out = `=== ELEMENTOR GLOBAL STYLES ===\nKit ID: ${r.kit_id}\n`;
4426
+
4427
+ out += `\n--- System Colors (${r.system_colors.length}) ---\n`;
4428
+ r.system_colors.forEach(c => { out += ` [${c.id}] ${c.title}: ${c.color}\n`; });
4429
+
4430
+ out += `\n--- Custom Colors (${r.custom_colors.length}) ---\n`;
4431
+ if (r.custom_colors.length) {
4432
+ r.custom_colors.forEach(c => { out += ` [${c.id}] ${c.title}: ${c.color}\n`; });
4433
+ } else { out += ' (none)\n'; }
4434
+
4435
+ out += `\n--- System Typography (${r.system_typography.length}) ---\n`;
4436
+ r.system_typography.forEach(t => {
4437
+ out += ` [${t.id}] ${t.title}: ${t.font_family || '(unset)'}`;
4438
+ if (t.font_weight) out += ` ${t.font_weight}`;
4439
+ if (t.font_size) out += ` ${typeof t.font_size === 'object' ? t.font_size.size + t.font_size.unit : t.font_size}`;
4440
+ out += '\n';
4441
+ });
4442
+
4443
+ out += `\n--- Custom Typography (${r.custom_typography.length}) ---\n`;
4444
+ if (r.custom_typography.length) {
4445
+ r.custom_typography.forEach(t => {
4446
+ out += ` [${t.id}] ${t.title}: ${t.font_family || '(unset)'}`;
4447
+ if (t.font_weight) out += ` ${t.font_weight}`;
4448
+ out += '\n';
4449
+ });
4450
+ } else { out += ' (none)\n'; }
4451
+
4452
+ return ok(out);
4453
+ }
4454
+
4455
+ case 'set_global_styles': {
4456
+ const body = {};
4457
+ if (args.system_colors) body.system_colors = args.system_colors;
4458
+ if (args.custom_colors) body.custom_colors = args.custom_colors;
4459
+ if (args.system_typography) body.system_typography = args.system_typography;
4460
+ if (args.custom_typography) body.custom_typography = args.custom_typography;
4461
+ if (args.dry_run !== undefined) body.dry_run = args.dry_run;
4462
+ const r = await apiCall('/global-styles', 'POST', body);
4463
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4464
+ let out = r.dry_run ? `=== SET GLOBAL STYLES (DRY RUN) ===\n` : `=== SET GLOBAL STYLES ===\n`;
4465
+ out += `Kit ID: ${r.kit_id}\n`;
4466
+ if (r.changes?.length) {
4467
+ r.changes.forEach(c => { out += ` • ${c}\n`; });
4468
+ } else {
4469
+ out += ' No changes detected.\n';
4470
+ }
4471
+ out += `\n${r.note}`;
4472
+ return ok(out);
4473
+ }
4474
+
4475
+ case 'audit_broken_images': {
4476
+ const params = new URLSearchParams();
4477
+ if (args.page_id) params.set('page_id', args.page_id);
4478
+ if (args.source) params.set('source', args.source);
4479
+ if (args.timeout) params.set('timeout', args.timeout);
4480
+ const r = await apiCall(`/audit-broken-images?${params}`);
4481
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4482
+ const s = r.summary;
4483
+ let out = `=== BROKEN IMAGE AUDIT ===\n`;
4484
+ out += `Images checked: ${s.unique_images_checked} | Broken: ${s.broken_images} | Posts affected: ${s.posts_affected}\n`;
4485
+ if (r.broken?.length) {
4486
+ r.broken.forEach(b => {
4487
+ out += `\n❌ ${b.url}\n`;
4488
+ out += ` Status: ${b.error || ('HTTP ' + b.status)}\n`;
4489
+ out += ` Found in:\n`;
4490
+ b.found_in.forEach(loc => {
4491
+ out += ` [${loc.post_id}] ${loc.post_title} (${loc.source})\n`;
4492
+ });
4493
+ });
4494
+ }
4495
+ out += `\n${r.note}`;
4496
+ return ok(out);
4497
+ }
4498
+
4499
+ // ── ACF Fields ──
4500
+
4501
+ case 'list_acf_field_groups': {
4502
+ const r = await apiCall('/acf-field-groups');
4503
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4504
+ let out = `=== ACF FIELD GROUPS (${r.count}) ===\n`;
4505
+ if (!r.field_groups?.length) {
4506
+ out += 'No ACF field groups found.\n';
4507
+ } else {
4508
+ r.field_groups.forEach(g => {
4509
+ out += `\n[${g.key}] ${g.title} ${g.active ? '' : '(INACTIVE)'}\n`;
4510
+ if (g.post_types?.length) out += ` Post types: ${g.post_types.join(', ')}\n`;
4511
+ if (g.fields?.length) {
4512
+ g.fields.forEach(f => {
4513
+ out += ` • ${f.name} (${f.type}) — "${f.label}"${f.required ? ' *required' : ''}\n`;
4514
+ if (f.sub_fields?.length) {
4515
+ f.sub_fields.forEach(sf => {
4516
+ out += ` └ ${sf.name} (${sf.type}) — "${sf.label}"\n`;
4517
+ });
4518
+ }
4519
+ });
4520
+ }
4521
+ });
4522
+ }
4523
+ return ok(out);
4524
+ }
4525
+
4526
+ case 'get_acf_fields': {
4527
+ const r = await apiCall(`/acf-fields?post_id=${args.post_id}`);
4528
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4529
+ let out = `=== ACF FIELDS: ${r.post_title} (ID:${r.post_id}) ===\n`;
4530
+ if (!r.fields?.length) {
4531
+ out += 'No ACF fields found for this post.\n';
4532
+ } else {
4533
+ r.fields.forEach(f => {
4534
+ const val = typeof f.value === 'object' ? JSON.stringify(f.value, null, 2) : (f.value ?? '(empty)');
4535
+ out += `\n ${f.name} (${f.type}): ${val}\n`;
4536
+ });
4537
+ }
4538
+ return ok(out);
4539
+ }
4540
+
4541
+ case 'set_acf_fields': {
4542
+ const body = { post_id: args.post_id, fields: args.fields };
4543
+ if (args.dry_run !== undefined) body.dry_run = args.dry_run;
4544
+ const r = await apiCall('/acf-fields', 'POST', body);
4545
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4546
+ let out = r.dry_run ? `=== SET ACF FIELDS (DRY RUN) ===\n` : `=== SET ACF FIELDS ===\n`;
4547
+ out += `Post: [${r.post_id}] ${r.post_title}\nUpdated: ${r.updated} field(s)\n`;
4548
+ if (r.results?.length) {
4549
+ r.results.forEach(f => {
4550
+ const icon = (f.status === 'updated' || f.status === 'would_update') ? '+' : 'x';
4551
+ out += ` [${icon}] ${f.name}: ${JSON.stringify(f.old_value)} -> ${JSON.stringify(f.new_value)} (${f.status})\n`;
4552
+ });
4553
+ }
4554
+ out += `\n${r.note}`;
4555
+ return ok(out);
4556
+ }
4557
+
4558
+ case 'create_acf_field_group': {
4559
+ const body = { title: args.title, fields: args.fields };
4560
+ if (args.post_types) body.post_types = args.post_types;
4561
+ if (args.active !== undefined) body.active = args.active;
4562
+ const r = await apiCall('/acf-field-groups', 'POST', body);
4563
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4564
+ let out = `=== ACF FIELD GROUP CREATED ===\n`;
4565
+ out += `Key: ${r.key}\nTitle: ${r.title}\nFields: ${r.field_count}\n`;
4566
+ if (r.post_types?.length) out += `Post types: ${r.post_types.join(', ')}\n`;
4567
+ return ok(out);
4568
+ }
4569
+
4570
+ case 'delete_acf_field': {
4571
+ const r = await apiCall(`/acf-field?field_key=${encodeURIComponent(args.field_key)}`, 'DELETE');
4572
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
4573
+ return ok(`Deleted ACF field: "${r.label}" (${r.name}) [${r.field_key}] from group "${r.group_title}"`);
4574
+ }
4575
+
4576
+ case 'audit_orphan_pages': {
4577
+ const r = await apiCall('/audit-orphan-pages');
4578
+ let msg = `=== ORPHAN PAGES ===\nTotal pages: ${r.total_pages} | Linked: ${r.linked_pages} | Orphans: ${r.orphan_count}\n`;
4579
+ if (r.orphans && r.orphans.length) {
4580
+ msg += '\nOrphan pages (no incoming internal links):\n';
4581
+ for (const p of r.orphans) {
4582
+ msg += ` - [${p.post_type}] ${p.title} (ID ${p.post_id})\n ${p.permalink}\n`;
4583
+ }
4584
+ } else {
4585
+ msg += '\nAll pages have incoming links — no orphans.\n';
4586
+ }
4587
+ return ok(msg);
4588
+ }
4589
+
4590
+ case 'audit_h1': {
4591
+ const r = await apiCall('/audit-h1');
4592
+ let msg = `=== H1 HEADING AUDIT ===\nPages scanned: ${r.pages_scanned} | Missing H1: ${r.missing_h1} | Duplicate H1: ${r.duplicate_h1}\n`;
4593
+ if (r.results && r.results.length) {
4594
+ msg += '\nPages with H1 issues:\n';
4595
+ for (const p of r.results) {
4596
+ const label = p.issue === 'missing' ? 'MISSING' : `DUPLICATE (${p.h1_count})`;
4597
+ msg += ` - ${p.title} (ID ${p.post_id}) — ${label}`;
4598
+ if (p.h1_texts && p.h1_texts.length) {
4599
+ msg += ': ' + p.h1_texts.join(' | ');
4600
+ }
4601
+ msg += '\n';
4602
+ }
4603
+ } else {
4604
+ msg += '\nAll pages have exactly one H1.\n';
4605
+ }
4606
+ return ok(msg);
4607
+ }
4608
+
4609
+ case 'audit_redirect_chains': {
4610
+ const r = await apiCall('/audit-redirect-chains');
4611
+ let msg = `=== REDIRECT CHAINS ===\nURLs checked: ${r.urls_checked} | Chains found: ${r.chains_found}\n`;
4612
+ if (r.chains && r.chains.length) {
4613
+ msg += '\nRedirect chains (2+ hops):\n';
4614
+ for (const c of r.chains) {
4615
+ msg += ` - ${c.original_url} → ${c.final_url} (${c.hops} hops)\n`;
4616
+ for (const hop of c.chain) {
4617
+ msg += ` ${hop.status}: ${hop.from} → ${hop.to}\n`;
4618
+ }
4619
+ if (c.locations && c.locations.length) {
4620
+ msg += ` Used on: ${c.locations.map(l => l.page_title).join(', ')}\n`;
4621
+ }
4622
+ }
4623
+ } else {
4624
+ msg += '\nNo redirect chains found.\n';
4625
+ }
4626
+ return ok(msg);
4627
+ }
4628
+
3653
4629
  case 'copy_section': {
3654
- const r = await apiCall('/copy-section', 'POST', {
4630
+ const body = {
3655
4631
  source_id: args.source_id,
3656
4632
  section_index: args.section_index,
3657
4633
  target_id: args.target_id,
3658
4634
  position: args.position || 'end',
3659
- });
4635
+ };
4636
+ if (args.force) body.force = true;
4637
+ const r = await apiCall('/copy-section', 'POST', body);
3660
4638
  if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
3661
4639
  let msg = `Section copied!\n`;
3662
4640
  msg += `Source: ID ${r.source_id}, section index ${r.section_index}\n`;
@@ -3669,12 +4647,15 @@ async function handleToolCall(name, args) {
3669
4647
  }
3670
4648
 
3671
4649
  case 'append_section': {
4650
+ // Auto-add boilerplate to all appended sections
4651
+ if (args.section) addBoilerplate(args.section);
3672
4652
  const body = {
3673
4653
  target_id: args.target_id,
3674
4654
  section: args.section,
3675
4655
  position: args.position || 'end',
3676
4656
  };
3677
4657
  if (args.regenerate_ids !== undefined) body.regenerate_ids = args.regenerate_ids;
4658
+ if (args.force) body.force = true;
3678
4659
  const r = await apiCall('/append-section', 'POST', body);
3679
4660
  if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
3680
4661
  let msg = `Section appended!\n`;
@@ -3713,12 +4694,27 @@ async function handleToolCall(name, args) {
3713
4694
  const catalog = getTemplateCatalog();
3714
4695
  if (!catalog) return ok('Template library not configured. Set TEMPLATE_LIBRARY_DIR env var.');
3715
4696
  let sections = catalog.sections;
4697
+
4698
+ // Filter out CoSpace (all legacy section/column structure) unless explicitly requested
4699
+ if (!args.kit_id || !args.kit_id.includes('cospace')) {
4700
+ sections = sections.filter(s => !s.kit_id.includes('cospace'));
4701
+ }
4702
+ // Filter out low-quality sections (score < 50) unless min_score overridden
4703
+ const minScore = args.min_score !== undefined ? args.min_score : 50;
4704
+ if (minScore > 0) {
4705
+ sections = sections.filter(s => (s.quality_score || 0) >= minScore);
4706
+ }
4707
+
3716
4708
  if (args.kit_id) sections = sections.filter(s => s.kit_id === args.kit_id);
3717
4709
  if (args.type) sections = sections.filter(s => s.type === args.type);
3718
4710
  if (args.page_name) {
3719
4711
  const q = args.page_name.toLowerCase();
3720
4712
  sections = sections.filter(s => s.page_name.toLowerCase().includes(q));
3721
4713
  }
4714
+
4715
+ // Sort by quality score descending for best-first results
4716
+ sections.sort((a, b) => (b.quality_score || 0) - (a.quality_score || 0));
4717
+
3722
4718
  const limit = args.limit || 20;
3723
4719
  const total = sections.length;
3724
4720
  sections = sections.slice(0, limit);
@@ -3732,6 +4728,11 @@ async function handleToolCall(name, args) {
3732
4728
  msg += ` Type: ${s.type} (confidence: ${s.confidence})`;
3733
4729
  if (s.secondary_type) msg += ` | also: ${s.secondary_type}`;
3734
4730
  msg += '\n';
4731
+ if (s.quality_score !== undefined) {
4732
+ msg += ` Quality: ${s.quality_score}/100`;
4733
+ if (s.quality_flags && s.quality_flags.length) msg += ` [${s.quality_flags.join(', ')}]`;
4734
+ msg += '\n';
4735
+ }
3735
4736
  const widgetSummary = Object.entries(s.features.widgets).map(([w,c]) => `${w}:${c}`).join(', ');
3736
4737
  msg += ` Widgets: ${widgetSummary || '(none)'}\n`;
3737
4738
  msg += ` Elements: ${s.features.total_elements} | Columns: ${s.features.column_count} | Depth: ${s.features.max_depth}\n`;
@@ -4077,6 +5078,9 @@ async function handleToolCall(name, args) {
4077
5078
  }
4078
5079
  }
4079
5080
 
5081
+ // Add site-specific boilerplate (jet_parallax, plus_tooltip, full_width)
5082
+ addBoilerplate(final);
5083
+
4080
5084
  // Append to page
4081
5085
  try {
4082
5086
  const appendResult = await apiCall('/append-section', 'POST', {
@@ -4166,6 +5170,25 @@ async function handleToolCall(name, args) {
4166
5170
  return ok(r.message);
4167
5171
  }
4168
5172
 
5173
+ case 'scan_shortcodes': {
5174
+ const r = await apiCall('/scan-shortcodes');
5175
+ let msg = `Shortcode scan: ${r.posts_scanned} posts scanned, ${r.posts_with_shortcodes} using shortcodes.\n\n`;
5176
+ msg += 'Summary:\n';
5177
+ for (const [sc, count] of Object.entries(r.shortcode_summary || {})) {
5178
+ msg += ` [${sc}]: ${count} page(s)\n`;
5179
+ }
5180
+ if (r.results && r.results.length) {
5181
+ msg += '\nPages with shortcodes:\n';
5182
+ for (const p of r.results) {
5183
+ msg += ` - ${p.title} (${p.post_type}, ID ${p.post_id}): ${p.shortcodes_found.join(', ')} [${p.locations.join(', ')}]\n`;
5184
+ msg += ` Edit: ${p.edit_url}\n`;
5185
+ }
5186
+ } else {
5187
+ msg += '\nNo pages use NVBC shortcodes — safe to disable.\n';
5188
+ }
5189
+ return ok(msg);
5190
+ }
5191
+
4169
5192
  case 'get_site_profile': {
4170
5193
  const r = await apiCall('/site-profile');
4171
5194
  let msg = '=== SITE PROFILE ===\n\n';
@@ -4286,6 +5309,22 @@ async function handleToolCall(name, args) {
4286
5309
  return ok(r.message || 'Audit started. Call get_audit_report in ~60s for results.');
4287
5310
  }
4288
5311
 
5312
+ case 'clean_globals': {
5313
+ const r = await apiCall(`/pages/${args.page_id}/clean-globals`, 'POST', {
5314
+ dry_run: args.dry_run !== undefined ? parseBool(args.dry_run) : true,
5315
+ force: parseBool(args.force),
5316
+ });
5317
+ return ok(`${r.dry_run ? '[DRY RUN] ' : ''}Cleaned ${r.foreign_globals_cleaned} foreign global references on page ${r.page_id}\nValid color IDs: ${r.valid_color_ids.join(', ')}\nValid font IDs: ${r.valid_font_ids.join(', ')}`);
5318
+ }
5319
+
5320
+ case 'set_front_page': {
5321
+ const r = await apiCall('/set-front-page', 'POST', {
5322
+ page_id: args.page_id,
5323
+ blog_page_id: args.blog_page_id,
5324
+ });
5325
+ return ok(`Front page set to "${r.front_page_title}" (ID: ${r.front_page_id})${r.blog_page_id ? `\nBlog page: ${r.blog_page_id}` : ''}`);
5326
+ }
5327
+
4289
5328
  default:
4290
5329
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
4291
5330
  }