@yashwant.dharmdas/elementor-mcp 3.12.0 → 3.14.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 +1233 -15
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -163,6 +163,115 @@ function createMcpServer(sites) {
163
163
  const putRes = await axios.put(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/data`, transformed, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
164
164
  return putRes.data;
165
165
  };
166
+ // ── Shared helper: merge settings into a specific Elementor element ───────
167
+ // Used by all the typed wrapper tools (set-container-background, set-element-spacing, etc.)
168
+ // GET element → merge new settings keys → POST full element back.
169
+ const applyElementSettings = async (wpUrl, authHeader, page_id, element_id, newSettings) => {
170
+ // Strip undefined values so they don't clobber existing settings
171
+ const clean = {};
172
+ for (const [k, v] of Object.entries(newSettings)) {
173
+ if (v !== undefined && v !== null)
174
+ clean[k] = v;
175
+ }
176
+ if (Object.keys(clean).length === 0) {
177
+ return { success: true, message: "No settings to apply (all params undefined)", element_id };
178
+ }
179
+ const getRes = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}`, { headers: { Authorization: authHeader } });
180
+ const merged = {
181
+ ...getRes.data,
182
+ settings: { ...(getRes.data.settings || {}), ...clean },
183
+ };
184
+ const postRes = await axios.post(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}`, merged, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
185
+ return postRes.data;
186
+ };
187
+ // ── Spacing parser: "120 48 120 48" or "5%" → Elementor structured object ─
188
+ // Accepts: single value ("5%"), two values ("100 0"), four values ("120 48 120 48").
189
+ // Returns Elementor's { unit, top, right, bottom, left, isLinked } shape.
190
+ const parseSpacing = (input) => {
191
+ if (input === undefined || input === null || input === "")
192
+ return undefined;
193
+ const trimmed = String(input).trim();
194
+ if (!trimmed)
195
+ return undefined;
196
+ // Detect unit suffix
197
+ let unit = "px";
198
+ let valuesStr = trimmed;
199
+ const unitMatch = trimmed.match(/(%|px|em|rem|vh|vw)$/i);
200
+ if (unitMatch) {
201
+ unit = unitMatch[1].toLowerCase();
202
+ valuesStr = trimmed.slice(0, -unitMatch[1].length).trim();
203
+ }
204
+ const values = valuesStr.split(/[\s,]+/).filter(Boolean);
205
+ if (values.length === 0)
206
+ return undefined;
207
+ let top, right, bottom, left;
208
+ let isLinked = false;
209
+ if (values.length === 1) {
210
+ top = right = bottom = left = values[0];
211
+ isLinked = true;
212
+ }
213
+ else if (values.length === 2) {
214
+ top = bottom = values[0];
215
+ right = left = values[1];
216
+ }
217
+ else if (values.length === 3) {
218
+ top = values[0];
219
+ right = left = values[1];
220
+ bottom = values[2];
221
+ }
222
+ else {
223
+ [top, right, bottom, left] = values;
224
+ }
225
+ return { unit, top, right, bottom, left, isLinked };
226
+ };
227
+ // ── Size parser: "420px" / "5vh" / "100%" → { unit, size } ────────────────
228
+ const parseSize = (input) => {
229
+ if (input === undefined || input === null || input === "")
230
+ return undefined;
231
+ const str = String(input).trim();
232
+ if (!str)
233
+ return undefined;
234
+ const m = str.match(/^(-?\d+(?:\.\d+)?)\s*(%|px|em|rem|vh|vw|deg|fr)?$/i);
235
+ if (!m)
236
+ return undefined;
237
+ return { unit: (m[2] || "px").toLowerCase(), size: parseFloat(m[1]) };
238
+ };
239
+ // ── Gap parser: "64" / "64 32" / "20px" → { unit, column, row, isLinked } ─
240
+ const parseGap = (input) => {
241
+ if (input === undefined || input === null || input === "")
242
+ return undefined;
243
+ const str = String(input).trim();
244
+ if (!str)
245
+ return undefined;
246
+ let unit = "px";
247
+ let valStr = str;
248
+ const m = str.match(/(%|px|em|rem|vh|vw)$/i);
249
+ if (m) {
250
+ unit = m[1].toLowerCase();
251
+ valStr = str.slice(0, -m[1].length).trim();
252
+ }
253
+ const parts = valStr.split(/[\s,]+/).filter(Boolean);
254
+ if (parts.length === 0)
255
+ return undefined;
256
+ const col = parts[0];
257
+ const row = parts[1] ?? parts[0];
258
+ return { unit, column: col, row, isLinked: parts.length === 1 };
259
+ };
260
+ // ── Globals resolver: "primary" → {__globals__: {<key>: "globals/colors?id=primary"}} ─
261
+ // Or accepts raw hex like "#FFFFFF" → returns just the value
262
+ const resolveColor = (value) => {
263
+ if (!value)
264
+ return {};
265
+ const v = String(value).trim();
266
+ if (!v)
267
+ return {};
268
+ // If it looks like a hex/rgb/keyword, return as plain value
269
+ if (v.startsWith("#") || v.startsWith("rgb") || v.startsWith("hsl") || v.startsWith("var(")) {
270
+ return { value: v };
271
+ }
272
+ // Otherwise treat as a global color slug
273
+ return { global_id: `globals/colors?id=${v}` };
274
+ };
166
275
  // ── Meta: list-sites ──────────────────────────────────────────────────────
167
276
  server.tool("list-sites", "List all configured WordPress sites available to this MCP server.", {}, async () => {
168
277
  if (sites.length === 0) {
@@ -202,16 +311,19 @@ function createMcpServer(sites) {
202
311
  return { content: [{ type: "text", text: `Error fetching page data: ${error.response?.data?.message || error.message}` }], isError: true };
203
312
  }
204
313
  });
205
- server.tool("find-elements", "Search the Elementor element tree of a page SERVER-SIDE and return ONLY matching elements — never downloads the full JSON. Use this instead of get-data whenever you need to locate specific widgets. Filters are combined with AND. Example uses: find all buttons (widget_type='button'), find buttons with no link (widget_type='button' + has_empty='link.url'), find headings containing a phrase (widget_type='heading' + contains='Welcome').", {
314
+ server.tool("find-elements", "Search the Elementor element tree of a page SERVER-SIDE and return ONLY matching elements — never downloads the full JSON. All filters AND together. Example uses: find buttons with no URL (widget_type='button' + has_empty='link.url'); find elements with a specific custom class (css_class='Mbutton'); find every page that embeds a specific template (template_id='3287'); audit all containers with background images (has_background_image=true).", {
206
315
  page_id: z.string().describe("WordPress Page ID"),
207
316
  el_type: z.string().optional().describe("Filter by elType: 'widget' or 'container'"),
208
- widget_type: z.string().optional().describe("Filter by widgetType e.g. 'button', 'heading', 'image', 'text-editor'"),
317
+ widget_type: z.string().optional().describe("Filter by widgetType e.g. 'button', 'heading', 'image', 'text-editor', 'template'"),
209
318
  contains: z.string().optional().describe("Substring that must appear anywhere in the element's settings JSON"),
210
319
  has_empty: z.string().optional().describe("Dot-notation setting path that must be empty/missing — e.g. 'link.url' finds buttons with no URL"),
320
+ css_class: z.string().optional().describe("Custom CSS class that must be in _css_classes — finds elements with a specific class, e.g. 'Mbutton', 'blogcontent'"),
321
+ template_id: z.string().optional().describe("Find template widgets that embed a specific template ID — useful for finding/replacing hero/CF/footer template references sitewide"),
322
+ has_background_image: z.boolean().optional().describe("Set true to match only containers with background_image.url set — useful for hero image audits"),
211
323
  return_keys: z.array(z.string()).optional().describe("Limit which settings keys are returned — e.g. ['text','link'] to keep response small"),
212
324
  include_path: z.boolean().optional().describe("Include ancestor element IDs in each result"),
213
325
  site: siteParam,
214
- }, async ({ page_id, el_type, widget_type, contains, has_empty, return_keys, include_path, site }) => {
326
+ }, async ({ page_id, el_type, widget_type, contains, has_empty, css_class, template_id, has_background_image, return_keys, include_path, site }) => {
215
327
  try {
216
328
  const { wpUrl, authHeader } = resolveSite(sites, site);
217
329
  const body = {};
@@ -223,6 +335,12 @@ function createMcpServer(sites) {
223
335
  body.contains = contains;
224
336
  if (has_empty)
225
337
  body.has_empty = has_empty;
338
+ if (css_class)
339
+ body.css_class = css_class;
340
+ if (template_id)
341
+ body.template_id = template_id;
342
+ if (has_background_image)
343
+ body.has_background_image = has_background_image;
226
344
  if (return_keys)
227
345
  body.return_keys = return_keys;
228
346
  if (include_path)
@@ -353,10 +471,10 @@ function createMcpServer(sites) {
353
471
  return { content: [{ type: "text", text: `Error moving element: ${error.response?.data?.message || error.message}` }], isError: true };
354
472
  }
355
473
  });
356
- server.tool("merge-element-settings", "Deep-merge settings into a specific Elementor element without replacing the full element. Use for small setting adjustments.", {
474
+ server.tool("merge-element-settings", "Deep-merge settings into a specific Elementor element without replacing the full element. ALWAYS prefer a dedicated typed tool when one exists (set-container-background, set-element-spacing, set-container-layout, set-element-width, set-element-min-height, set-element-border, set-element-position, set-element-sticky, set-element-motion-effects, set-element-flex-order, set-element-css-class, set-posts-widget-filter, set-element-link, hide-page-title). Use this fallback only for niche keys with no dedicated tool. For global tokens use the __globals__ object — e.g. {\"__globals__\":{\"text_color\":\"globals/colors?id=primary\",\"typography_typography\":\"globals/typography?id=heading\"}} sets a heading's color + typography from the kit.", {
357
475
  page_id: z.string().describe("WordPress Page ID"),
358
476
  element_id: z.string().describe("Elementor Element ID"),
359
- settings: z.string().describe("JSON object of settings to merge (stringified)"),
477
+ settings: z.string().describe("JSON object of settings to merge (stringified). Use __globals__ key to reference kit tokens, e.g. '{\"__globals__\":{\"text_color\":\"globals/colors?id=primary\"}}'"),
360
478
  site: siteParam,
361
479
  }, async ({ page_id, element_id, settings, site }) => {
362
480
  let parsedSettings;
@@ -368,15 +486,1089 @@ function createMcpServer(sites) {
368
486
  }
369
487
  try {
370
488
  const { wpUrl, authHeader } = resolveSite(sites, site);
371
- const getRes = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}`, { headers: { Authorization: authHeader } });
372
- const merged = { ...getRes.data, settings: { ...(getRes.data.settings || {}), ...parsedSettings } };
373
- const postRes = await axios.post(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}`, merged, { headers: { Authorization: authHeader } });
374
- return { content: [{ type: "text", text: JSON.stringify(postRes.data, null, 2) }] };
489
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, parsedSettings);
490
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
375
491
  }
376
492
  catch (error) {
377
493
  return { content: [{ type: "text", text: `Error merging settings: ${error.response?.data?.message || error.message}` }], isError: true };
378
494
  }
379
495
  });
496
+ // ═══════════════════════════════════════════════════════════════════════════
497
+ // ── v3.13.0 — TYPED SETTING WRAPPERS — built for Elementor web-design skill ─
498
+ // ═══════════════════════════════════════════════════════════════════════════
499
+ // All wrappers call applyElementSettings() under the hood. They translate
500
+ // clean parameter names into Elementor's quirky setting keys so the AI
501
+ // doesn't have to memorize them.
502
+ // ── 1. set-container-background ───────────────────────────────────────────
503
+ server.tool("set-container-background", "Set background of any container element — color, image, gradient — including overlay. Handles all background_* settings + responsive variants in one call. Use color='primary' to reference a kit global, or color='#FFFFFF' for hex. For gradient, pass background_type='gradient' + color + color_b. For an image with overlay, pass image_url + overlay_color + overlay_opacity. Used on every hero, CF section, team section, decorative background.", {
504
+ page_id: z.string().describe("WordPress Page ID"),
505
+ element_id: z.string().describe("Elementor container/widget ID"),
506
+ background_type: z.enum(["classic", "gradient", "video", "slideshow", "none"]).optional().describe("'classic' = solid/image, 'gradient' = two-color gradient, 'none' = clear background"),
507
+ color: z.string().optional().describe("Background color hex (e.g. '#FFFFFF') OR global slug (e.g. 'primary'). Use 'primary'/'secondary'/'accent' for kit globals."),
508
+ color_b: z.string().optional().describe("Gradient end color (only when background_type='gradient'). Hex or global slug."),
509
+ gradient_angle: z.number().optional().describe("Gradient angle in degrees (0–360). Default 180 (top to bottom)."),
510
+ gradient_type: z.enum(["linear", "radial"]).optional().describe("Gradient direction type. Default 'linear'."),
511
+ image_url: z.string().optional().describe("Background image URL (full https URL)"),
512
+ image_url_tablet: z.string().optional().describe("Tablet background image URL"),
513
+ image_url_mobile: z.string().optional().describe("Mobile background image URL"),
514
+ position: z.string().optional().describe("Background position e.g. 'center center', 'top left', 'bottom right'"),
515
+ position_tablet: z.string().optional(),
516
+ position_mobile: z.string().optional(),
517
+ size: z.enum(["cover", "contain", "auto"]).optional().describe("Background size — 'cover' (most common), 'contain', or 'auto'"),
518
+ repeat: z.enum(["no-repeat", "repeat", "repeat-x", "repeat-y"]).optional(),
519
+ overlay_color: z.string().optional().describe("Overlay color hex or global slug — adds a tinted layer over the background image"),
520
+ overlay_image_url: z.string().optional().describe("Overlay image URL — used for decorative SVG watermarks on top of background"),
521
+ overlay_opacity: z.number().min(0).max(1).optional().describe("Overlay opacity 0–1 (e.g. 0.4 for 40% dark overlay)"),
522
+ overlay_blend_mode: z.enum(["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"]).optional().describe("CSS blend mode — 'multiply' is common for tinted images"),
523
+ site: siteParam,
524
+ }, async ({ page_id, element_id, background_type, color, color_b, gradient_angle, gradient_type, image_url, image_url_tablet, image_url_mobile, position, position_tablet, position_mobile, size, repeat, overlay_color, overlay_image_url, overlay_opacity, overlay_blend_mode, site }) => {
525
+ try {
526
+ const { wpUrl, authHeader } = resolveSite(sites, site);
527
+ const s = {};
528
+ if (background_type)
529
+ s.background_background = background_type === "none" ? "" : background_type;
530
+ if (color) {
531
+ const c = resolveColor(color);
532
+ if (c.value)
533
+ s.background_color = c.value;
534
+ if (c.global_id)
535
+ s.__globals__ = { ...(s.__globals__ || {}), background_color: c.global_id };
536
+ }
537
+ if (color_b) {
538
+ const c2 = resolveColor(color_b);
539
+ if (c2.value)
540
+ s.background_color_b = c2.value;
541
+ if (c2.global_id)
542
+ s.__globals__ = { ...(s.__globals__ || {}), background_color_b: c2.global_id };
543
+ }
544
+ if (gradient_angle !== undefined)
545
+ s.background_gradient_angle = { unit: "deg", size: gradient_angle };
546
+ if (gradient_type)
547
+ s.background_gradient_type = gradient_type;
548
+ if (image_url)
549
+ s.background_image = { url: image_url, id: "" };
550
+ if (image_url_tablet)
551
+ s.background_image_tablet = { url: image_url_tablet, id: "" };
552
+ if (image_url_mobile)
553
+ s.background_image_mobile = { url: image_url_mobile, id: "" };
554
+ if (position)
555
+ s.background_position = position;
556
+ if (position_tablet)
557
+ s.background_position_tablet = position_tablet;
558
+ if (position_mobile)
559
+ s.background_position_mobile = position_mobile;
560
+ if (size)
561
+ s.background_size = size;
562
+ if (repeat)
563
+ s.background_repeat = repeat;
564
+ if (overlay_color || overlay_image_url || overlay_opacity !== undefined || overlay_blend_mode) {
565
+ s.background_overlay_background = "classic";
566
+ }
567
+ if (overlay_color) {
568
+ const c = resolveColor(overlay_color);
569
+ if (c.value)
570
+ s.background_overlay_color = c.value;
571
+ if (c.global_id)
572
+ s.__globals__ = { ...(s.__globals__ || {}), background_overlay_color: c.global_id };
573
+ }
574
+ if (overlay_image_url)
575
+ s.background_overlay_image = { url: overlay_image_url, id: "" };
576
+ if (overlay_opacity !== undefined)
577
+ s.background_overlay_opacity = { unit: "px", size: overlay_opacity };
578
+ if (overlay_blend_mode)
579
+ s.overlay_blend_mode = overlay_blend_mode;
580
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
581
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
582
+ }
583
+ catch (error) {
584
+ return { content: [{ type: "text", text: `Error setting background: ${error.response?.data?.message || error.message}` }], isError: true };
585
+ }
586
+ });
587
+ // ── 2. set-element-spacing ────────────────────────────────────────────────
588
+ server.tool("set-element-spacing", "Set padding and/or margin on any element across desktop/tablet/mobile. Accepts compact strings: '120 48 120 48' = top right bottom left in px, '5%' = all sides 5%, '100 0' = vertical horizontal. Supports negative values for margin: '-153 0 0 0' for negative top margin (header overlap technique). The #1 most-used setting after background.", {
589
+ page_id: z.string().describe("WordPress Page ID"),
590
+ element_id: z.string().describe("Elementor element ID"),
591
+ padding: z.string().optional().describe("Desktop padding. '120 48 120 48' (top right bottom left px), '5%' (all sides %), '100 0' (vertical horizontal)"),
592
+ padding_tablet: z.string().optional().describe("Tablet padding (same format as padding)"),
593
+ padding_mobile: z.string().optional().describe("Mobile padding (same format as padding)"),
594
+ margin: z.string().optional().describe("Desktop margin. Same format. Supports negatives: '-153 0 0 0' for negative top margin."),
595
+ margin_tablet: z.string().optional().describe("Tablet margin"),
596
+ margin_mobile: z.string().optional().describe("Mobile margin"),
597
+ site: siteParam,
598
+ }, async ({ page_id, element_id, padding, padding_tablet, padding_mobile, margin, margin_tablet, margin_mobile, site }) => {
599
+ try {
600
+ const { wpUrl, authHeader } = resolveSite(sites, site);
601
+ const s = {};
602
+ const p1 = parseSpacing(padding);
603
+ if (p1)
604
+ s.padding = p1;
605
+ const p2 = parseSpacing(padding_tablet);
606
+ if (p2)
607
+ s.padding_tablet = p2;
608
+ const p3 = parseSpacing(padding_mobile);
609
+ if (p3)
610
+ s.padding_mobile = p3;
611
+ const m1 = parseSpacing(margin);
612
+ if (m1)
613
+ s.margin = m1;
614
+ const m2 = parseSpacing(margin_tablet);
615
+ if (m2)
616
+ s.margin_tablet = m2;
617
+ const m3 = parseSpacing(margin_mobile);
618
+ if (m3)
619
+ s.margin_mobile = m3;
620
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
621
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
622
+ }
623
+ catch (error) {
624
+ return { content: [{ type: "text", text: `Error setting spacing: ${error.response?.data?.message || error.message}` }], isError: true };
625
+ }
626
+ });
627
+ // ── 3. set-container-layout ───────────────────────────────────────────────
628
+ server.tool("set-container-layout", "Set all flexbox layout properties on a container — direction, justify, align, wrap, gap, content_width — across desktop/tablet/mobile. THE most common container operation after background+spacing. For mobile stacking pass direction_mobile='column'. For boxed sites pass content_width='boxed' + boxed_width='1280'.", {
629
+ page_id: z.string().describe("WordPress Page ID"),
630
+ element_id: z.string().describe("Elementor container ID"),
631
+ direction: z.enum(["row", "column", "row-reverse", "column-reverse"]).optional().describe("Desktop flex direction"),
632
+ direction_tablet: z.enum(["row", "column", "row-reverse", "column-reverse"]).optional(),
633
+ direction_mobile: z.enum(["row", "column", "row-reverse", "column-reverse"]).optional().describe("Mobile direction — almost always 'column' for stacking"),
634
+ justify: z.enum(["flex-start", "center", "flex-end", "space-between", "space-around", "space-evenly"]).optional().describe("Desktop justify-content"),
635
+ justify_tablet: z.enum(["flex-start", "center", "flex-end", "space-between", "space-around", "space-evenly"]).optional(),
636
+ justify_mobile: z.enum(["flex-start", "center", "flex-end", "space-between", "space-around", "space-evenly"]).optional(),
637
+ align: z.enum(["flex-start", "center", "flex-end", "stretch"]).optional().describe("Desktop align-items"),
638
+ align_tablet: z.enum(["flex-start", "center", "flex-end", "stretch"]).optional(),
639
+ align_mobile: z.enum(["flex-start", "center", "flex-end", "stretch"]).optional(),
640
+ wrap: z.enum(["nowrap", "wrap"]).optional().describe("Desktop flex-wrap"),
641
+ wrap_tablet: z.enum(["nowrap", "wrap"]).optional(),
642
+ wrap_mobile: z.enum(["nowrap", "wrap"]).optional(),
643
+ gap: z.string().optional().describe("Desktop gap — '64' (px both axes), '64 32' (column row in px), '5%'"),
644
+ gap_tablet: z.string().optional(),
645
+ gap_mobile: z.string().optional(),
646
+ content_width: z.enum(["boxed", "full"]).optional().describe("'boxed' = constrain inner content to boxed_width; 'full' = stretch full viewport"),
647
+ boxed_width: z.string().optional().describe("Max content width when content_width='boxed'. E.g. '1280' (px), '1440px', '90%'"),
648
+ site: siteParam,
649
+ }, async ({ page_id, element_id, direction, direction_tablet, direction_mobile, justify, justify_tablet, justify_mobile, align, align_tablet, align_mobile, wrap, wrap_tablet, wrap_mobile, gap, gap_tablet, gap_mobile, content_width, boxed_width, site }) => {
650
+ try {
651
+ const { wpUrl, authHeader } = resolveSite(sites, site);
652
+ const s = {};
653
+ if (direction)
654
+ s.flex_direction = direction;
655
+ if (direction_tablet)
656
+ s.flex_direction_tablet = direction_tablet;
657
+ if (direction_mobile)
658
+ s.flex_direction_mobile = direction_mobile;
659
+ if (justify)
660
+ s.flex_justify_content = justify;
661
+ if (justify_tablet)
662
+ s.flex_justify_content_tablet = justify_tablet;
663
+ if (justify_mobile)
664
+ s.flex_justify_content_mobile = justify_mobile;
665
+ if (align)
666
+ s.flex_align_items = align;
667
+ if (align_tablet)
668
+ s.flex_align_items_tablet = align_tablet;
669
+ if (align_mobile)
670
+ s.flex_align_items_mobile = align_mobile;
671
+ if (wrap)
672
+ s.flex_wrap = wrap;
673
+ if (wrap_tablet)
674
+ s.flex_wrap_tablet = wrap_tablet;
675
+ if (wrap_mobile)
676
+ s.flex_wrap_mobile = wrap_mobile;
677
+ const g1 = parseGap(gap);
678
+ if (g1)
679
+ s.flex_gap = g1;
680
+ const g2 = parseGap(gap_tablet);
681
+ if (g2)
682
+ s.flex_gap_tablet = g2;
683
+ const g3 = parseGap(gap_mobile);
684
+ if (g3)
685
+ s.flex_gap_mobile = g3;
686
+ if (content_width)
687
+ s.content_width = content_width;
688
+ const bw = parseSize(boxed_width);
689
+ if (bw)
690
+ s.boxed_width = bw;
691
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
692
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
693
+ }
694
+ catch (error) {
695
+ return { content: [{ type: "text", text: `Error setting layout: ${error.response?.data?.message || error.message}` }], isError: true };
696
+ }
697
+ });
698
+ // ── 4. set-element-width ──────────────────────────────────────────────────
699
+ server.tool("set-element-width", "Set width of a column/container element across breakpoints. For child containers in a row, pass desktop='50%', mobile='100%' for two-column-stacking. Critical for every column layout.", {
700
+ page_id: z.string().describe("WordPress Page ID"),
701
+ element_id: z.string().describe("Elementor element ID"),
702
+ desktop: z.string().optional().describe("Desktop width — '50%', '300px', '50' (px default), '1fr' for grid"),
703
+ tablet: z.string().optional().describe("Tablet width"),
704
+ mobile: z.string().optional().describe("Mobile width — typically '100%' for stack-to-full-width"),
705
+ site: siteParam,
706
+ }, async ({ page_id, element_id, desktop, tablet, mobile, site }) => {
707
+ try {
708
+ const { wpUrl, authHeader } = resolveSite(sites, site);
709
+ const s = {};
710
+ const w1 = parseSize(desktop);
711
+ const w2 = parseSize(tablet);
712
+ const w3 = parseSize(mobile);
713
+ if (w1) {
714
+ s._element_width = "custom";
715
+ s._element_custom_width = w1;
716
+ s.width = w1;
717
+ }
718
+ if (w2) {
719
+ s._element_width_tablet = "custom";
720
+ s._element_custom_width_tablet = w2;
721
+ s.width_tablet = w2;
722
+ }
723
+ if (w3) {
724
+ s._element_width_mobile = "custom";
725
+ s._element_custom_width_mobile = w3;
726
+ s.width_mobile = w3;
727
+ }
728
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
729
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
730
+ }
731
+ catch (error) {
732
+ return { content: [{ type: "text", text: `Error setting width: ${error.response?.data?.message || error.message}` }], isError: true };
733
+ }
734
+ });
735
+ // ── 5. set-element-min-height ─────────────────────────────────────────────
736
+ server.tool("set-element-min-height", "Set minimum height of a container across breakpoints. Used on every hero section, CF image column, full-height containers. Supports px and vh — heroes often use '420px' desktop / '80vh' for viewport-relative.", {
737
+ page_id: z.string().describe("WordPress Page ID"),
738
+ element_id: z.string().describe("Elementor container ID"),
739
+ desktop: z.string().optional().describe("Desktop min-height — '420px', '80vh', '600'"),
740
+ tablet: z.string().optional(),
741
+ mobile: z.string().optional(),
742
+ site: siteParam,
743
+ }, async ({ page_id, element_id, desktop, tablet, mobile, site }) => {
744
+ try {
745
+ const { wpUrl, authHeader } = resolveSite(sites, site);
746
+ const s = {};
747
+ const v1 = parseSize(desktop);
748
+ if (v1)
749
+ s.min_height = v1;
750
+ const v2 = parseSize(tablet);
751
+ if (v2)
752
+ s.min_height_tablet = v2;
753
+ const v3 = parseSize(mobile);
754
+ if (v3)
755
+ s.min_height_mobile = v3;
756
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
757
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
758
+ }
759
+ catch (error) {
760
+ return { content: [{ type: "text", text: `Error setting min-height: ${error.response?.data?.message || error.message}` }], isError: true };
761
+ }
762
+ });
763
+ // ── 6. set-element-border ─────────────────────────────────────────────────
764
+ server.tool("set-element-border", "Set border properties (type, width, color, radius) on any element across breakpoints. Some footer/card patterns show different borders per breakpoint — pass per-device width if needed. Use color='primary' for kit globals.", {
765
+ page_id: z.string().describe("WordPress Page ID"),
766
+ element_id: z.string().describe("Elementor element ID"),
767
+ border_type: z.enum(["none", "solid", "dashed", "dotted", "double"]).optional().describe("'solid' is most common. 'none' clears all borders."),
768
+ width: z.string().optional().describe("Desktop border width — '1' (all sides 1px), '0 1 0 0' (right-only)"),
769
+ width_tablet: z.string().optional(),
770
+ width_mobile: z.string().optional(),
771
+ color: z.string().optional().describe("Border color hex or global slug (e.g. 'primary')"),
772
+ radius: z.string().optional().describe("Desktop border radius — '0' (square corners — standard), '8' (rounded), '10 10 0 0' (top-only round)"),
773
+ radius_tablet: z.string().optional(),
774
+ radius_mobile: z.string().optional(),
775
+ site: siteParam,
776
+ }, async ({ page_id, element_id, border_type, width, width_tablet, width_mobile, color, radius, radius_tablet, radius_mobile, site }) => {
777
+ try {
778
+ const { wpUrl, authHeader } = resolveSite(sites, site);
779
+ const s = {};
780
+ if (border_type)
781
+ s.border_border = border_type === "none" ? "" : border_type;
782
+ const w1 = parseSpacing(width);
783
+ if (w1)
784
+ s.border_width = w1;
785
+ const w2 = parseSpacing(width_tablet);
786
+ if (w2)
787
+ s.border_width_tablet = w2;
788
+ const w3 = parseSpacing(width_mobile);
789
+ if (w3)
790
+ s.border_width_mobile = w3;
791
+ if (color) {
792
+ const c = resolveColor(color);
793
+ if (c.value)
794
+ s.border_color = c.value;
795
+ if (c.global_id)
796
+ s.__globals__ = { ...(s.__globals__ || {}), border_color: c.global_id };
797
+ }
798
+ const r1 = parseSpacing(radius);
799
+ if (r1)
800
+ s.border_radius = r1;
801
+ const r2 = parseSpacing(radius_tablet);
802
+ if (r2)
803
+ s.border_radius_tablet = r2;
804
+ const r3 = parseSpacing(radius_mobile);
805
+ if (r3)
806
+ s.border_radius_mobile = r3;
807
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
808
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
809
+ }
810
+ catch (error) {
811
+ return { content: [{ type: "text", text: `Error setting border: ${error.response?.data?.message || error.message}` }], isError: true };
812
+ }
813
+ });
814
+ // ── 7. set-element-position ───────────────────────────────────────────────
815
+ server.tool("set-element-position", "Set CSS position and offset values on an element. Used for absolute-positioned decorative border frames, overlap techniques, and fixed elements. Pass position='absolute' + offset_x/_y for offset positioning. Pass position='' (empty) to reset to default flow positioning.", {
816
+ page_id: z.string().describe("WordPress Page ID"),
817
+ element_id: z.string().describe("Elementor element ID"),
818
+ position: z.enum(["", "absolute", "relative", "fixed"]).optional().describe("'absolute' takes element out of flow; 'relative' allows offset without breaking flow; '' resets"),
819
+ offset_x: z.string().optional().describe("Horizontal offset desktop — '20px', '-2%' (negative for left)"),
820
+ offset_x_tablet: z.string().optional(),
821
+ offset_x_mobile: z.string().optional(),
822
+ offset_y: z.string().optional().describe("Vertical offset desktop — '20px', '-2%' (negative for up)"),
823
+ offset_y_tablet: z.string().optional(),
824
+ offset_y_mobile: z.string().optional(),
825
+ site: siteParam,
826
+ }, async ({ page_id, element_id, position, offset_x, offset_x_tablet, offset_x_mobile, offset_y, offset_y_tablet, offset_y_mobile, site }) => {
827
+ try {
828
+ const { wpUrl, authHeader } = resolveSite(sites, site);
829
+ const s = {};
830
+ if (position !== undefined)
831
+ s.position = position;
832
+ const ox1 = parseSize(offset_x);
833
+ if (ox1)
834
+ s._offset_x = ox1;
835
+ const ox2 = parseSize(offset_x_tablet);
836
+ if (ox2)
837
+ s._offset_x_tablet = ox2;
838
+ const ox3 = parseSize(offset_x_mobile);
839
+ if (ox3)
840
+ s._offset_x_mobile = ox3;
841
+ const oy1 = parseSize(offset_y);
842
+ if (oy1)
843
+ s._offset_y = oy1;
844
+ const oy2 = parseSize(offset_y_tablet);
845
+ if (oy2)
846
+ s._offset_y_tablet = oy2;
847
+ const oy3 = parseSize(offset_y_mobile);
848
+ if (oy3)
849
+ s._offset_y_mobile = oy3;
850
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
851
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
852
+ }
853
+ catch (error) {
854
+ return { content: [{ type: "text", text: `Error setting position: ${error.response?.data?.message || error.message}` }], isError: true };
855
+ }
856
+ });
857
+ // ── 8. set-element-sticky ─────────────────────────────────────────────────
858
+ server.tool("set-element-sticky", "Configure sticky scroll behavior on a container/widget. Used on every header (sticky='top') and some image columns. Pass sticky_on=['desktop','tablet','mobile'] to control where it applies. sticky_offset = scroll distance before sticking; sticky_effects_offset = trigger for opacity/animation effects.", {
859
+ page_id: z.string().describe("WordPress Page ID"),
860
+ element_id: z.string().describe("Elementor element ID"),
861
+ sticky: z.enum(["", "top", "bottom"]).optional().describe("'top' = sticks at top of viewport on scroll (typical header); '' = disabled"),
862
+ sticky_on: z.array(z.enum(["desktop", "tablet", "mobile"])).optional().describe("Which breakpoints sticky applies on. Default Elementor: all three."),
863
+ sticky_offset: z.number().optional().describe("Scroll offset in px before stickiness engages (default 0)"),
864
+ sticky_effects_offset: z.number().optional().describe("Scroll px before scroll-triggered effects kick in"),
865
+ site: siteParam,
866
+ }, async ({ page_id, element_id, sticky, sticky_on, sticky_offset, sticky_effects_offset, site }) => {
867
+ try {
868
+ const { wpUrl, authHeader } = resolveSite(sites, site);
869
+ const s = {};
870
+ if (sticky !== undefined)
871
+ s.sticky = sticky;
872
+ if (sticky_on)
873
+ s.sticky_on = sticky_on;
874
+ if (sticky_offset !== undefined)
875
+ s.sticky_offset = sticky_offset;
876
+ if (sticky_effects_offset !== undefined)
877
+ s.sticky_effects_offset = sticky_effects_offset;
878
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
879
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
880
+ }
881
+ catch (error) {
882
+ return { content: [{ type: "text", text: `Error setting sticky: ${error.response?.data?.message || error.message}` }], isError: true };
883
+ }
884
+ });
885
+ // ── 9. set-element-motion-effects (merged entrance animation + motion fx) ─
886
+ server.tool("set-element-motion-effects", "Set scroll/entrance motion effects — entrance animation OR background motion-fx (scroll opacity, scroll translate). Used on hero sections, sticky headers, and scroll-triggered card reveals. For entrance: pass entrance_animation + duration. For scroll fade-out on sticky headers: pass scroll_opacity_enabled=true + scroll_opacity_to=0.4.", {
887
+ page_id: z.string().describe("WordPress Page ID"),
888
+ element_id: z.string().describe("Elementor element ID"),
889
+ entrance_animation: z.string().optional().describe("Entrance animation: 'fadeIn', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'fadeInDown', 'zoomIn', 'slideInUp', 'none'"),
890
+ animation_duration: z.enum(["slow", "normal", "fast"]).optional().describe("Default: 'normal'"),
891
+ animation_delay: z.number().optional().describe("Delay in milliseconds before entrance plays"),
892
+ scroll_opacity_enabled: z.boolean().optional().describe("Enable scroll-triggered opacity effect (sticky header fade on scroll)"),
893
+ scroll_opacity_from: z.number().min(0).max(1).optional().describe("Opacity start value (default 1)"),
894
+ scroll_opacity_to: z.number().min(0).max(1).optional().describe("Opacity end value (e.g. 0.4 for partial fade)"),
895
+ scroll_translate_enabled: z.boolean().optional().describe("Enable parallax translateY effect"),
896
+ scroll_translate_speed: z.number().optional().describe("Translate speed value (Elementor default 4)"),
897
+ motion_fx_range: z.enum(["", "viewport", "page"]).optional().describe("Range where motion-fx applies"),
898
+ site: siteParam,
899
+ }, async ({ page_id, element_id, entrance_animation, animation_duration, animation_delay, scroll_opacity_enabled, scroll_opacity_from, scroll_opacity_to, scroll_translate_enabled, scroll_translate_speed, motion_fx_range, site }) => {
900
+ try {
901
+ const { wpUrl, authHeader } = resolveSite(sites, site);
902
+ const s = {};
903
+ if (entrance_animation)
904
+ s.animation = entrance_animation;
905
+ if (animation_duration)
906
+ s.animation_duration = animation_duration;
907
+ if (animation_delay !== undefined)
908
+ s._animation_delay = animation_delay;
909
+ if (scroll_opacity_enabled !== undefined) {
910
+ s.motion_fx_motion_fx_scrolling = "yes";
911
+ s.motion_fx_opacity_effect = scroll_opacity_enabled ? "yes" : "";
912
+ }
913
+ if (scroll_opacity_from !== undefined || scroll_opacity_to !== undefined) {
914
+ s.motion_fx_opacity_range = { unit: "%", sizes: { from: scroll_opacity_from ?? 1, to: scroll_opacity_to ?? 0.4 } };
915
+ }
916
+ if (scroll_translate_enabled !== undefined) {
917
+ s.motion_fx_motion_fx_scrolling = "yes";
918
+ s.motion_fx_translateY_effect = scroll_translate_enabled ? "yes" : "";
919
+ }
920
+ if (scroll_translate_speed !== undefined) {
921
+ s.motion_fx_translateY_speed = { unit: "px", size: scroll_translate_speed };
922
+ }
923
+ if (motion_fx_range !== undefined)
924
+ s.motion_fx_range = motion_fx_range;
925
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
926
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
927
+ }
928
+ catch (error) {
929
+ return { content: [{ type: "text", text: `Error setting motion effects: ${error.response?.data?.message || error.message}` }], isError: true };
930
+ }
931
+ });
932
+ // ── 10. set-element-flex-order ────────────────────────────────────────────
933
+ server.tool("set-element-flex-order", "Set CSS order per breakpoint — used to reorder visual layout (e.g. image goes above text on mobile). Pass mobile='start' to pull this element to the top in the mobile stack. THE fix for 'image should appear above text on mobile but to the right on desktop'.", {
934
+ page_id: z.string().describe("WordPress Page ID"),
935
+ element_id: z.string().describe("Elementor element ID"),
936
+ desktop: z.enum(["default", "start", "end", "custom"]).optional(),
937
+ desktop_custom: z.number().optional().describe("Custom order integer (when desktop='custom')"),
938
+ tablet: z.enum(["default", "start", "end", "custom"]).optional(),
939
+ tablet_custom: z.number().optional(),
940
+ mobile: z.enum(["default", "start", "end", "custom"]).optional().describe("'start' = pull this element to the top of mobile stack (most common use)"),
941
+ mobile_custom: z.number().optional(),
942
+ site: siteParam,
943
+ }, async ({ page_id, element_id, desktop, desktop_custom, tablet, tablet_custom, mobile, mobile_custom, site }) => {
944
+ try {
945
+ const { wpUrl, authHeader } = resolveSite(sites, site);
946
+ const s = {};
947
+ if (desktop)
948
+ s._flex_order = desktop;
949
+ if (desktop_custom !== undefined)
950
+ s._flex_order_custom = desktop_custom;
951
+ if (tablet)
952
+ s._flex_order_tablet = tablet;
953
+ if (tablet_custom !== undefined)
954
+ s._flex_order_custom_tablet = tablet_custom;
955
+ if (mobile)
956
+ s._flex_order_mobile = mobile;
957
+ if (mobile_custom !== undefined)
958
+ s._flex_order_custom_mobile = mobile_custom;
959
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
960
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
961
+ }
962
+ catch (error) {
963
+ return { content: [{ type: "text", text: `Error setting flex order: ${error.response?.data?.message || error.message}` }], isError: true };
964
+ }
965
+ });
966
+ // ── 11. set-element-css-class ─────────────────────────────────────────────
967
+ server.tool("set-element-css-class", "Set custom CSS class(es) and/or element-scoped custom CSS. Used across all sites — e.g. 'Mbutton' class on header CTA buttons, 'blogcontent' on post content, 'sticky-wrapper' on header. Pass space-separated classes. Pass css_code to add inline CSS scoped to this element.", {
968
+ page_id: z.string().describe("WordPress Page ID"),
969
+ element_id: z.string().describe("Elementor element ID"),
970
+ css_classes: z.string().optional().describe("Space-separated CSS classes, e.g. 'Mbutton brand-cta'"),
971
+ css_code: z.string().optional().describe("Custom CSS scoped to this element. Use 'selector' as the placeholder for this element's selector."),
972
+ site: siteParam,
973
+ }, async ({ page_id, element_id, css_classes, css_code, site }) => {
974
+ try {
975
+ const { wpUrl, authHeader } = resolveSite(sites, site);
976
+ const s = {};
977
+ if (css_classes !== undefined)
978
+ s._css_classes = css_classes;
979
+ if (css_code !== undefined)
980
+ s.custom_css = css_code;
981
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
982
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
983
+ }
984
+ catch (error) {
985
+ return { content: [{ type: "text", text: `Error setting CSS class: ${error.response?.data?.message || error.message}` }], isError: true };
986
+ }
987
+ });
988
+ // ── 12. set-posts-widget-filter ───────────────────────────────────────────
989
+ server.tool("set-posts-widget-filter", "Configure query/filter settings on a Posts widget — post type, term IDs, ordering, skin, columns. Replaces ~15 verbose merge-element-settings keys with named params. Posts widget is dominant for blog/services listings.", {
990
+ page_id: z.string().describe("WordPress Page ID"),
991
+ element_id: z.string().describe("Elementor Posts widget ID"),
992
+ post_type: z.string().optional().describe("'post', 'page', 'astra-portfolio', 'by_id', or any custom post type"),
993
+ include_term_ids: z.array(z.number()).optional().describe("Include posts from these taxonomy term IDs"),
994
+ exclude_term_ids: z.array(z.number()).optional(),
995
+ include_ids: z.array(z.number()).optional().describe("Specific post IDs to include (used with post_type='by_id')"),
996
+ exclude_ids: z.array(z.number()).optional(),
997
+ posts_per_page: z.number().optional().describe("How many posts per page (e.g. 12)"),
998
+ order: z.enum(["ASC", "DESC"]).optional(),
999
+ orderby: z.enum(["date", "modified", "title", "menu_order", "rand", "comment_count"]).optional(),
1000
+ skin: z.enum(["classic", "cards", "full_content"]).optional(),
1001
+ columns: z.number().optional().describe("Desktop column count"),
1002
+ columns_tablet: z.number().optional(),
1003
+ columns_mobile: z.number().optional(),
1004
+ show_excerpt: z.boolean().optional(),
1005
+ show_read_more: z.boolean().optional(),
1006
+ site: siteParam,
1007
+ }, async ({ page_id, element_id, post_type, include_term_ids, exclude_term_ids, include_ids, exclude_ids, posts_per_page, order, orderby, skin, columns, columns_tablet, columns_mobile, show_excerpt, show_read_more, site }) => {
1008
+ try {
1009
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1010
+ const s = {};
1011
+ if (post_type)
1012
+ s.posts_post_type = post_type;
1013
+ if (include_term_ids)
1014
+ s.posts_include_term_ids = include_term_ids;
1015
+ if (exclude_term_ids)
1016
+ s.posts_exclude_term_ids = exclude_term_ids;
1017
+ if (include_ids)
1018
+ s.posts_include_ids = include_ids;
1019
+ if (exclude_ids)
1020
+ s.posts_exclude_ids = exclude_ids;
1021
+ if (posts_per_page !== undefined)
1022
+ s.posts_per_page = posts_per_page;
1023
+ if (order)
1024
+ s.order = order;
1025
+ if (orderby)
1026
+ s.orderby = orderby;
1027
+ if (skin)
1028
+ s._skin = skin;
1029
+ if (columns !== undefined)
1030
+ s.columns = { unit: "px", size: columns };
1031
+ if (columns_tablet !== undefined)
1032
+ s.columns_tablet = { unit: "px", size: columns_tablet };
1033
+ if (columns_mobile !== undefined)
1034
+ s.columns_mobile = { unit: "px", size: columns_mobile };
1035
+ if (show_excerpt !== undefined)
1036
+ s.show_excerpt = show_excerpt ? "yes" : "";
1037
+ if (show_read_more !== undefined)
1038
+ s.show_read_more = show_read_more ? "yes" : "";
1039
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
1040
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
1041
+ }
1042
+ catch (error) {
1043
+ return { content: [{ type: "text", text: `Error setting posts filter: ${error.response?.data?.message || error.message}` }], isError: true };
1044
+ }
1045
+ });
1046
+ // ═══════════════════════════════════════════════════════════════════════════
1047
+ // ── COMPOUND BUILDERS — scaffold multi-step ops into one tool ─────────────
1048
+ // ═══════════════════════════════════════════════════════════════════════════
1049
+ // ── 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.", {
1051
+ page_id: z.string().describe("WordPress Page ID"),
1052
+ parent_id: z.string().optional().describe("Parent container ID. Omit for top-level section."),
1053
+ position: z.enum(["before", "after", "first_child", "last_child"]).optional().describe("Default 'last_child' (append to parent or page end)"),
1054
+ reference_id: z.string().optional().describe("Required when position='before'/'after' — the sibling element ID to anchor to"),
1055
+ direction: z.enum(["row", "column"]).optional().describe("Flex direction — default 'column' for section, 'row' for inner row containers"),
1056
+ direction_mobile: z.enum(["row", "column"]).optional().describe("Default 'column' for mobile stacking"),
1057
+ content_width: z.enum(["boxed", "full"]).optional().describe("'boxed' = constrained to boxed_width; 'full' = stretch to viewport"),
1058
+ boxed_width: z.string().optional().describe("Max content width when boxed — '1280', '1440px'"),
1059
+ padding: z.string().optional().describe("Desktop padding — '120 48 120 48', '5%'"),
1060
+ padding_tablet: z.string().optional(),
1061
+ padding_mobile: z.string().optional(),
1062
+ gap: z.string().optional().describe("Flex gap — '64', '64 32'"),
1063
+ gap_tablet: z.string().optional(),
1064
+ gap_mobile: z.string().optional(),
1065
+ min_height: z.string().optional().describe("Min-height — '420px', '80vh'"),
1066
+ min_height_tablet: z.string().optional(),
1067
+ min_height_mobile: z.string().optional(),
1068
+ background_color: z.string().optional().describe("Background color — hex or global slug"),
1069
+ background_image: z.string().optional().describe("Background image URL"),
1070
+ overlay_color: z.string().optional().describe("Overlay color — hex or global slug"),
1071
+ overlay_opacity: z.number().optional().describe("Overlay opacity 0–1"),
1072
+ 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 }) => {
1074
+ try {
1075
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1076
+ const settings = {
1077
+ content_width: content_width || "boxed",
1078
+ flex_direction: direction || "column",
1079
+ };
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 };
1134
+ }
1135
+ if (overlay_opacity !== undefined) {
1136
+ settings.background_overlay_opacity = { unit: "px", size: overlay_opacity };
1137
+ }
1138
+ }
1139
+ const newElement = { elType: "container", settings, elements: [] };
1140
+ const body = { element: newElement, position: position || "last_child" };
1141
+ if (parent_id)
1142
+ body.parent_id = parent_id;
1143
+ if (reference_id)
1144
+ body.reference_id = reference_id;
1145
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/insert-element`, body, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
1146
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
1147
+ }
1148
+ catch (error) {
1149
+ return { content: [{ type: "text", text: `Error creating section: ${error.response?.data?.message || error.message}` }], isError: true };
1150
+ }
1151
+ });
1152
+ // ── 14. embed-template-in-page ────────────────────────────────────────────
1153
+ server.tool("embed-template-in-page", "Insert a saved Elementor template (header/footer/hero/CF section) into a page as a template widget — wrapped in a full-width container. This is how every page should embed its hero, CF, and footer templates. Pass either template_id (numeric) OR template_title (calls get-template-by-title internally). Default position is last_child — use first_child for hero/header at top.", {
1154
+ page_id: z.string().describe("WordPress Page ID where the template should be embedded"),
1155
+ template_id: z.string().optional().describe("Numeric template ID (use this if you have it)"),
1156
+ template_title: z.string().optional().describe("Template title (substring match) — used if template_id is not provided"),
1157
+ template_type: z.string().optional().describe("When using template_title, optionally filter by type (header/footer/popup/section/page) for a more precise match"),
1158
+ position: z.enum(["before", "after", "first_child", "last_child"]).optional().describe("Default 'last_child'. Use 'first_child' for hero/title template at top."),
1159
+ reference_id: z.string().optional().describe("Required when position='before'/'after' — sibling element to anchor to"),
1160
+ parent_id: z.string().optional().describe("Parent container — omit for top-level"),
1161
+ site: siteParam,
1162
+ }, async ({ page_id, template_id, template_title, template_type, position, reference_id, parent_id, site }) => {
1163
+ try {
1164
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1165
+ let resolvedId = template_id;
1166
+ if (!resolvedId && template_title) {
1167
+ const params = { title: template_title };
1168
+ if (template_type)
1169
+ params.type = template_type;
1170
+ const s = await axios.get(`${wpUrl}/wp-json/erc/v1/templates/search`, {
1171
+ headers: { Authorization: authHeader },
1172
+ params,
1173
+ });
1174
+ if (!s.data?.results?.length) {
1175
+ return { content: [{ type: "text", text: `No template found matching title='${template_title}'${template_type ? ` type='${template_type}'` : ""}` }], isError: true };
1176
+ }
1177
+ resolvedId = String(s.data.results[0].id);
1178
+ }
1179
+ if (!resolvedId) {
1180
+ return { content: [{ type: "text", text: "Provide either template_id or template_title." }], isError: true };
1181
+ }
1182
+ const newElement = {
1183
+ elType: "container",
1184
+ settings: {
1185
+ content_width: "full",
1186
+ padding: { unit: "px", top: "0", right: "0", bottom: "0", left: "0", isLinked: true },
1187
+ },
1188
+ elements: [{
1189
+ elType: "widget",
1190
+ widgetType: "template",
1191
+ settings: { template_id: resolvedId },
1192
+ }],
1193
+ };
1194
+ const body = { element: newElement, position: position || "last_child" };
1195
+ if (parent_id)
1196
+ body.parent_id = parent_id;
1197
+ if (reference_id)
1198
+ body.reference_id = reference_id;
1199
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/insert-element`, body, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
1200
+ return { content: [{ type: "text", text: JSON.stringify({ embedded_template_id: resolvedId, ...r.data }, null, 2) }] };
1201
+ }
1202
+ catch (error) {
1203
+ return { content: [{ type: "text", text: `Error embedding template: ${error.response?.data?.message || error.message}` }], isError: true };
1204
+ }
1205
+ });
1206
+ // ═══════════════════════════════════════════════════════════════════════════
1207
+ // ── KIT TOKENS + TEMPLATE SEARCH ──────────────────────────────────────────
1208
+ // ═══════════════════════════════════════════════════════════════════════════
1209
+ // ── 15. set-global-kit-colors ─────────────────────────────────────────────
1210
+ server.tool("set-global-kit-colors", "Add or update individual global color tokens in the Elementor kit. Every new site build starts here. Pass an array of {id, title, value} objects. Existing colors with the same id are updated; new ones appended. Standard token IDs: 'primary', 'secondary', 'accent', 'text', 'white_color'.", {
1211
+ colors: z.array(z.object({
1212
+ id: z.string().describe("Token slug (e.g. 'primary', 'secondary', 'accent', 'text')"),
1213
+ title: z.string().describe("Human-readable name shown in the kit panel"),
1214
+ value: z.string().describe("Hex color value, e.g. '#0066cc'"),
1215
+ })).min(1).describe("Array of color tokens to upsert"),
1216
+ site: siteParam,
1217
+ }, async ({ colors, site }) => {
1218
+ try {
1219
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1220
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/kit/colors`, { colors }, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
1221
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
1222
+ }
1223
+ catch (error) {
1224
+ return { content: [{ type: "text", text: `Error setting kit colors: ${error.response?.data?.message || error.message}` }], isError: true };
1225
+ }
1226
+ });
1227
+ // ── 16. set-global-kit-typography ─────────────────────────────────────────
1228
+ server.tool("set-global-kit-typography", "Add or update individual global typography tokens in the Elementor kit. Every new site needs 3-5 typography tokens (primary heading, secondary heading, body, accent) before widgets can reference globals correctly.", {
1229
+ typography: z.array(z.object({
1230
+ id: z.string().describe("Token slug (e.g. 'primary', 'secondary', 'text', 'accent')"),
1231
+ title: z.string().describe("Human-readable name"),
1232
+ font_family: z.string().optional().describe("Font family name (must be loaded by theme), e.g. 'Playfair Display', 'Inter'"),
1233
+ font_size: z.union([z.number(), z.object({ unit: z.string(), size: z.number() })]).optional().describe("Number = px (e.g. 60). Or object {unit:'px',size:60}"),
1234
+ font_weight: z.string().optional().describe("'400', '500', '600', '700', 'normal', 'bold'"),
1235
+ text_transform: z.enum(["none", "uppercase", "lowercase", "capitalize"]).optional(),
1236
+ letter_spacing: z.union([z.number(), z.object({ unit: z.string(), size: z.number() })]).optional(),
1237
+ line_height: z.union([z.number(), z.object({ unit: z.string(), size: z.number() })]).optional(),
1238
+ font_style: z.enum(["normal", "italic", "oblique"]).optional(),
1239
+ text_decoration: z.enum(["none", "underline", "line-through", "overline"]).optional(),
1240
+ })).min(1).describe("Array of typography tokens to upsert"),
1241
+ site: siteParam,
1242
+ }, async ({ typography, site }) => {
1243
+ try {
1244
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1245
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/kit/typography`, { typography }, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
1246
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
1247
+ }
1248
+ catch (error) {
1249
+ return { content: [{ type: "text", text: `Error setting kit typography: ${error.response?.data?.message || error.message}` }], isError: true };
1250
+ }
1251
+ });
1252
+ // ── 17. get-template-by-title ─────────────────────────────────────────────
1253
+ server.tool("get-template-by-title", "Find Elementor templates by title (case-insensitive substring match). When building a new page you usually know template names ('Header', 'Footer', 'CF Section') but not their numeric IDs. Eliminates the list-templates → manual scan step.", {
1254
+ title: z.string().describe("Title to search for (substring, case-insensitive)"),
1255
+ type: z.string().optional().describe("Optional filter by template type — 'header', 'footer', 'popup', 'section', 'page', 'archive', 'single'"),
1256
+ site: siteParam,
1257
+ }, async ({ title, type, site }) => {
1258
+ try {
1259
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1260
+ const params = { title };
1261
+ if (type)
1262
+ params.type = type;
1263
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/templates/search`, {
1264
+ headers: { Authorization: authHeader },
1265
+ params,
1266
+ });
1267
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
1268
+ }
1269
+ catch (error) {
1270
+ return { content: [{ type: "text", text: `Error searching templates: ${error.response?.data?.message || error.message}` }], isError: true };
1271
+ }
1272
+ });
1273
+ // ═══════════════════════════════════════════════════════════════════════════
1274
+ // ── v3.14.0 — Figma → Elementor pipeline tools ────────────────────────────
1275
+ // ═══════════════════════════════════════════════════════════════════════════
1276
+ // ── 18. upload-image-to-media-library ─────────────────────────────────────
1277
+ server.tool("upload-image-to-media-library", "Upload an image to the WordPress media library and return its permanent URL. Three input modes: 1) file_path (local file on this machine — MCP server reads it from disk, NEVER passes bytes through Claude's context — efficient for Figma exports), 2) url (remote URL — WP fetches it directly), 3) base64_data (inline base64 — use sparingly, expensive in tokens). After upload, use the returned URL with set-container-background, set-image-widget-src, etc.", {
1278
+ file_path: z.string().optional().describe("Absolute local file path (e.g. D:\\figma-exports\\hero.png). Most efficient — file goes disk → MCP server → WP without passing through Claude's context."),
1279
+ url: z.string().optional().describe("Remote URL to fetch and upload (e.g. external CDN URL). WordPress downloads it server-side."),
1280
+ base64_data: z.string().optional().describe("Base64-encoded image bytes (NO data: prefix). Use only when you already have base64 in context — passing it costs tokens proportional to image size."),
1281
+ mime_type: z.string().optional().describe("MIME type (e.g. 'image/png', 'image/jpeg', 'image/svg+xml'). Auto-detected from filename if omitted."),
1282
+ filename: z.string().describe("Desired filename in media library, e.g. 'hero-clearstone.png'. Must include extension."),
1283
+ title: z.string().optional().describe("Attachment title (defaults to filename without extension)"),
1284
+ alt_text: z.string().optional().describe("Alt text for accessibility"),
1285
+ site: siteParam,
1286
+ }, async ({ file_path, url, base64_data, mime_type, filename, title, alt_text, site }) => {
1287
+ try {
1288
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1289
+ let body = { filename };
1290
+ if (title)
1291
+ body.title = title;
1292
+ if (alt_text)
1293
+ body.alt_text = alt_text;
1294
+ if (mime_type)
1295
+ body.mimeType = mime_type;
1296
+ if (file_path) {
1297
+ if (!fs.existsSync(file_path)) {
1298
+ return { content: [{ type: "text", text: `Error: File not found at path: ${file_path}` }], isError: true };
1299
+ }
1300
+ // Read file from disk and convert to base64 — happens in MCP server memory, NOT Claude context
1301
+ const buf = fs.readFileSync(file_path);
1302
+ body.data = buf.toString("base64");
1303
+ // Auto-detect mime from extension if not provided
1304
+ if (!body.mimeType) {
1305
+ const ext = path.extname(file_path).toLowerCase();
1306
+ const mimes = {
1307
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
1308
+ ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
1309
+ ".pdf": "application/pdf",
1310
+ };
1311
+ body.mimeType = mimes[ext] || "application/octet-stream";
1312
+ }
1313
+ }
1314
+ else if (url) {
1315
+ body.url = url;
1316
+ }
1317
+ else if (base64_data) {
1318
+ body.data = base64_data;
1319
+ }
1320
+ else {
1321
+ return { content: [{ type: "text", text: "Provide one of: file_path, url, or base64_data." }], isError: true };
1322
+ }
1323
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/media/upload`, body, {
1324
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
1325
+ maxBodyLength: 100 * 1024 * 1024, // allow up to 100MB
1326
+ maxContentLength: 100 * 1024 * 1024,
1327
+ });
1328
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
1329
+ }
1330
+ catch (error) {
1331
+ return { content: [{ type: "text", text: `Error uploading to media library: ${error.response?.data?.message || error.message}` }], isError: true };
1332
+ }
1333
+ });
1334
+ // ── 19. set-element-shadow ────────────────────────────────────────────────
1335
+ server.tool("set-element-shadow", "Set box-shadow on any element — drop shadow or inset shadow. Figma's drop-shadow effects translate directly to this. Maps to Elementor's box_shadow_* settings. Set shadow_type='none' to remove. For multiple stacked shadows (common in Figma), call this once per shadow with merge: just pass the dominant one — Elementor's UI supports one box shadow per element.", {
1336
+ page_id: z.string().describe("WordPress Page ID"),
1337
+ element_id: z.string().describe("Elementor element ID"),
1338
+ shadow_type: z.enum(["none", "outline", "inset"]).optional().describe("'outline' = drop shadow (default), 'inset' = inner shadow, 'none' = remove shadow"),
1339
+ color: z.string().optional().describe("Shadow color hex with alpha — e.g. '#00000040' for 25% black. Or use global slug."),
1340
+ horizontal: z.number().optional().describe("X offset in px (positive = right)"),
1341
+ vertical: z.number().optional().describe("Y offset in px (positive = down)"),
1342
+ blur: z.number().optional().describe("Blur radius in px (Figma calls this 'radius')"),
1343
+ spread: z.number().optional().describe("Spread radius in px (Figma drop-shadow has 'spread' as the 4th value)"),
1344
+ site: siteParam,
1345
+ }, async ({ page_id, element_id, shadow_type, color, horizontal, vertical, blur, spread, site }) => {
1346
+ try {
1347
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1348
+ const s = {};
1349
+ if (shadow_type === "none") {
1350
+ s.box_shadow_box_shadow_type = "";
1351
+ }
1352
+ else if (shadow_type) {
1353
+ s.box_shadow_box_shadow_type = shadow_type;
1354
+ }
1355
+ if (color) {
1356
+ const c = resolveColor(color);
1357
+ if (c.value)
1358
+ s.box_shadow_box_shadow = { ...(s.box_shadow_box_shadow || {}), color: c.value };
1359
+ if (c.global_id)
1360
+ s.__globals__ = { ...(s.__globals__ || {}), box_shadow_box_shadow: c.global_id };
1361
+ }
1362
+ // Elementor stores box_shadow as a single object with horizontal/vertical/blur/spread/color
1363
+ const shadowObj = s.box_shadow_box_shadow || {};
1364
+ if (horizontal !== undefined)
1365
+ shadowObj.horizontal = horizontal;
1366
+ if (vertical !== undefined)
1367
+ shadowObj.vertical = vertical;
1368
+ if (blur !== undefined)
1369
+ shadowObj.blur = blur;
1370
+ if (spread !== undefined)
1371
+ shadowObj.spread = spread;
1372
+ if (Object.keys(shadowObj).length > 0)
1373
+ s.box_shadow_box_shadow = shadowObj;
1374
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
1375
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
1376
+ }
1377
+ catch (error) {
1378
+ return { content: [{ type: "text", text: `Error setting shadow: ${error.response?.data?.message || error.message}` }], isError: true };
1379
+ }
1380
+ });
1381
+ // ── 20. set-element-typography ────────────────────────────────────────────
1382
+ server.tool("set-element-typography", "Set typography on a heading/text/button widget — font family, size, weight, line height, letter spacing, text-transform, color. Critical for Figma → Elementor: Figma has explicit font weights (400/500/700) that must translate to Elementor's typography_font_weight. Use global_typography='heading' to reference a kit typography token (preferred). Use color='primary' to reference a kit color global.", {
1383
+ page_id: z.string().describe("WordPress Page ID"),
1384
+ element_id: z.string().describe("Elementor widget ID (heading, text-editor, button, etc.)"),
1385
+ global_typography: z.string().optional().describe("Kit typography token slug — references a global (e.g. 'primary', 'heading'). Preferred over individual properties."),
1386
+ font_family: z.string().optional().describe("Font family name, e.g. 'Playfair Display', 'Inter'"),
1387
+ font_size: z.string().optional().describe("Desktop font size — '60', '60px', '2.5rem', '60vh'"),
1388
+ font_size_tablet: z.string().optional(),
1389
+ font_size_mobile: z.string().optional(),
1390
+ font_weight: z.string().optional().describe("'400', '500', '600', '700', 'normal', 'bold'"),
1391
+ text_transform: z.enum(["none", "uppercase", "lowercase", "capitalize"]).optional(),
1392
+ text_decoration: z.enum(["none", "underline", "overline", "line-through"]).optional(),
1393
+ font_style: z.enum(["normal", "italic", "oblique"]).optional(),
1394
+ line_height: z.string().optional().describe("Line height — '1.5' (unitless), '24px', '1.5em'"),
1395
+ letter_spacing: z.string().optional().describe("Letter spacing — '1' (px default), '2px', '0.05em'"),
1396
+ color: z.string().optional().describe("Text color hex (e.g. '#000000') OR global slug (e.g. 'primary')"),
1397
+ align: z.enum(["left", "center", "right", "justify"]).optional().describe("Text alignment (only applies to widgets with align setting)"),
1398
+ align_mobile: z.enum(["left", "center", "right", "justify"]).optional(),
1399
+ site: siteParam,
1400
+ }, async ({ page_id, element_id, global_typography, font_family, font_size, font_size_tablet, font_size_mobile, font_weight, text_transform, text_decoration, font_style, line_height, letter_spacing, color, align, align_mobile, site }) => {
1401
+ try {
1402
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1403
+ const s = {};
1404
+ // Typography is composite — Elementor uses typography_typography="custom" to enable per-element settings
1405
+ const needsCustomTypography = font_family || font_size || font_weight || text_transform || text_decoration || font_style || line_height || letter_spacing;
1406
+ if (needsCustomTypography)
1407
+ s.typography_typography = "custom";
1408
+ if (global_typography) {
1409
+ s.__globals__ = { ...(s.__globals__ || {}), typography_typography: `globals/typography?id=${global_typography}` };
1410
+ }
1411
+ if (font_family)
1412
+ s.typography_font_family = font_family;
1413
+ if (font_weight)
1414
+ s.typography_font_weight = font_weight;
1415
+ if (text_transform)
1416
+ s.typography_text_transform = text_transform;
1417
+ if (text_decoration)
1418
+ s.typography_text_decoration = text_decoration;
1419
+ if (font_style)
1420
+ s.typography_font_style = font_style;
1421
+ const fs1 = parseSize(font_size);
1422
+ if (fs1)
1423
+ s.typography_font_size = fs1;
1424
+ const fs2 = parseSize(font_size_tablet);
1425
+ if (fs2)
1426
+ s.typography_font_size_tablet = fs2;
1427
+ const fs3 = parseSize(font_size_mobile);
1428
+ if (fs3)
1429
+ s.typography_font_size_mobile = fs3;
1430
+ if (line_height) {
1431
+ // Unitless (e.g. "1.5") → use 'em'-like ratio; with unit → use that unit
1432
+ const lh = parseSize(line_height);
1433
+ if (lh)
1434
+ s.typography_line_height = lh;
1435
+ else if (/^\d+(\.\d+)?$/.test(String(line_height).trim())) {
1436
+ s.typography_line_height = { unit: "em", size: parseFloat(String(line_height)) };
1437
+ }
1438
+ }
1439
+ const ls = parseSize(letter_spacing);
1440
+ if (ls)
1441
+ s.typography_letter_spacing = ls;
1442
+ if (color) {
1443
+ const c = resolveColor(color);
1444
+ // Common color keys across widgets: title_color (heading), color (text-editor), button_text_color (button)
1445
+ // We set ALL three so it works regardless of widget type — Elementor ignores keys that don't apply
1446
+ if (c.value) {
1447
+ s.title_color = c.value;
1448
+ s.color = c.value;
1449
+ s.button_text_color = c.value;
1450
+ }
1451
+ if (c.global_id) {
1452
+ s.__globals__ = {
1453
+ ...(s.__globals__ || {}),
1454
+ title_color: c.global_id,
1455
+ color: c.global_id,
1456
+ button_text_color: c.global_id,
1457
+ };
1458
+ }
1459
+ }
1460
+ if (align)
1461
+ s.align = align;
1462
+ if (align_mobile)
1463
+ s.align_mobile = align_mobile;
1464
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
1465
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
1466
+ }
1467
+ catch (error) {
1468
+ return { content: [{ type: "text", text: `Error setting typography: ${error.response?.data?.message || error.message}` }], isError: true };
1469
+ }
1470
+ });
1471
+ // ── 21. set-text-content ──────────────────────────────────────────────────
1472
+ server.tool("set-text-content", "Write text content directly to a heading, text-editor, or button widget. Auto-detects the right setting key (title for heading, editor for text-editor, text for button). Use this instead of merge-element-settings for content updates — clearer intent and less prone to wrong-key errors.", {
1473
+ page_id: z.string().describe("WordPress Page ID"),
1474
+ element_id: z.string().describe("Elementor widget ID"),
1475
+ text: z.string().describe("New text content. Can include basic HTML for text-editor widget."),
1476
+ widget_type: z.enum(["auto", "heading", "text-editor", "button", "icon-box"]).optional().describe("Default 'auto' — picks the right key by trying common ones. Specify for clarity if you know the widget type."),
1477
+ site: siteParam,
1478
+ }, async ({ page_id, element_id, text, widget_type, site }) => {
1479
+ try {
1480
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1481
+ const s = {};
1482
+ const t = widget_type ?? "auto";
1483
+ if (t === "heading")
1484
+ s.title = text;
1485
+ else if (t === "text-editor")
1486
+ s.editor = text;
1487
+ else if (t === "button")
1488
+ s.text = text;
1489
+ else if (t === "icon-box") {
1490
+ s.title_text = text;
1491
+ }
1492
+ else {
1493
+ // auto — set all common keys; Elementor ignores those that don't apply to the widget
1494
+ s.title = text;
1495
+ s.editor = text;
1496
+ s.text = text;
1497
+ s.title_text = text;
1498
+ }
1499
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
1500
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
1501
+ }
1502
+ catch (error) {
1503
+ return { content: [{ type: "text", text: `Error setting text content: ${error.response?.data?.message || error.message}` }], isError: true };
1504
+ }
1505
+ });
1506
+ // ── 22. set-image-widget-src ──────────────────────────────────────────────
1507
+ server.tool("set-image-widget-src", "Set the source URL of an Elementor image widget. Pair with upload-image-to-media-library: 1) upload the Figma export, 2) pass the returned URL here. Sets the image, optionally with attachment_id (recommended for WP media library images so srcset/responsive sizes work). Also supports alt text and caption.", {
1508
+ page_id: z.string().describe("WordPress Page ID"),
1509
+ element_id: z.string().describe("Elementor image widget ID"),
1510
+ url: z.string().describe("Image URL (use the URL returned from upload-image-to-media-library)"),
1511
+ attachment_id: z.number().optional().describe("WordPress attachment ID (from upload-image-to-media-library response). Enables responsive srcset."),
1512
+ alt_text: z.string().optional().describe("Alt text for accessibility (also sets caption on some widgets)"),
1513
+ site: siteParam,
1514
+ }, async ({ page_id, element_id, url, attachment_id, alt_text, site }) => {
1515
+ try {
1516
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1517
+ const s = {
1518
+ image: { url, id: attachment_id ?? "" },
1519
+ };
1520
+ if (alt_text) {
1521
+ s.image_alt = alt_text;
1522
+ s.caption = alt_text; // some widgets use 'caption' key
1523
+ }
1524
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
1525
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
1526
+ }
1527
+ catch (error) {
1528
+ return { content: [{ type: "text", text: `Error setting image src: ${error.response?.data?.message || error.message}` }], isError: true };
1529
+ }
1530
+ });
1531
+ // ── 23. set-element-opacity ───────────────────────────────────────────────
1532
+ server.tool("set-element-opacity", "Set the opacity of any Elementor element. Figma nodes with opacity < 1 translate directly to this. Accepts 0–1 (Figma format) or 0–100 (CSS percentage) — auto-detects.", {
1533
+ page_id: z.string().describe("WordPress Page ID"),
1534
+ element_id: z.string().describe("Elementor element ID"),
1535
+ opacity: z.number().describe("Opacity value — 0–1 (Figma style, e.g. 0.5) or 0–100 (percent, e.g. 50). Auto-detected."),
1536
+ site: siteParam,
1537
+ }, async ({ page_id, element_id, opacity, site }) => {
1538
+ try {
1539
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1540
+ // Normalize to 0–1 range
1541
+ const normalized = opacity > 1 ? opacity / 100 : opacity;
1542
+ const s = {
1543
+ _element_opacity: { unit: "px", size: normalized },
1544
+ opacity: { unit: "px", size: normalized }, // alt key some widgets use
1545
+ };
1546
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
1547
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
1548
+ }
1549
+ catch (error) {
1550
+ return { content: [{ type: "text", text: `Error setting opacity: ${error.response?.data?.message || error.message}` }], isError: true };
1551
+ }
1552
+ });
1553
+ // ── 24. set-element-rotation ──────────────────────────────────────────────
1554
+ server.tool("set-element-rotation", "Rotate any Elementor element by N degrees. Figma's node.rotation translates directly. Positive = clockwise. Maps to Elementor's _element_rotation setting.", {
1555
+ page_id: z.string().describe("WordPress Page ID"),
1556
+ element_id: z.string().describe("Elementor element ID"),
1557
+ rotation: z.number().describe("Rotation in degrees (e.g. 45, -90, 180). 0 = no rotation."),
1558
+ site: siteParam,
1559
+ }, async ({ page_id, element_id, rotation, site }) => {
1560
+ try {
1561
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1562
+ const s = {
1563
+ _element_rotate: { unit: "deg", size: rotation },
1564
+ };
1565
+ const result = await applyElementSettings(wpUrl, authHeader, page_id, element_id, s);
1566
+ return { content: [{ type: "text", text: JSON.stringify({ applied: s, result }, null, 2) }] };
1567
+ }
1568
+ catch (error) {
1569
+ return { content: [{ type: "text", text: `Error setting rotation: ${error.response?.data?.message || error.message}` }], isError: true };
1570
+ }
1571
+ });
380
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.", {
381
1573
  page_id: z.string().describe("WordPress Page ID"),
382
1574
  elements_json: z.string().describe("Full elements array as stringified JSON"),
@@ -603,13 +1795,17 @@ function createMcpServer(sites) {
603
1795
  return { content: [{ type: "text", text: `Error fetching template: ${error.response?.data?.message || error.message}` }], isError: true };
604
1796
  }
605
1797
  });
606
- server.tool("create-template", "Create a new Elementor template of any type (page, section, popup, header, footer).", {
1798
+ server.tool("create-template", "Create a new Elementor template of any type (page, section, popup, header, footer, archive, single). For theme-builder templates (header/footer/popup), pass display_condition to auto-register where it shows — 'entire_site' is the most common. For popups, pass popup_settings to configure trigger/width/height/etc. Returns the template ID and edit URL.", {
607
1799
  title: z.string().describe("Template title"),
608
- type: z.string().describe("Template type: page, section, popup, header, footer"),
1800
+ type: z.string().describe("Template type: page, section, popup, header, footer, archive, single, single-page, single-post"),
609
1801
  status: z.string().optional().describe("Post status: publish (default) or draft"),
610
1802
  elements: z.string().optional().describe("Initial elements JSON (stringified array)"),
1803
+ display_condition: z.enum(["entire_site", "specific_post_type", "specific_page", "none"]).optional().describe("For header/footer/popup/archive/single: where to display. 'entire_site' = show on all pages. 'specific_post_type' = pair with display_post_type. 'specific_page' = pair with display_page_id."),
1804
+ display_post_type: z.string().optional().describe("Used when display_condition='specific_post_type' — e.g. 'page', 'post', 'astra-portfolio'"),
1805
+ display_page_id: z.string().optional().describe("Used when display_condition='specific_page' — numeric WP page ID"),
1806
+ popup_settings: z.string().optional().describe("For type='popup': JSON object with popup config (stringified). Common keys: triggers, conditions, advanced_rules, width, height, position. E.g. '{\"triggers\":{\"page_load\":\"yes\",\"page_load_delay\":3}}'"),
611
1807
  site: siteParam,
612
- }, async ({ title, type, status, elements, site }) => {
1808
+ }, async ({ title, type, status, elements, display_condition, display_post_type, display_page_id, popup_settings, site }) => {
613
1809
  try {
614
1810
  const { wpUrl, authHeader } = resolveSite(sites, site);
615
1811
  const body = { title, type, status: status || "publish" };
@@ -619,6 +1815,20 @@ function createMcpServer(sites) {
619
1815
  }
620
1816
  catch { }
621
1817
  }
1818
+ if (display_condition && display_condition !== "none")
1819
+ body.display_condition = display_condition;
1820
+ if (display_post_type)
1821
+ body.display_post_type = display_post_type;
1822
+ if (display_page_id)
1823
+ body.display_page_id = display_page_id;
1824
+ if (popup_settings) {
1825
+ try {
1826
+ body.popup_settings = JSON.parse(popup_settings);
1827
+ }
1828
+ catch {
1829
+ return { content: [{ type: "text", text: "Invalid JSON in popup_settings." }], isError: true };
1830
+ }
1831
+ }
622
1832
  const r = await axios.post(`${wpUrl}/wp-json/erc/v1/templates`, body, { headers: { Authorization: authHeader } });
623
1833
  return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
624
1834
  }
@@ -1283,18 +2493,26 @@ function createMcpServer(sites) {
1283
2493
  }
1284
2494
  });
1285
2495
  // ── Group 9: Screenshot ──────────────────────────────────────────────────────
1286
- server.tool("screenshot-page", "Take a screenshot of a published WordPress page using system Chrome. Works with any post type (page, post, custom post types like astra-portfolio). Pass either page_id OR a direct url. Saves the file to disk and returns both a preview image and the saved file path. Pass the file_path to Basecamp MCP's upload_attachment tool to attach it.", {
2496
+ server.tool("screenshot-page", "Take a screenshot of a published WordPress page using system Chrome. Works with any post type (page, post, custom post types like astra-portfolio). Pass either page_id OR a direct url. Use viewport='tablet' or 'mobile' to verify responsive behavior — every responsive build should be screenshotted at all 3 breakpoints. Saves the file to disk and returns both a preview image and the saved file path.", {
1287
2497
  page_id: z.string().optional().describe("WordPress post/page ID (works for any post type). Either page_id OR url must be provided."),
1288
2498
  url: z.string().optional().describe("Direct URL to screenshot (e.g. https://site.com/services/xyz/). Use this for custom post types or external URLs. Takes precedence over page_id."),
2499
+ viewport: z.enum(["desktop", "tablet", "mobile"]).optional().describe("Preset viewport: desktop=1440px, tablet=768px, mobile=375px. Overrides width if both are passed. Critical for verifying responsive output at each breakpoint."),
1289
2500
  full_page: z.boolean().optional().describe("Capture full scrollable page (default: true)"),
1290
- width: z.number().optional().describe("Viewport width in pixels (default: 1440)"),
2501
+ width: z.number().optional().describe("Viewport width in pixels (default: 1440). Ignored if viewport preset is passed."),
1291
2502
  format: z.enum(["jpeg", "png"]).optional().describe("Image format — jpeg is much smaller and recommended (default: jpeg)"),
1292
2503
  quality: z.number().min(1).max(100).optional().describe("JPEG quality 1–100 (default: 82). Lower = smaller file size."),
1293
2504
  max_height: z.number().optional().describe("Cap the captured height in pixels (e.g. 4000). Useful for very long pages to stay under size limits."),
1294
2505
  wait: z.number().optional().describe("Extra milliseconds to wait after page load + auto-scroll before capturing (default: 2000). Increase for heavy animations/videos."),
1295
2506
  auto_scroll: z.boolean().optional().describe("Scroll through the page to trigger lazy-loaded images and content (default: true). Disable only for pages with infinite scroll."),
1296
2507
  site: siteParam,
1297
- }, async ({ page_id, url, full_page, width, format, quality, max_height, wait, auto_scroll, site }) => {
2508
+ }, async ({ page_id, url, viewport, full_page, width, format, quality, max_height, wait, auto_scroll, site }) => {
2509
+ // viewport preset overrides explicit width
2510
+ if (viewport === "desktop")
2511
+ width = 1440;
2512
+ else if (viewport === "tablet")
2513
+ width = 768;
2514
+ else if (viewport === "mobile")
2515
+ width = 375;
1298
2516
  try {
1299
2517
  if (!page_id && !url) {
1300
2518
  throw new Error("Provide either page_id or url.");