@yashwant.dharmdas/elementor-mcp 3.14.0 → 3.16.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/dist/index.js +552 -63
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1046,8 +1046,291 @@ function createMcpServer(sites) {
1046
1046
  // ═══════════════════════════════════════════════════════════════════════════
1047
1047
  // ── COMPOUND BUILDERS — scaffold multi-step ops into one tool ─────────────
1048
1048
  // ═══════════════════════════════════════════════════════════════════════════
1049
+ // ── Helper: typography settings builder for child widgets ─────────────────
1050
+ // Maps clean params (typography_global, font_size, color, etc.) → Elementor's
1051
+ // typography_* keys + __globals__ entries. Used by create-section children.
1052
+ const buildTypography = (opts, prefix = "typography") => {
1053
+ const settings = {};
1054
+ const globals = {};
1055
+ if (!opts)
1056
+ return { settings, globals };
1057
+ // Global typography token wins over everything else
1058
+ if (opts.typography_global) {
1059
+ globals[`${prefix}_typography`] = `globals/typography?id=${opts.typography_global}`;
1060
+ return { settings, globals };
1061
+ }
1062
+ // Otherwise build custom typography
1063
+ let touched = false;
1064
+ const fontKey = `${prefix}_font_family`;
1065
+ const sizeKey = `${prefix}_font_size`;
1066
+ if (opts.font_family) {
1067
+ settings[fontKey] = opts.font_family;
1068
+ touched = true;
1069
+ }
1070
+ const fs1 = parseSize(opts.font_size);
1071
+ if (fs1) {
1072
+ settings[sizeKey] = fs1;
1073
+ touched = true;
1074
+ }
1075
+ const fs2 = parseSize(opts.font_size_tablet);
1076
+ if (fs2) {
1077
+ settings[`${sizeKey}_tablet`] = fs2;
1078
+ touched = true;
1079
+ }
1080
+ const fs3 = parseSize(opts.font_size_mobile);
1081
+ if (fs3) {
1082
+ settings[`${sizeKey}_mobile`] = fs3;
1083
+ touched = true;
1084
+ }
1085
+ if (opts.font_weight) {
1086
+ settings[`${prefix}_font_weight`] = String(opts.font_weight);
1087
+ touched = true;
1088
+ }
1089
+ if (opts.letter_spacing !== undefined) {
1090
+ settings[`${prefix}_letter_spacing`] = { unit: "px", size: opts.letter_spacing };
1091
+ touched = true;
1092
+ }
1093
+ const lh = parseSize(opts.line_height);
1094
+ if (lh) {
1095
+ settings[`${prefix}_line_height`] = lh;
1096
+ touched = true;
1097
+ }
1098
+ if (opts.text_transform) {
1099
+ settings[`${prefix}_text_transform`] = opts.text_transform;
1100
+ touched = true;
1101
+ }
1102
+ if (opts.text_decoration) {
1103
+ settings[`${prefix}_text_decoration`] = opts.text_decoration;
1104
+ touched = true;
1105
+ }
1106
+ if (touched)
1107
+ settings[`${prefix}_typography`] = "custom";
1108
+ return { settings, globals };
1109
+ };
1110
+ // ── Helper: build container settings from clean params ────────────────────
1111
+ const buildContainerSettings = (opts) => {
1112
+ const s = {};
1113
+ const g = {};
1114
+ if (opts.content_width)
1115
+ s.content_width = opts.content_width;
1116
+ if (opts.direction)
1117
+ s.flex_direction = opts.direction;
1118
+ if (opts.direction_mobile)
1119
+ s.flex_direction_mobile = opts.direction_mobile;
1120
+ if (opts.justify)
1121
+ s.flex_justify_content = opts.justify;
1122
+ if (opts.align)
1123
+ s.flex_align_items = opts.align;
1124
+ if (opts.wrap)
1125
+ s.flex_wrap = opts.wrap;
1126
+ const bw = parseSize(opts.boxed_width);
1127
+ if (bw)
1128
+ s.boxed_width = bw;
1129
+ const p1 = parseSpacing(opts.padding);
1130
+ if (p1)
1131
+ s.padding = p1;
1132
+ const p2 = parseSpacing(opts.padding_tablet);
1133
+ if (p2)
1134
+ s.padding_tablet = p2;
1135
+ const p3 = parseSpacing(opts.padding_mobile);
1136
+ if (p3)
1137
+ s.padding_mobile = p3;
1138
+ const g1 = parseGap(opts.gap);
1139
+ if (g1)
1140
+ s.flex_gap = g1;
1141
+ const g2 = parseGap(opts.gap_tablet);
1142
+ if (g2)
1143
+ s.flex_gap_tablet = g2;
1144
+ const g3 = parseGap(opts.gap_mobile);
1145
+ if (g3)
1146
+ s.flex_gap_mobile = g3;
1147
+ const mh1 = parseSize(opts.min_height);
1148
+ if (mh1)
1149
+ s.min_height = mh1;
1150
+ const mh2 = parseSize(opts.min_height_tablet);
1151
+ if (mh2)
1152
+ s.min_height_tablet = mh2;
1153
+ const mh3 = parseSize(opts.min_height_mobile);
1154
+ if (mh3)
1155
+ s.min_height_mobile = mh3;
1156
+ const w1 = parseSize(opts.width);
1157
+ if (w1)
1158
+ s.width = w1;
1159
+ const w2 = parseSize(opts.width_tablet);
1160
+ if (w2)
1161
+ s.width_tablet = w2;
1162
+ const w3 = parseSize(opts.width_mobile);
1163
+ if (w3)
1164
+ s.width_mobile = w3;
1165
+ if (opts.background_color) {
1166
+ s.background_background = "classic";
1167
+ const c = resolveColor(opts.background_color);
1168
+ if (c.value)
1169
+ s.background_color = c.value;
1170
+ if (c.global_id)
1171
+ g.background_color = c.global_id;
1172
+ }
1173
+ if (opts.background_image) {
1174
+ s.background_background = "classic";
1175
+ s.background_image = { url: opts.background_image, id: opts.background_image_id ?? "" };
1176
+ s.background_size = "cover";
1177
+ s.background_position = "center center";
1178
+ }
1179
+ if (opts.overlay_color || opts.overlay_opacity !== undefined) {
1180
+ s.background_overlay_background = "classic";
1181
+ if (opts.overlay_color) {
1182
+ const oc = resolveColor(opts.overlay_color);
1183
+ if (oc.value)
1184
+ s.background_overlay_color = oc.value;
1185
+ if (oc.global_id)
1186
+ g.background_overlay_color = oc.global_id;
1187
+ }
1188
+ if (opts.overlay_opacity !== undefined) {
1189
+ s.background_overlay_opacity = { unit: "px", size: opts.overlay_opacity };
1190
+ }
1191
+ }
1192
+ if (Object.keys(g).length)
1193
+ s.__globals__ = { ...(s.__globals__ || {}), ...g };
1194
+ return s;
1195
+ };
1196
+ // ── Helper: recursive child-element builder ───────────────────────────────
1197
+ // Accepts a ChildDef object and returns an Elementor element JSON node.
1198
+ // Supported `type` values: container, heading, text, button, image, spacer.
1199
+ // Containers can nest further children. Unknown types throw.
1200
+ const buildChild = (child) => {
1201
+ if (!child || typeof child !== "object") {
1202
+ throw new Error("create-section child must be an object with a 'type' field.");
1203
+ }
1204
+ const t = String(child.type || "").toLowerCase();
1205
+ // ── container ───────────────────────────────────────────────────────
1206
+ if (t === "container") {
1207
+ const s = buildContainerSettings(child);
1208
+ const node = {
1209
+ elType: "container",
1210
+ settings: s,
1211
+ elements: [],
1212
+ isInner: true,
1213
+ };
1214
+ if (Array.isArray(child.children)) {
1215
+ node.elements = child.children.map((c) => buildChild(c));
1216
+ }
1217
+ return node;
1218
+ }
1219
+ // ── heading ─────────────────────────────────────────────────────────
1220
+ if (t === "heading") {
1221
+ const s = { title: String(child.title ?? "") };
1222
+ if (child.header_size)
1223
+ s.header_size = child.header_size;
1224
+ if (child.align)
1225
+ s.align = child.align;
1226
+ const typo = buildTypography(child);
1227
+ Object.assign(s, typo.settings);
1228
+ if (Object.keys(typo.globals).length)
1229
+ s.__globals__ = { ...(s.__globals__ || {}), ...typo.globals };
1230
+ if (child.color) {
1231
+ const c = resolveColor(child.color);
1232
+ if (c.value)
1233
+ s.title_color = c.value;
1234
+ if (c.global_id)
1235
+ s.__globals__ = { ...(s.__globals__ || {}), title_color: c.global_id };
1236
+ }
1237
+ return { elType: "widget", widgetType: "heading", settings: s, elements: [] };
1238
+ }
1239
+ // ── text-editor (paragraph / rich text) ──────────────────────────────
1240
+ if (t === "text" || t === "text-editor") {
1241
+ const s = { editor: String(child.content ?? "") };
1242
+ const typo = buildTypography(child);
1243
+ Object.assign(s, typo.settings);
1244
+ if (Object.keys(typo.globals).length)
1245
+ s.__globals__ = { ...(s.__globals__ || {}), ...typo.globals };
1246
+ if (child.color) {
1247
+ const c = resolveColor(child.color);
1248
+ if (c.value)
1249
+ s.text_color = c.value;
1250
+ if (c.global_id)
1251
+ s.__globals__ = { ...(s.__globals__ || {}), text_color: c.global_id };
1252
+ }
1253
+ if (child.align)
1254
+ s.align = child.align;
1255
+ return { elType: "widget", widgetType: "text-editor", settings: s, elements: [] };
1256
+ }
1257
+ // ── button ───────────────────────────────────────────────────────────
1258
+ if (t === "button") {
1259
+ const s = { text: String(child.text ?? "") };
1260
+ if (child.link)
1261
+ s.link = { url: child.link };
1262
+ if (child.align)
1263
+ s.align = child.align;
1264
+ const typo = buildTypography(child);
1265
+ Object.assign(s, typo.settings);
1266
+ if (Object.keys(typo.globals).length)
1267
+ s.__globals__ = { ...(s.__globals__ || {}), ...typo.globals };
1268
+ if (child.text_color) {
1269
+ const c = resolveColor(child.text_color);
1270
+ if (c.value)
1271
+ s.button_text_color = c.value;
1272
+ if (c.global_id)
1273
+ s.__globals__ = { ...(s.__globals__ || {}), button_text_color: c.global_id };
1274
+ }
1275
+ if (child.background_color) {
1276
+ const c = resolveColor(child.background_color);
1277
+ if (c.value)
1278
+ s.background_color = c.value;
1279
+ if (c.global_id)
1280
+ s.__globals__ = { ...(s.__globals__ || {}), background_color: c.global_id };
1281
+ }
1282
+ if (child.border) {
1283
+ s.border_border = child.border.style || "solid";
1284
+ if (child.border.color) {
1285
+ const c = resolveColor(child.border.color);
1286
+ if (c.value)
1287
+ s.border_color = c.value;
1288
+ if (c.global_id)
1289
+ s.__globals__ = { ...(s.__globals__ || {}), border_color: c.global_id };
1290
+ }
1291
+ if (child.border.width !== undefined) {
1292
+ const w = Number(child.border.width);
1293
+ s.border_width = { unit: "px", top: String(w), right: String(w), bottom: String(w), left: String(w), isLinked: true };
1294
+ }
1295
+ }
1296
+ if (child.border_radius !== undefined) {
1297
+ const r = Number(child.border_radius);
1298
+ s.border_radius = { unit: "px", top: String(r), right: String(r), bottom: String(r), left: String(r), isLinked: true };
1299
+ }
1300
+ const p1 = parseSpacing(child.padding);
1301
+ if (p1)
1302
+ s.padding = p1;
1303
+ return { elType: "widget", widgetType: "button", settings: s, elements: [] };
1304
+ }
1305
+ // ── image ────────────────────────────────────────────────────────────
1306
+ if (t === "image") {
1307
+ const s = {
1308
+ image: { url: String(child.url ?? ""), id: child.attachment_id ?? "" },
1309
+ image_size: child.image_size || "full",
1310
+ };
1311
+ if (child.alt)
1312
+ s.alt = child.alt;
1313
+ if (child.link)
1314
+ s.link = { url: child.link };
1315
+ if (child.align)
1316
+ s.align = child.align;
1317
+ const w = parseSize(child.width);
1318
+ if (w)
1319
+ s.width = w;
1320
+ return { elType: "widget", widgetType: "image", settings: s, elements: [] };
1321
+ }
1322
+ // ── spacer ───────────────────────────────────────────────────────────
1323
+ if (t === "spacer") {
1324
+ const s = {};
1325
+ const h = parseSize(child.height);
1326
+ if (h)
1327
+ s.space = h;
1328
+ return { elType: "widget", widgetType: "spacer", settings: s, elements: [] };
1329
+ }
1330
+ throw new Error(`Unknown child type '${child.type}'. Supported: container, heading, text, button, image, spacer.`);
1331
+ };
1049
1332
  // ── 13. create-section ────────────────────────────────────────────────────
1050
- server.tool("create-section", "Scaffold a new section container with all common defaults padding, flex direction, gap, background, min-height — in ONE call. Replaces a typical insert-element set-container-background set-element-spacing set-container-layout sequence (4 calls) with 1. Returns the new container's element ID so you can immediately add child widgets/columns.", {
1333
+ server.tool("create-section", "Scaffold a section container optionally with its FULL subtree of nested containers + widgets — in ONE call. Pass top-level settings (padding, bg, etc.) AND an optional 'children' array of typed widget defs. This replaces 50+ round-trips (insert-element + N×set-X) with a single call. Supported child types: 'container' (nests further), 'heading', 'text' (text-editor), 'button', 'image', 'spacer'. Each child accepts typography_global (preferred — uses kit globals) OR custom typography props (font_size, font_weight, etc.). Colors accept either hex ('#FFF') or kit-global slug ('primary').", {
1051
1334
  page_id: z.string().describe("WordPress Page ID"),
1052
1335
  parent_id: z.string().optional().describe("Parent container ID. Omit for top-level section."),
1053
1336
  position: z.enum(["before", "after", "first_child", "last_child"]).optional().describe("Default 'last_child' (append to parent or page end)"),
@@ -1065,78 +1348,38 @@ function createMcpServer(sites) {
1065
1348
  min_height: z.string().optional().describe("Min-height — '420px', '80vh'"),
1066
1349
  min_height_tablet: z.string().optional(),
1067
1350
  min_height_mobile: z.string().optional(),
1351
+ justify: z.enum(["flex-start", "center", "flex-end", "space-between", "space-around", "space-evenly"]).optional().describe("Justify content along the flex main axis"),
1352
+ align: z.enum(["flex-start", "center", "flex-end", "stretch", "baseline"]).optional().describe("Align items along the flex cross axis"),
1353
+ wrap: z.enum(["wrap", "nowrap"]).optional().describe("Flex wrap. Default 'nowrap'."),
1068
1354
  background_color: z.string().optional().describe("Background color — hex or global slug"),
1069
1355
  background_image: z.string().optional().describe("Background image URL"),
1356
+ background_image_id: z.number().optional().describe("WP attachment ID for the bg image (enables srcset). Use the id returned by list-media-library."),
1070
1357
  overlay_color: z.string().optional().describe("Overlay color — hex or global slug"),
1071
1358
  overlay_opacity: z.number().optional().describe("Overlay opacity 0–1"),
1359
+ children: z.array(z.any()).optional().describe("Optional array of child elements to build inside this section in the SAME call. Each child: { type: 'container'|'heading'|'text'|'button'|'image'|'spacer', ...settings }. Containers can nest further via their own 'children' array. Prefer typography_global ('primary-text', 'body') over hardcoded typography. Prefer kit-global color slugs ('primary', 'accent') over hex. This is the fastest way to build a section — ~50x fewer round-trips than the per-widget tools."),
1072
1360
  site: siteParam,
1073
- }, async ({ page_id, parent_id, position, reference_id, direction, direction_mobile, content_width, boxed_width, padding, padding_tablet, padding_mobile, gap, gap_tablet, gap_mobile, min_height, min_height_tablet, min_height_mobile, background_color, background_image, overlay_color, overlay_opacity, site }) => {
1361
+ }, async (args) => {
1074
1362
  try {
1363
+ const { page_id, parent_id, position, reference_id, children, site } = args;
1075
1364
  const { wpUrl, authHeader } = resolveSite(sites, site);
1076
- const settings = {
1077
- content_width: content_width || "boxed",
1078
- flex_direction: direction || "column",
1365
+ // Default content_width "boxed" / direction "column" for top-level sections
1366
+ const containerOpts = {
1367
+ ...args,
1368
+ content_width: args.content_width || "boxed",
1369
+ direction: args.direction || "column",
1079
1370
  };
1080
- if (direction_mobile)
1081
- settings.flex_direction_mobile = direction_mobile;
1082
- const bw = parseSize(boxed_width);
1083
- if (bw)
1084
- settings.boxed_width = bw;
1085
- const p1 = parseSpacing(padding);
1086
- if (p1)
1087
- settings.padding = p1;
1088
- const p2 = parseSpacing(padding_tablet);
1089
- if (p2)
1090
- settings.padding_tablet = p2;
1091
- const p3 = parseSpacing(padding_mobile);
1092
- if (p3)
1093
- settings.padding_mobile = p3;
1094
- const g1 = parseGap(gap);
1095
- if (g1)
1096
- settings.flex_gap = g1;
1097
- const g2 = parseGap(gap_tablet);
1098
- if (g2)
1099
- settings.flex_gap_tablet = g2;
1100
- const g3 = parseGap(gap_mobile);
1101
- if (g3)
1102
- settings.flex_gap_mobile = g3;
1103
- const mh1 = parseSize(min_height);
1104
- if (mh1)
1105
- settings.min_height = mh1;
1106
- const mh2 = parseSize(min_height_tablet);
1107
- if (mh2)
1108
- settings.min_height_tablet = mh2;
1109
- const mh3 = parseSize(min_height_mobile);
1110
- if (mh3)
1111
- settings.min_height_mobile = mh3;
1112
- if (background_color) {
1113
- settings.background_background = "classic";
1114
- const c = resolveColor(background_color);
1115
- if (c.value)
1116
- settings.background_color = c.value;
1117
- if (c.global_id)
1118
- settings.__globals__ = { ...(settings.__globals__ || {}), background_color: c.global_id };
1119
- }
1120
- if (background_image) {
1121
- settings.background_background = "classic";
1122
- settings.background_image = { url: background_image, id: "" };
1123
- settings.background_size = "cover";
1124
- settings.background_position = "center center";
1125
- }
1126
- if (overlay_color || overlay_opacity !== undefined) {
1127
- settings.background_overlay_background = "classic";
1128
- if (overlay_color) {
1129
- const oc = resolveColor(overlay_color);
1130
- if (oc.value)
1131
- settings.background_overlay_color = oc.value;
1132
- if (oc.global_id)
1133
- settings.__globals__ = { ...(settings.__globals__ || {}), background_overlay_color: oc.global_id };
1371
+ const settings = buildContainerSettings(containerOpts);
1372
+ // Build any inline children recursively
1373
+ let elements = [];
1374
+ if (Array.isArray(children) && children.length) {
1375
+ try {
1376
+ elements = children.map((c) => buildChild(c));
1134
1377
  }
1135
- if (overlay_opacity !== undefined) {
1136
- settings.background_overlay_opacity = { unit: "px", size: overlay_opacity };
1378
+ catch (err) {
1379
+ return { content: [{ type: "text", text: `Error building children: ${err.message}` }], isError: true };
1137
1380
  }
1138
1381
  }
1139
- const newElement = { elType: "container", settings, elements: [] };
1382
+ const newElement = { elType: "container", settings, elements };
1140
1383
  const body = { element: newElement, position: position || "last_child" };
1141
1384
  if (parent_id)
1142
1385
  body.parent_id = parent_id;
@@ -1569,7 +1812,7 @@ function createMcpServer(sites) {
1569
1812
  return { content: [{ type: "text", text: `Error setting rotation: ${error.response?.data?.message || error.message}` }], isError: true };
1570
1813
  }
1571
1814
  });
1572
- server.tool("update-data", "Replace the entire Elementor element tree for a page. By default refuses if the new data is dramatically smaller than existing. Use force_replace=true to override.", {
1815
+ server.tool("update-data", "⚠️ HEAVY HAMMER — use ONLY for first-time page scaffold. Replaces the ENTIRE Elementor element tree of a page.\n\nWHEN TO USE: First creation of a brand-new page from scratch (one POST).\n\nWHEN NOT TO USE: Any edit to an existing page. Use surgical tools instead — they're faster, safer, and don't require you to reconstruct the whole tree:\n • Text change → set-text-content\n • Button text + link → set-button\n • Image swap → set-image-widget-src\n • Container layout (flex, justify, align) → set-container-layout\n • Container background → set-container-background\n • Typography → set-element-typography\n • Spacing / padding / margin → set-element-spacing\n • Border / shadow / radius → set-element-border / set-element-shadow\n • Add a widget → add-widget-to-page / insert-element / clone-element\n • Move/reorder element → move-element\n • Delete an element → delete-element\n • Swap widget type → change-widget-type\n • Restructure a container's children → restructure-container\n • Same change on many elements → bulk-set-property\n • Anything else on settings → merge-element-settings\n\nSIZE LIMIT: There is no MCP-enforced size limit. The PHP backend writes via Elementor's $document->save() and accepts whatever your WordPress server's post_max_size allows (typically 64MB+). Do NOT fall back to Python scripts to 'work around a size limit' — there isn't one. If your payload is huge, you're using the wrong tool.\n\nBy default refuses if the new data is dramatically smaller than existing (protects against accidental wipeouts). Use force_replace=true to override.", {
1573
1816
  page_id: z.string().describe("WordPress Page ID"),
1574
1817
  elements_json: z.string().describe("Full elements array as stringified JSON"),
1575
1818
  force_replace: z.boolean().optional().describe("Set true to allow replacing with dramatically fewer top-level elements"),
@@ -3532,6 +3775,252 @@ function createMcpServer(sites) {
3532
3775
  return { content: [{ type: "text", text: `Error updating video overlay: ${error.response?.data?.message || error.message}` }], isError: true };
3533
3776
  }
3534
3777
  });
3778
+ // ═══════════════════════════════════════════════════════════════════════════
3779
+ // ── v3.15.0 — Media library browse + page-structure validation ────────────
3780
+ // Replaces the "export images from Figma → upload" workflow with a
3781
+ // "developer uploads clean source images, AI matches by name" workflow.
3782
+ // ═══════════════════════════════════════════════════════════════════════════
3783
+ // ── list-media-library ────────────────────────────────────────────────────
3784
+ server.tool("list-media-library", "List images already uploaded to the WordPress media library. THIS IS THE PREFERRED way to source images for a Figma-→-Elementor build. Workflow: 1) Developer/designer uploads clean source images to WP media (bulk upload via WP admin → Media → Add New), 2) Call this tool to get the inventory, 3) Match each Figma layer name to a media item by 'normalised_slug' (kebab-cased filename) or fuzzy substring, 4) Pass the returned attachment_id + url to set-image-widget-src / set-container-background. NEVER export images from Figma — exports bake in overlays, text, and effects.", {
3785
+ search: z.string().optional().describe("Substring to filter by — matches title, filename, normalised_slug. e.g. 'hero' returns 'hero-bg.jpg', 'home-hero.webp'."),
3786
+ mime: z.string().optional().describe("MIME prefix filter (default 'image'). Use '' for all types, 'image/png' to narrow further."),
3787
+ per_page: z.number().optional().describe("Items per page (default 100, max 500)"),
3788
+ page: z.number().optional().describe("Page number for pagination (default 1)"),
3789
+ site: siteParam,
3790
+ }, async ({ search, mime, per_page, page, site }) => {
3791
+ try {
3792
+ const { wpUrl, authHeader } = resolveSite(sites, site);
3793
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/media/list`, {
3794
+ headers: { Authorization: authHeader },
3795
+ params: {
3796
+ search: search ?? "",
3797
+ mime: mime ?? "image",
3798
+ per_page: per_page ?? 100,
3799
+ page: page ?? 1,
3800
+ },
3801
+ });
3802
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
3803
+ }
3804
+ catch (error) {
3805
+ return { content: [{ type: "text", text: `Error listing media library: ${error.response?.data?.message || error.message}` }], isError: true };
3806
+ }
3807
+ });
3808
+ // ═══════════════════════════════════════════════════════════════════════════
3809
+ // ── v3.16.0 — Coverage gap closers ────────────────────────────────────────
3810
+ // These five tools fill the gaps that previously forced fallback to
3811
+ // update-data (the heavy hammer). After v3.16.0, update-data should ONLY
3812
+ // be used for the very first scaffold of a new page. Every subsequent
3813
+ // change uses one of the surgical tools below or an earlier typed setter.
3814
+ // ═══════════════════════════════════════════════════════════════════════════
3815
+ // ── delete-element ────────────────────────────────────────────────────────
3816
+ server.tool("delete-element", "Remove an Elementor element from a page by ID. Surgical, server-side, no full-page download. Works for any element (widget, container, section). For top-level sections you must pass force=true to confirm — protects against accidental wipeouts. After deleting, the parent container's children array is automatically resaved. Use this INSTEAD of update-data when you want to remove just one element.", {
3817
+ page_id: z.string().describe("WordPress Page ID"),
3818
+ element_id: z.string().describe("Elementor Element ID to remove"),
3819
+ force: z.boolean().optional().describe("Required true to delete a top-level section (safety guard). Children/widgets inside a container delete without force."),
3820
+ site: siteParam,
3821
+ }, async ({ page_id, element_id, force, site }) => {
3822
+ try {
3823
+ const { wpUrl, authHeader } = resolveSite(sites, site);
3824
+ const r = await axios.delete(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}`, {
3825
+ headers: { Authorization: authHeader },
3826
+ params: { force_delete: force === true ? "true" : "false" },
3827
+ });
3828
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
3829
+ }
3830
+ catch (error) {
3831
+ return { content: [{ type: "text", text: `Error deleting element: ${error.response?.data?.message || error.message}` }], isError: true };
3832
+ }
3833
+ });
3834
+ // ── change-widget-type ────────────────────────────────────────────────────
3835
+ server.tool("change-widget-type", "Swap a widget's type (e.g. heading → button, text-editor → heading) while keeping its position in the page. Old settings are cleared (they belong to the OLD widget type and wouldn't render under the new one); pass new_settings to seed the new widget. Use this INSTEAD of delete + insert when you want to keep the same element ID and avoid disturbing surrounding flex order. NEVER use update-data for type swaps.", {
3836
+ page_id: z.string().describe("WordPress Page ID"),
3837
+ element_id: z.string().describe("Existing widget ID to convert"),
3838
+ new_widget: z.enum(["heading", "text-editor", "button", "image", "icon", "icon-box", "spacer", "divider", "video", "html", "shortcode"]).describe("Target widgetType"),
3839
+ new_settings: z.record(z.any()).optional().describe("Initial settings for the new widget (e.g. { title: 'Click me', link: { url: '/contact' } }). Empty by default."),
3840
+ site: siteParam,
3841
+ }, async ({ page_id, element_id, new_widget, new_settings, site }) => {
3842
+ try {
3843
+ const { wpUrl, authHeader } = resolveSite(sites, site);
3844
+ // GET current element
3845
+ const getRes = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}`, { headers: { Authorization: authHeader } });
3846
+ const current = getRes.data;
3847
+ if (current.elType !== "widget") {
3848
+ return { content: [{ type: "text", text: `Element ${element_id} is elType="${current.elType}", not "widget". change-widget-type only works on widgets. For containers, use restructure-container.` }], isError: true };
3849
+ }
3850
+ // POST with new widgetType + fresh settings (top-level array_merge in PHP will replace)
3851
+ const body = {
3852
+ id: element_id,
3853
+ elType: "widget",
3854
+ widgetType: new_widget,
3855
+ settings: new_settings ?? {},
3856
+ elements: [], // widgets don't have children
3857
+ };
3858
+ const postRes = await axios.post(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}`, body, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
3859
+ return { content: [{ type: "text", text: JSON.stringify({ from: current.widgetType, to: new_widget, ...postRes.data }, null, 2) }] };
3860
+ }
3861
+ catch (error) {
3862
+ return { content: [{ type: "text", text: `Error changing widget type: ${error.response?.data?.message || error.message}` }], isError: true };
3863
+ }
3864
+ });
3865
+ // ── restructure-container ─────────────────────────────────────────────────
3866
+ server.tool("restructure-container", "Replace a container's entire children array — the surgical way to split one column into two, add a column, reorder children, or rebuild a container's internal layout WITHOUT touching anything outside that container. Children are described in the same typed format as create-section's children param (container / heading / text / button / image / spacer). The container's own settings (flex direction, padding, background) are preserved. Use this INSTEAD of update-data for ANY structural change inside a single container.", {
3867
+ page_id: z.string().describe("WordPress Page ID"),
3868
+ container_id: z.string().describe("Container Element ID whose children you want to replace"),
3869
+ children: z.array(z.any()).describe("New children array in typed format. Same schema as create-section's `children` field. Example: [{ type:'container', flex_direction:'column', children:[ {type:'heading', text:'Hi'} ] }]"),
3870
+ site: siteParam,
3871
+ }, async ({ page_id, container_id, children, site }) => {
3872
+ try {
3873
+ const { wpUrl, authHeader } = resolveSite(sites, site);
3874
+ // GET current container
3875
+ const getRes = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${container_id}`, { headers: { Authorization: authHeader } });
3876
+ const current = getRes.data;
3877
+ if (current.elType !== "container") {
3878
+ return { content: [{ type: "text", text: `Element ${container_id} is elType="${current.elType}", not "container". restructure-container only works on containers.` }], isError: true };
3879
+ }
3880
+ // Build the new children array via the shared buildChild helper
3881
+ const builtChildren = children.map((c) => buildChild(c));
3882
+ // POST back the container with its own id/elType/settings preserved + new children
3883
+ const body = {
3884
+ id: container_id,
3885
+ elType: "container",
3886
+ settings: current.settings || {},
3887
+ elements: builtChildren,
3888
+ };
3889
+ const postRes = await axios.post(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${container_id}`, body, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
3890
+ return { content: [{ type: "text", text: JSON.stringify({ container_id, new_child_count: builtChildren.length, ...postRes.data }, null, 2) }] };
3891
+ }
3892
+ catch (error) {
3893
+ return { content: [{ type: "text", text: `Error restructuring container: ${error.response?.data?.message || error.message}` }], isError: true };
3894
+ }
3895
+ });
3896
+ // ── bulk-set-property ─────────────────────────────────────────────────────
3897
+ server.tool("bulk-set-property", "Apply one settings patch to every element on a page matching a filter — e.g. 'set color = primary global on all h2 headings', 'add padding 24px to every button', 'set background = light-bg on every container with css_class=\"card\"'. Runs the filter in-memory on the page tree, then issues a parallel batch of merge-element-settings calls. Use this INSTEAD of looping with update-data when you need the same change in many places.", {
3898
+ page_id: z.string().describe("WordPress Page ID"),
3899
+ match_elType: z.enum(["widget", "container", "section", "column"]).optional().describe("Filter by elType (e.g. 'container' to target only containers)"),
3900
+ match_widget_type: z.string().optional().describe("Filter by widgetType (e.g. 'heading', 'button', 'image')"),
3901
+ match_css_class: z.string().optional().describe("Filter by CSS class substring (matches if class list contains this token)"),
3902
+ match_text_value: z.string().optional().describe("Filter by current text contents (case-insensitive substring match against title/editor/text settings)"),
3903
+ settings: z.record(z.any()).describe("Settings patch to deep-merge into every matched element (same shape as merge-element-settings)"),
3904
+ dry_run: z.boolean().optional().describe("Return matched IDs without applying (default false)"),
3905
+ site: siteParam,
3906
+ }, async ({ page_id, match_elType, match_widget_type, match_css_class, match_text_value, settings, dry_run, site }) => {
3907
+ try {
3908
+ const { wpUrl, authHeader } = resolveSite(sites, site);
3909
+ if (!match_elType && !match_widget_type && !match_css_class && !match_text_value) {
3910
+ return { content: [{ type: "text", text: "Refusing to operate without at least one match_* filter — that would touch every element on the page. Add at least one filter parameter." }], isError: true };
3911
+ }
3912
+ // Pull the full page tree once
3913
+ const pageRes = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/data`, { headers: { Authorization: authHeader } });
3914
+ const elements = pageRes.data?.elements ?? pageRes.data ?? [];
3915
+ const matched = [];
3916
+ const txtNeedle = match_text_value?.toLowerCase();
3917
+ const walk = (els) => {
3918
+ for (const el of els) {
3919
+ const elT = el.elType;
3920
+ const wT = el.widgetType;
3921
+ const cls = (el?.settings?._css_classes ?? "");
3922
+ const textVals = [el?.settings?.title, el?.settings?.editor, el?.settings?.text]
3923
+ .filter((v) => typeof v === "string")
3924
+ .map((v) => v.toLowerCase());
3925
+ let ok = true;
3926
+ if (match_elType && elT !== match_elType)
3927
+ ok = false;
3928
+ if (match_widget_type && wT !== match_widget_type)
3929
+ ok = false;
3930
+ if (match_css_class && !String(cls).split(/\s+/).includes(match_css_class))
3931
+ ok = false;
3932
+ if (txtNeedle && !textVals.some((v) => v.includes(txtNeedle)))
3933
+ ok = false;
3934
+ if (ok && el.id) {
3935
+ matched.push({ id: el.id, elType: elT, widgetType: wT });
3936
+ }
3937
+ if (Array.isArray(el.elements) && el.elements.length > 0)
3938
+ walk(el.elements);
3939
+ }
3940
+ };
3941
+ walk(elements);
3942
+ if (dry_run) {
3943
+ return { content: [{ type: "text", text: JSON.stringify({ dry_run: true, matched_count: matched.length, matched }, null, 2) }] };
3944
+ }
3945
+ // Apply in parallel — each one is its own GET+merge+POST, but they're independent
3946
+ const results = await Promise.allSettled(matched.map((m) => applyElementSettings(wpUrl, authHeader, page_id, m.id, settings)));
3947
+ const okCount = results.filter((r) => r.status === "fulfilled").length;
3948
+ const failures = results
3949
+ .map((r, i) => r.status === "rejected" ? { id: matched[i].id, error: String(r.reason) } : null)
3950
+ .filter(Boolean);
3951
+ return { content: [{ type: "text", text: JSON.stringify({ matched_count: matched.length, applied: okCount, failed: failures.length, failures }, null, 2) }] };
3952
+ }
3953
+ catch (error) {
3954
+ return { content: [{ type: "text", text: `Error in bulk-set-property: ${error.response?.data?.message || error.message}` }], isError: true };
3955
+ }
3956
+ });
3957
+ // ── set-button ────────────────────────────────────────────────────────────
3958
+ server.tool("set-button", "One-call setter for a button widget — sets text + link + (optional) icon + (optional) size/style in one shot. Replaces the 'set-text-content then set-element-link' two-step. Use this for ANY button content change. Idempotent: omit a field to leave it unchanged.", {
3959
+ page_id: z.string().describe("WordPress Page ID"),
3960
+ element_id: z.string().describe("Button widget ID"),
3961
+ text: z.string().optional().describe("Button label text"),
3962
+ url: z.string().optional().describe("Button link URL"),
3963
+ open_in_new_tab: z.boolean().optional().describe("Native Elementor 'Open in new window' toggle"),
3964
+ nofollow: z.boolean().optional().describe("Native Elementor 'Add nofollow' toggle"),
3965
+ icon: z.string().optional().describe("Elementor icon library value, e.g. 'fas fa-arrow-right'. Omit to leave icon alone."),
3966
+ icon_align: z.enum(["left", "right"]).optional().describe("Icon position relative to text"),
3967
+ button_size: z.enum(["xs", "sm", "md", "lg", "xl"]).optional().describe("Elementor preset size"),
3968
+ button_type: z.enum(["default", "info", "success", "warning", "danger"]).optional().describe("Elementor color preset"),
3969
+ site: siteParam,
3970
+ }, async ({ page_id, element_id, text, url, open_in_new_tab, nofollow, icon, icon_align, button_size, button_type, site }) => {
3971
+ try {
3972
+ const { wpUrl, authHeader } = resolveSite(sites, site);
3973
+ // 1) Settings patch — text/icon/size/type via applyElementSettings
3974
+ const s = {};
3975
+ if (text !== undefined)
3976
+ s.text = text;
3977
+ if (icon !== undefined)
3978
+ s.selected_icon = { value: icon, library: "fa-solid" };
3979
+ if (icon_align !== undefined)
3980
+ s.icon_align = icon_align;
3981
+ if (button_size !== undefined)
3982
+ s.size = button_size;
3983
+ if (button_type !== undefined)
3984
+ s.button_type = button_type;
3985
+ let settingsResult = null;
3986
+ if (Object.keys(s).length > 0) {
3987
+ settingsResult = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
3988
+ }
3989
+ // 2) Link — via the dedicated /link endpoint (handles is_external, nofollow natively)
3990
+ let linkResult = null;
3991
+ if (url !== undefined || open_in_new_tab !== undefined || nofollow !== undefined) {
3992
+ const body = {};
3993
+ if (url !== undefined)
3994
+ body.url = url;
3995
+ if (open_in_new_tab !== undefined)
3996
+ body.open_in_new_tab = open_in_new_tab;
3997
+ if (nofollow !== undefined)
3998
+ body.nofollow = nofollow;
3999
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}/link`, body, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
4000
+ linkResult = r.data;
4001
+ }
4002
+ return { content: [{ type: "text", text: JSON.stringify({ settings: settingsResult, link: linkResult }, null, 2) }] };
4003
+ }
4004
+ catch (error) {
4005
+ return { content: [{ type: "text", text: `Error setting button: ${error.response?.data?.message || error.message}` }], isError: true };
4006
+ }
4007
+ });
4008
+ // ── validate-page-structure ───────────────────────────────────────────────
4009
+ server.tool("validate-page-structure", "Post-build sanity check on a page. Returns a report of structural / standards violations: header widgets in page body (should be a template), images with external URLs missing WP attachment IDs, hardcoded fonts/colors that should use kit globals, legacy _element_width on flex children, missing image alt text, suspicious min-heights. Call this AFTER building a page from Figma. If errors are found, fix them with the typed tools and re-run. If only warnings remain, surface them to the human for review.", {
4010
+ page_id: z.string().describe("WordPress Page ID to validate"),
4011
+ site: siteParam,
4012
+ }, async ({ page_id, site }) => {
4013
+ try {
4014
+ const { wpUrl, authHeader } = resolveSite(sites, site);
4015
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/validate-structure`, {
4016
+ headers: { Authorization: authHeader },
4017
+ });
4018
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
4019
+ }
4020
+ catch (error) {
4021
+ return { content: [{ type: "text", text: `Error validating page structure: ${error.response?.data?.message || error.message}` }], isError: true };
4022
+ }
4023
+ });
3535
4024
  return server;
3536
4025
  }
3537
4026
  // ─── Entry Point ──────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yashwant.dharmdas/elementor-mcp",
3
- "version": "3.14.0",
3
+ "version": "3.16.0",
4
4
  "description": "MCP server for controlling Elementor via Claude — supports multiple WordPress sites",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",