@yashwant.dharmdas/elementor-mcp 3.11.1 → 3.13.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 +975 -17
  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,790 @@ 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
+ });
380
1273
  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
1274
  page_id: z.string().describe("WordPress Page ID"),
382
1275
  elements_json: z.string().describe("Full elements array as stringified JSON"),
@@ -422,9 +1315,9 @@ function createMcpServer(sites) {
422
1315
  }
423
1316
  });
424
1317
  // ── Group 2: Page & Site Config ───────────────────────────────────────────
425
- server.tool("update-page-settings", "Update Elementor page-level settings (e.g. hide title, custom CSS, page layout). Merges with existing settings.", {
1318
+ server.tool("update-page-settings", "Update Elementor page-level settings — merges with existing settings on _elementor_page_settings post meta. Common keys: hide_title ('yes' or ''), template ('elementor_canvas', 'elementor_header_footer', 'default'), custom_css. For the common 'Hide Title' toggle prefer the dedicated hide-page-title tool which handles one or many pages with a clean boolean.", {
426
1319
  page_id: z.string().describe("WordPress Page ID"),
427
- settings: z.string().describe("Settings object to merge as stringified JSON"),
1320
+ settings: z.string().describe("Settings object to merge as stringified JSON, e.g. '{\"hide_title\":\"yes\"}'"),
428
1321
  site: siteParam,
429
1322
  }, async ({ page_id, settings, site }) => {
430
1323
  let parsed;
@@ -443,6 +1336,45 @@ function createMcpServer(sites) {
443
1336
  return { content: [{ type: "text", text: `Error updating page settings: ${error.response?.data?.message || error.message}` }], isError: true };
444
1337
  }
445
1338
  });
1339
+ server.tool("hide-page-title", "Toggle Elementor's 'Hide Title' setting on one OR MANY pages in a single call. Equivalent to opening each page in Elementor → Page Settings → flipping the 'Hide Title' switch. Writes hide_title='yes' (or '' to unhide) to _elementor_page_settings post meta. Use this right after create-page-from-file to hide the WordPress title that appears above your Elementor hero section. Clears the Elementor cache so the change is visible immediately.", {
1340
+ page_ids: z.array(z.string()).min(1).describe("One or more WordPress page IDs. Pass multiple to batch — e.g. all 5 service pages at once."),
1341
+ hide: z.boolean().optional().describe("true = hide the title (default), false = show the title."),
1342
+ site: siteParam,
1343
+ }, async ({ page_ids, hide, site }) => {
1344
+ try {
1345
+ const { wpUrl, authHeader } = resolveSite(sites, site);
1346
+ const hideValue = (hide ?? true) ? "yes" : "";
1347
+ const results = [];
1348
+ for (const page_id of page_ids) {
1349
+ try {
1350
+ const r = await axios.patch(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/settings`, { hide_title: hideValue }, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
1351
+ results.push({ page_id, success: true, hide_title: hideValue, result: r.data });
1352
+ }
1353
+ catch (err) {
1354
+ results.push({ page_id, success: false, error: err.response?.data?.message || err.message });
1355
+ }
1356
+ }
1357
+ // Clear cache once at the end so all pages refresh
1358
+ await axios.post(`${wpUrl}/wp-json/erc/v1/site/clear-cache`, {}, { headers: { Authorization: authHeader } }).catch(() => { });
1359
+ const failed = results.filter(r => !r.success);
1360
+ return {
1361
+ content: [{
1362
+ type: "text",
1363
+ text: JSON.stringify({
1364
+ total: page_ids.length,
1365
+ succeeded: results.length - failed.length,
1366
+ failed: failed.length,
1367
+ hide: hide ?? true,
1368
+ results,
1369
+ }, null, 2),
1370
+ }],
1371
+ isError: failed.length === page_ids.length,
1372
+ };
1373
+ }
1374
+ catch (error) {
1375
+ return { content: [{ type: "text", text: `Error toggling hide-title: ${error.response?.data?.message || error.message}` }], isError: true };
1376
+ }
1377
+ });
446
1378
  server.tool("get-theme-context", "Get a summary of the active WordPress theme, Elementor version, active kit ID, Pro status, and viewport breakpoints.", { site: siteParam }, async ({ site }) => {
447
1379
  try {
448
1380
  const { wpUrl, authHeader } = resolveSite(sites, site);
@@ -564,13 +1496,17 @@ function createMcpServer(sites) {
564
1496
  return { content: [{ type: "text", text: `Error fetching template: ${error.response?.data?.message || error.message}` }], isError: true };
565
1497
  }
566
1498
  });
567
- server.tool("create-template", "Create a new Elementor template of any type (page, section, popup, header, footer).", {
1499
+ 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.", {
568
1500
  title: z.string().describe("Template title"),
569
- type: z.string().describe("Template type: page, section, popup, header, footer"),
1501
+ type: z.string().describe("Template type: page, section, popup, header, footer, archive, single, single-page, single-post"),
570
1502
  status: z.string().optional().describe("Post status: publish (default) or draft"),
571
1503
  elements: z.string().optional().describe("Initial elements JSON (stringified array)"),
1504
+ 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."),
1505
+ display_post_type: z.string().optional().describe("Used when display_condition='specific_post_type' — e.g. 'page', 'post', 'astra-portfolio'"),
1506
+ display_page_id: z.string().optional().describe("Used when display_condition='specific_page' — numeric WP page ID"),
1507
+ 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}}'"),
572
1508
  site: siteParam,
573
- }, async ({ title, type, status, elements, site }) => {
1509
+ }, async ({ title, type, status, elements, display_condition, display_post_type, display_page_id, popup_settings, site }) => {
574
1510
  try {
575
1511
  const { wpUrl, authHeader } = resolveSite(sites, site);
576
1512
  const body = { title, type, status: status || "publish" };
@@ -580,6 +1516,20 @@ function createMcpServer(sites) {
580
1516
  }
581
1517
  catch { }
582
1518
  }
1519
+ if (display_condition && display_condition !== "none")
1520
+ body.display_condition = display_condition;
1521
+ if (display_post_type)
1522
+ body.display_post_type = display_post_type;
1523
+ if (display_page_id)
1524
+ body.display_page_id = display_page_id;
1525
+ if (popup_settings) {
1526
+ try {
1527
+ body.popup_settings = JSON.parse(popup_settings);
1528
+ }
1529
+ catch {
1530
+ return { content: [{ type: "text", text: "Invalid JSON in popup_settings." }], isError: true };
1531
+ }
1532
+ }
583
1533
  const r = await axios.post(`${wpUrl}/wp-json/erc/v1/templates`, body, { headers: { Authorization: authHeader } });
584
1534
  return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
585
1535
  }
@@ -1244,18 +2194,26 @@ function createMcpServer(sites) {
1244
2194
  }
1245
2195
  });
1246
2196
  // ── Group 9: Screenshot ──────────────────────────────────────────────────────
1247
- 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.", {
2197
+ 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.", {
1248
2198
  page_id: z.string().optional().describe("WordPress post/page ID (works for any post type). Either page_id OR url must be provided."),
1249
2199
  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."),
2200
+ 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."),
1250
2201
  full_page: z.boolean().optional().describe("Capture full scrollable page (default: true)"),
1251
- width: z.number().optional().describe("Viewport width in pixels (default: 1440)"),
2202
+ width: z.number().optional().describe("Viewport width in pixels (default: 1440). Ignored if viewport preset is passed."),
1252
2203
  format: z.enum(["jpeg", "png"]).optional().describe("Image format — jpeg is much smaller and recommended (default: jpeg)"),
1253
2204
  quality: z.number().min(1).max(100).optional().describe("JPEG quality 1–100 (default: 82). Lower = smaller file size."),
1254
2205
  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."),
1255
2206
  wait: z.number().optional().describe("Extra milliseconds to wait after page load + auto-scroll before capturing (default: 2000). Increase for heavy animations/videos."),
1256
2207
  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."),
1257
2208
  site: siteParam,
1258
- }, async ({ page_id, url, full_page, width, format, quality, max_height, wait, auto_scroll, site }) => {
2209
+ }, async ({ page_id, url, viewport, full_page, width, format, quality, max_height, wait, auto_scroll, site }) => {
2210
+ // viewport preset overrides explicit width
2211
+ if (viewport === "desktop")
2212
+ width = 1440;
2213
+ else if (viewport === "tablet")
2214
+ width = 768;
2215
+ else if (viewport === "mobile")
2216
+ width = 375;
1259
2217
  try {
1260
2218
  if (!page_id && !url) {
1261
2219
  throw new Error("Provide either page_id or url.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yashwant.dharmdas/elementor-mcp",
3
- "version": "3.11.1",
3
+ "version": "3.13.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",