@yashwant.dharmdas/elementor-mcp 3.0.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 (3) hide show
  1. package/README.md +57 -0
  2. package/dist/index.js +958 -0
  3. package/package.json +40 -0
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @yashwant.dharmdas/elementor-mcp
2
+
3
+ MCP server for controlling Elementor via Claude. Supports multiple WordPress sites.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ npx @yashwant.dharmdas/elementor-mcp setup
9
+ ```
10
+
11
+ Follow the prompts to enter your WordPress URL, username, and Application Password.
12
+ Create an Application Password at: **WordPress Admin → Users → Your Profile → Application Passwords**
13
+
14
+ ## Claude Desktop Config
15
+
16
+ After setup, add this to your `claude_desktop_config.json` under `mcpServers`:
17
+
18
+ ```json
19
+ {
20
+ "elementor": {
21
+ "command": "npx",
22
+ "args": ["-y", "@yashwant.dharmdas/elementor-mcp"]
23
+ }
24
+ }
25
+ ```
26
+
27
+ Config file location:
28
+ - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
29
+ - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
30
+
31
+ ## Multiple Sites
32
+
33
+ ```bash
34
+ # Add more sites anytime — just run setup again with a different name
35
+ npx @yashwant.dharmdas/elementor-mcp setup
36
+ ```
37
+
38
+ Then specify the `site` parameter in Claude:
39
+ > *"List pages on acme-client"*
40
+ > *"Update the homepage on beta-corp"*
41
+
42
+ ## Tools
43
+
44
+ 35 tools across 7 categories:
45
+
46
+ - **Core:** `list-pages`, `get-data`, `find-elements`, `update-data`, `patch-data`, `merge-element-settings`
47
+ - **Page/Site Config:** `update-page-settings`, `get-theme-context`, `get-kit-settings`, `update-kit-settings`
48
+ - **Site Operations:** `clear-cache`, `replace-urls`, `get-style-guide`
49
+ - **Templates:** `list-templates`, `get-template`, `create-template`, `update-template`, `delete-template`, `duplicate-template`
50
+ - **Design Intelligence:** `evaluate-design`, `suggest-design-fixes`, `get-official-pattern-guidance`
51
+ - **Layout:** `image-widget-to-background-container`, `enforce-boundary-coherence`, `extract-design-tokens`, `apply-text-hierarchy`, `normalize-section-spacing-rhythm`, `normalize-responsive-values`, `sync-component-variant`
52
+ - **Pro Features:** `list-custom-code`, `create-custom-code`, `update-custom-code`, `delete-custom-code`, `list-form-submissions`, `delete-form-submission`
53
+
54
+ ## Requirements
55
+
56
+ - Node.js ≥ 18
57
+ - WordPress with the [Elementor Remote Control plugin](https://github.com/yashwant-dharmdas/elementor-mcp) installed
package/dist/index.js ADDED
@@ -0,0 +1,958 @@
1
+ #!/usr/bin/env node
2
+ import AjvModule from "ajv";
3
+ const Ajv = AjvModule.default || AjvModule;
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+ import axios from "axios";
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import path from "path";
11
+ import readline from "readline";
12
+ const CONFIG_DIR = path.join(os.homedir(), ".elementor-mcp");
13
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
14
+ function loadConfig() {
15
+ if (fs.existsSync(CONFIG_PATH)) {
16
+ try {
17
+ const raw = fs.readFileSync(CONFIG_PATH, "utf8");
18
+ const parsed = JSON.parse(raw);
19
+ return Array.isArray(parsed) ? parsed : [parsed];
20
+ }
21
+ catch {
22
+ console.error(`Failed to parse config at ${CONFIG_PATH}. Run: npx elementor-mcp setup`);
23
+ process.exit(1);
24
+ }
25
+ }
26
+ // Env var fallback — useful for CI / containerised deployments
27
+ const { WP_URL, WP_USER, WP_APP_PASSWORD } = process.env;
28
+ if (WP_URL && WP_USER && WP_APP_PASSWORD) {
29
+ return [{ name: "default", wpUrl: WP_URL, user: WP_USER, appPassword: WP_APP_PASSWORD }];
30
+ }
31
+ return [];
32
+ }
33
+ function saveConfig(sites) {
34
+ if (!fs.existsSync(CONFIG_DIR))
35
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
36
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(sites, null, 2), { mode: 0o600 });
37
+ }
38
+ function resolveSite(sites, siteName) {
39
+ if (sites.length === 0) {
40
+ throw new Error("No sites configured. Run: npx elementor-mcp setup");
41
+ }
42
+ let site;
43
+ if (!siteName) {
44
+ if (sites.length === 1) {
45
+ site = sites[0];
46
+ }
47
+ else {
48
+ const names = sites.map(s => s.name).join(", ");
49
+ throw new Error(`Multiple sites configured — specify the 'site' parameter. Available: ${names}`);
50
+ }
51
+ }
52
+ else {
53
+ const found = sites.find(s => s.name === siteName);
54
+ if (!found) {
55
+ const names = sites.map(s => s.name).join(", ");
56
+ throw new Error(`Site "${siteName}" not found. Available: ${names}`);
57
+ }
58
+ site = found;
59
+ }
60
+ return {
61
+ wpUrl: site.wpUrl.replace(/\/$/, ""),
62
+ authHeader: `Basic ${Buffer.from(`${site.user}:${site.appPassword}`).toString("base64")}`,
63
+ };
64
+ }
65
+ // ─── Setup Wizard ─────────────────────────────────────────────────────────────
66
+ async function runSetup() {
67
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
68
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
69
+ const existing = loadConfig();
70
+ const isAdding = existing.length > 0;
71
+ console.log(isAdding ? "\n─── Add / Update a WordPress Site ───" : "\n─── Elementor MCP Setup ───");
72
+ console.log("You need a WordPress Application Password.");
73
+ console.log("Create one at: WordPress Admin → Users → Your Profile → Application Passwords\n");
74
+ try {
75
+ const name = (await ask("Site name (e.g. acme-client): ")).trim();
76
+ const wpUrl = (await ask("WordPress URL (e.g. https://acme.com): ")).trim();
77
+ const user = (await ask("WordPress username: ")).trim();
78
+ const appPassword = (await ask("Application Password (xxxx xxxx xxxx xxxx xxxx xxxx): ")).trim();
79
+ if (!name || !wpUrl || !user || !appPassword) {
80
+ console.error("\nAll fields are required.");
81
+ process.exit(1);
82
+ }
83
+ // Validate credentials against WordPress
84
+ process.stdout.write("\nValidating credentials...");
85
+ const authHeader = `Basic ${Buffer.from(`${user}:${appPassword}`).toString("base64")}`;
86
+ try {
87
+ await axios.get(`${wpUrl.replace(/\/$/, "")}/wp-json/wp/v2/users/me`, {
88
+ headers: { Authorization: authHeader },
89
+ });
90
+ console.log(" ✓\n");
91
+ }
92
+ catch (err) {
93
+ const msg = err.response?.data?.message || err.message;
94
+ console.error(`\n\nCredential validation failed: ${msg}`);
95
+ console.error("Check that the Application Password is correct and the WP REST API is reachable.");
96
+ process.exit(1);
97
+ }
98
+ // Upsert: replace entry with same name, keep others
99
+ const updated = [...existing.filter(s => s.name !== name), { name, wpUrl: wpUrl.replace(/\/$/, ""), user, appPassword }];
100
+ saveConfig(updated);
101
+ console.log(`Site "${name}" saved to ${CONFIG_PATH}\n`);
102
+ if (updated.length === 1) {
103
+ // First-time setup: show full Claude Desktop snippet
104
+ console.log("─── Claude Desktop Config ───────────────────────────────────────────────────");
105
+ console.log("Add this block to your claude_desktop_config.json under \"mcpServers\":\n");
106
+ console.log(JSON.stringify({ elementor: { command: "npx", args: ["-y", "elementor-mcp"] } }, null, 2));
107
+ console.log("\nConfig file locations:");
108
+ console.log(" macOS: ~/Library/Application Support/Claude/claude_desktop_config.json");
109
+ console.log(" Windows: %APPDATA%\\Claude\\claude_desktop_config.json");
110
+ console.log("─────────────────────────────────────────────────────────────────────────────\n");
111
+ }
112
+ else {
113
+ console.log(`You now have ${updated.length} sites: ${updated.map(s => s.name).join(", ")}`);
114
+ console.log("Pass the 'site' parameter in tool calls to target a specific site.\n");
115
+ }
116
+ }
117
+ finally {
118
+ rl.close();
119
+ }
120
+ }
121
+ // ─── Ajv schema validation ────────────────────────────────────────────────────
122
+ const ajv = new Ajv();
123
+ const elementSchema = {
124
+ type: "object",
125
+ properties: {
126
+ id: { type: "string" },
127
+ elType: { enum: ["container", "widget"] },
128
+ settings: { type: "object" },
129
+ elements: { type: "array" },
130
+ },
131
+ required: ["id", "elType"],
132
+ };
133
+ ajv.compile(elementSchema); // pre-compile; used by WP plugin validation
134
+ // ─── MCP Server Factory ───────────────────────────────────────────────────────
135
+ function createMcpServer(sites) {
136
+ const server = new McpServer({
137
+ name: "elementor-remote-control",
138
+ version: "3.0.0",
139
+ });
140
+ const siteDesc = sites.length > 1
141
+ ? `Target site name. Available: ${sites.map(s => s.name).join(", ")}`
142
+ : `Target site (optional — defaults to "${sites[0]?.name ?? "none"}")`;
143
+ const siteParam = z.string().optional().describe(siteDesc);
144
+ // ── Shared helpers ────────────────────────────────────────────────────────
145
+ const flattenElements = (els, out = []) => {
146
+ for (const el of els) {
147
+ out.push(el);
148
+ if (el.elements?.length)
149
+ flattenElements(el.elements, out);
150
+ }
151
+ return out;
152
+ };
153
+ const topLevel = (els) => els.filter((e) => e.elType === "container" || e.elType === "section");
154
+ const traverseAndMutate = (els, mutate) => els.map((el) => {
155
+ mutate(el);
156
+ if (el.elements?.length)
157
+ el.elements = traverseAndMutate(el.elements, mutate);
158
+ return el;
159
+ });
160
+ const getAndSave = async (wpUrl, authHeader, page_id, transform) => {
161
+ const getRes = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/data`, { headers: { Authorization: authHeader } });
162
+ const transformed = transform(getRes.data);
163
+ const putRes = await axios.put(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/data`, transformed, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
164
+ return putRes.data;
165
+ };
166
+ // ── Meta: list-sites ──────────────────────────────────────────────────────
167
+ server.tool("list-sites", "List all configured WordPress sites available to this MCP server.", {}, async () => {
168
+ if (sites.length === 0) {
169
+ return { content: [{ type: "text", text: "No sites configured. Run: npx elementor-mcp setup" }] };
170
+ }
171
+ const list = sites.map(s => ({ name: s.name, wpUrl: s.wpUrl }));
172
+ return { content: [{ type: "text", text: JSON.stringify(list, null, 2) }] };
173
+ });
174
+ // ── Group 1: Core Read / Write ────────────────────────────────────────────
175
+ server.tool("list-pages", "List all WordPress pages editable with Elementor.", { site: siteParam }, async ({ site }) => {
176
+ try {
177
+ const { wpUrl, authHeader } = resolveSite(sites, site);
178
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/pages`, { headers: { Authorization: authHeader } });
179
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
180
+ }
181
+ catch (error) {
182
+ return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
183
+ }
184
+ });
185
+ server.tool("get-data", "Get the full raw Elementor JSON element tree for a page (all elements with all settings). Use this before find-elements, patch-data, or any bulk transform.", {
186
+ page_id: z.string().describe("WordPress Page ID"),
187
+ site: siteParam,
188
+ }, async ({ page_id, site }) => {
189
+ try {
190
+ const { wpUrl, authHeader } = resolveSite(sites, site);
191
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/data`, { headers: { Authorization: authHeader } });
192
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
193
+ }
194
+ catch (error) {
195
+ return { content: [{ type: "text", text: `Error fetching page data: ${error.response?.data?.message || error.message}` }], isError: true };
196
+ }
197
+ });
198
+ server.tool("find-elements", "Search the Elementor element tree of a page and return matching elements. Filter by element type, widget type, or text content.", {
199
+ page_id: z.string().describe("WordPress Page ID"),
200
+ el_type: z.string().optional().describe("Filter by elType: 'container' or 'widget'"),
201
+ widget_type: z.string().optional().describe("Filter by widgetType, e.g. 'heading', 'image', 'button'"),
202
+ contains: z.string().optional().describe("Filter by text content found anywhere in the element's settings"),
203
+ include_path: z.boolean().optional().describe("Include ancestor IDs in results"),
204
+ site: siteParam,
205
+ }, async ({ page_id, el_type, widget_type, contains, include_path, site }) => {
206
+ try {
207
+ const { wpUrl, authHeader } = resolveSite(sites, site);
208
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/data`, { headers: { Authorization: authHeader } });
209
+ const results = [];
210
+ function traverse(els, pathArr) {
211
+ for (const el of els) {
212
+ const currentPath = [...pathArr, el.id];
213
+ let match = true;
214
+ if (el_type && el.elType !== el_type)
215
+ match = false;
216
+ if (widget_type && el.widgetType !== widget_type)
217
+ match = false;
218
+ if (contains && !JSON.stringify(el.settings || {}).includes(contains))
219
+ match = false;
220
+ if (match)
221
+ results.push(include_path ? { ...el, _path: currentPath } : el);
222
+ if (el.elements?.length)
223
+ traverse(el.elements, currentPath);
224
+ }
225
+ }
226
+ traverse(r.data, []);
227
+ return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
228
+ }
229
+ catch (error) {
230
+ return { content: [{ type: "text", text: `Error: ${error.response?.data?.message || error.message}` }], isError: true };
231
+ }
232
+ });
233
+ server.tool("merge-element-settings", "Deep-merge settings into a specific Elementor element without replacing the full element. Use for small setting adjustments.", {
234
+ page_id: z.string().describe("WordPress Page ID"),
235
+ element_id: z.string().describe("Elementor Element ID"),
236
+ settings: z.string().describe("JSON object of settings to merge (stringified)"),
237
+ site: siteParam,
238
+ }, async ({ page_id, element_id, settings, site }) => {
239
+ let parsedSettings;
240
+ try {
241
+ parsedSettings = JSON.parse(settings);
242
+ }
243
+ catch {
244
+ return { content: [{ type: "text", text: "Invalid JSON in settings parameter." }], isError: true };
245
+ }
246
+ try {
247
+ const { wpUrl, authHeader } = resolveSite(sites, site);
248
+ const getRes = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}`, { headers: { Authorization: authHeader } });
249
+ const merged = { ...getRes.data, settings: { ...(getRes.data.settings || {}), ...parsedSettings } };
250
+ const postRes = await axios.post(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${element_id}`, merged, { headers: { Authorization: authHeader } });
251
+ return { content: [{ type: "text", text: JSON.stringify(postRes.data, null, 2) }] };
252
+ }
253
+ catch (error) {
254
+ return { content: [{ type: "text", text: `Error merging settings: ${error.response?.data?.message || error.message}` }], isError: true };
255
+ }
256
+ });
257
+ 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.", {
258
+ page_id: z.string().describe("WordPress Page ID"),
259
+ elements_json: z.string().describe("Full elements array as stringified JSON"),
260
+ force_replace: z.boolean().optional().describe("Set true to allow replacing with dramatically fewer top-level elements"),
261
+ site: siteParam,
262
+ }, async ({ page_id, elements_json, force_replace, site }) => {
263
+ let parsed;
264
+ try {
265
+ parsed = JSON.parse(elements_json);
266
+ }
267
+ catch {
268
+ return { content: [{ type: "text", text: "Invalid JSON in elements_json." }], isError: true };
269
+ }
270
+ try {
271
+ const { wpUrl, authHeader } = resolveSite(sites, site);
272
+ const r = await axios.put(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/data`, parsed, {
273
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
274
+ params: { force_replace: force_replace ? "true" : "false" },
275
+ });
276
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
277
+ }
278
+ catch (error) {
279
+ return { content: [{ type: "text", text: `Error updating page data: ${error.response?.data?.message || error.message}` }], isError: true };
280
+ }
281
+ });
282
+ server.tool("patch-data", "Find and replace a text string within the Elementor JSON of a page. Useful for bulk URL or text changes on a single page.", {
283
+ page_id: z.string().describe("WordPress Page ID"),
284
+ find: z.string().describe("Text to find (exact string match within JSON)"),
285
+ replace: z.string().describe("Text to replace it with"),
286
+ site: siteParam,
287
+ }, async ({ page_id, find, replace, site }) => {
288
+ try {
289
+ const { wpUrl, authHeader } = resolveSite(sites, site);
290
+ const getRes = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/data`, { headers: { Authorization: authHeader } });
291
+ const raw = JSON.stringify(getRes.data);
292
+ const patched = raw.split(find).join(replace);
293
+ const putRes = await axios.put(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/data`, JSON.parse(patched), { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
294
+ const count = raw.split(find).length - 1;
295
+ return { content: [{ type: "text", text: `Replaced ${count} occurrence(s). ${JSON.stringify(putRes.data)}` }] };
296
+ }
297
+ catch (error) {
298
+ return { content: [{ type: "text", text: `Error patching data: ${error.response?.data?.message || error.message}` }], isError: true };
299
+ }
300
+ });
301
+ // ── Group 2: Page & Site Config ───────────────────────────────────────────
302
+ server.tool("update-page-settings", "Update Elementor page-level settings (e.g. hide title, custom CSS, page layout). Merges with existing settings.", {
303
+ page_id: z.string().describe("WordPress Page ID"),
304
+ settings: z.string().describe("Settings object to merge as stringified JSON"),
305
+ site: siteParam,
306
+ }, async ({ page_id, settings, site }) => {
307
+ let parsed;
308
+ try {
309
+ parsed = JSON.parse(settings);
310
+ }
311
+ catch {
312
+ return { content: [{ type: "text", text: "Invalid JSON in settings." }], isError: true };
313
+ }
314
+ try {
315
+ const { wpUrl, authHeader } = resolveSite(sites, site);
316
+ const r = await axios.patch(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/settings`, parsed, { headers: { Authorization: authHeader, "Content-Type": "application/json" } });
317
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
318
+ }
319
+ catch (error) {
320
+ return { content: [{ type: "text", text: `Error updating page settings: ${error.response?.data?.message || error.message}` }], isError: true };
321
+ }
322
+ });
323
+ 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 }) => {
324
+ try {
325
+ const { wpUrl, authHeader } = resolveSite(sites, site);
326
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/site/theme-context`, { headers: { Authorization: authHeader } });
327
+ const ctx = r.data;
328
+ const issues = [];
329
+ if (!ctx.elementor_version)
330
+ issues.push("Elementor version not detected — plugin may not be active.");
331
+ if (!ctx.active_kit_id)
332
+ issues.push("No active Elementor kit — global styles may not be applied.");
333
+ if (!ctx.elementor_pro)
334
+ issues.push("Elementor Pro not detected — Pro widgets and Theme Builder unavailable.");
335
+ if (ctx.theme?.parent)
336
+ issues.push(`Child theme active (${ctx.theme.name} → ${ctx.theme.parent}).`);
337
+ return { content: [{ type: "text", text: JSON.stringify({ ...ctx, _issues: issues }, null, 2) }] };
338
+ }
339
+ catch (error) {
340
+ return { content: [{ type: "text", text: `Error fetching theme context: ${error.response?.data?.message || error.message}` }], isError: true };
341
+ }
342
+ });
343
+ server.tool("get-kit-settings", "Get the full settings of the active Elementor kit (global colors, typography, spacing). Use before update-kit-settings.", { site: siteParam }, async ({ site }) => {
344
+ try {
345
+ const { wpUrl, authHeader } = resolveSite(sites, site);
346
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/site/kit-settings`, { headers: { Authorization: authHeader } });
347
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
348
+ }
349
+ catch (e) {
350
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
351
+ }
352
+ });
353
+ server.tool("update-kit-settings", "Update global colors, typography, or other settings on the active Elementor kit.", {
354
+ settings: z.string().describe("Settings object to merge as stringified JSON"),
355
+ site: siteParam,
356
+ }, async ({ settings, site }) => {
357
+ let parsed;
358
+ try {
359
+ parsed = JSON.parse(settings);
360
+ }
361
+ catch {
362
+ return { content: [{ type: "text", text: "Invalid JSON." }], isError: true };
363
+ }
364
+ try {
365
+ const { wpUrl, authHeader } = resolveSite(sites, site);
366
+ const r = await axios.put(`${wpUrl}/wp-json/erc/v1/site/kit-settings`, parsed, { headers: { Authorization: authHeader } });
367
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
368
+ }
369
+ catch (e) {
370
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
371
+ }
372
+ });
373
+ // ── Group 3: Site Operations ──────────────────────────────────────────────
374
+ server.tool("clear-cache", "Clear the Elementor CSS/file cache. Omit post_id to clear the entire site cache. Always call after programmatic edits.", {
375
+ post_id: z.string().optional().describe("Specific post ID to clear, or omit to clear all"),
376
+ site: siteParam,
377
+ }, async ({ post_id, site }) => {
378
+ try {
379
+ const { wpUrl, authHeader } = resolveSite(sites, site);
380
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/site/clear-cache`, {}, {
381
+ headers: { Authorization: authHeader },
382
+ params: post_id ? { post_id } : {},
383
+ });
384
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
385
+ }
386
+ catch (e) {
387
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
388
+ }
389
+ });
390
+ server.tool("replace-urls", "Find and replace a URL string across ALL Elementor data in the database. Use for domain migrations.", {
391
+ from: z.string().describe("URL to find"),
392
+ to: z.string().describe("URL to replace it with"),
393
+ site: siteParam,
394
+ }, async ({ from, to, site }) => {
395
+ try {
396
+ const { wpUrl, authHeader } = resolveSite(sites, site);
397
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/site/replace-urls`, { from, to }, { headers: { Authorization: authHeader } });
398
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
399
+ }
400
+ catch (e) {
401
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
402
+ }
403
+ });
404
+ server.tool("get-style-guide", "Build a style-guide summary from the active Elementor kit: global colors, typography, and spacing tokens.", { site: siteParam }, async ({ site }) => {
405
+ try {
406
+ const { wpUrl, authHeader } = resolveSite(sites, site);
407
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/site/style-guide`, { headers: { Authorization: authHeader } });
408
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
409
+ }
410
+ catch (error) {
411
+ return { content: [{ type: "text", text: `Error fetching style guide: ${error.response?.data?.message || error.message}` }], isError: true };
412
+ }
413
+ });
414
+ // ── Group 4: Templates ────────────────────────────────────────────────────
415
+ server.tool("list-templates", "List all saved Elementor templates. Optionally filter by type: page, section, popup, header, footer.", {
416
+ type: z.string().optional().describe("Filter by template type (page, section, popup, header, footer)"),
417
+ site: siteParam,
418
+ }, async ({ type, site }) => {
419
+ try {
420
+ const { wpUrl, authHeader } = resolveSite(sites, site);
421
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/templates`, {
422
+ headers: { Authorization: authHeader },
423
+ params: type ? { type } : {},
424
+ });
425
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
426
+ }
427
+ catch (error) {
428
+ return { content: [{ type: "text", text: `Error listing templates: ${error.response?.data?.message || error.message}` }], isError: true };
429
+ }
430
+ });
431
+ server.tool("get-template", "Get a single Elementor template with its full element data.", {
432
+ template_id: z.string().describe("Template ID"),
433
+ site: siteParam,
434
+ }, async ({ template_id, site }) => {
435
+ try {
436
+ const { wpUrl, authHeader } = resolveSite(sites, site);
437
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/templates/${template_id}`, { headers: { Authorization: authHeader } });
438
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
439
+ }
440
+ catch (error) {
441
+ return { content: [{ type: "text", text: `Error fetching template: ${error.response?.data?.message || error.message}` }], isError: true };
442
+ }
443
+ });
444
+ server.tool("create-template", "Create a new Elementor template of any type (page, section, popup, header, footer).", {
445
+ title: z.string().describe("Template title"),
446
+ type: z.string().describe("Template type: page, section, popup, header, footer"),
447
+ status: z.string().optional().describe("Post status: publish (default) or draft"),
448
+ elements: z.string().optional().describe("Initial elements JSON (stringified array)"),
449
+ site: siteParam,
450
+ }, async ({ title, type, status, elements, site }) => {
451
+ try {
452
+ const { wpUrl, authHeader } = resolveSite(sites, site);
453
+ const body = { title, type, status: status || "publish" };
454
+ if (elements) {
455
+ try {
456
+ body.elements = JSON.parse(elements);
457
+ }
458
+ catch { }
459
+ }
460
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/templates`, body, { headers: { Authorization: authHeader } });
461
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
462
+ }
463
+ catch (error) {
464
+ return { content: [{ type: "text", text: `Error creating template: ${error.response?.data?.message || error.message}` }], isError: true };
465
+ }
466
+ });
467
+ server.tool("update-template", "Modify an existing Elementor template's title, status, or element data.", {
468
+ template_id: z.string().describe("Template ID"),
469
+ title: z.string().optional().describe("New title"),
470
+ status: z.string().optional().describe("New post status"),
471
+ elements: z.string().optional().describe("New elements JSON (stringified array)"),
472
+ site: siteParam,
473
+ }, async ({ template_id, title, status, elements, site }) => {
474
+ try {
475
+ const { wpUrl, authHeader } = resolveSite(sites, site);
476
+ const body = {};
477
+ if (title)
478
+ body.title = title;
479
+ if (status)
480
+ body.status = status;
481
+ if (elements) {
482
+ try {
483
+ body.elements = JSON.parse(elements);
484
+ }
485
+ catch { }
486
+ }
487
+ const r = await axios.put(`${wpUrl}/wp-json/erc/v1/templates/${template_id}`, body, { headers: { Authorization: authHeader } });
488
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
489
+ }
490
+ catch (error) {
491
+ return { content: [{ type: "text", text: `Error updating template: ${error.response?.data?.message || error.message}` }], isError: true };
492
+ }
493
+ });
494
+ server.tool("delete-template", "Move a template to trash or permanently delete it. Set force=true to permanently delete.", {
495
+ template_id: z.string().describe("Template ID"),
496
+ force: z.boolean().optional().describe("Set true to permanently delete instead of trash"),
497
+ site: siteParam,
498
+ }, async ({ template_id, force, site }) => {
499
+ try {
500
+ const { wpUrl, authHeader } = resolveSite(sites, site);
501
+ const r = await axios.delete(`${wpUrl}/wp-json/erc/v1/templates/${template_id}`, {
502
+ headers: { Authorization: authHeader },
503
+ params: { force: force ? "true" : "false" },
504
+ });
505
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
506
+ }
507
+ catch (error) {
508
+ return { content: [{ type: "text", text: `Error deleting template: ${error.response?.data?.message || error.message}` }], isError: true };
509
+ }
510
+ });
511
+ server.tool("duplicate-template", "Create a copy of an Elementor template as a new draft.", {
512
+ template_id: z.string().describe("Template ID to duplicate"),
513
+ site: siteParam,
514
+ }, async ({ template_id, site }) => {
515
+ try {
516
+ const { wpUrl, authHeader } = resolveSite(sites, site);
517
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/templates/${template_id}/duplicate`, {}, { headers: { Authorization: authHeader } });
518
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
519
+ }
520
+ catch (error) {
521
+ return { content: [{ type: "text", text: `Error duplicating template: ${error.response?.data?.message || error.message}` }], isError: true };
522
+ }
523
+ });
524
+ // ── Group 5: Design Intelligence ──────────────────────────────────────────
525
+ server.tool("get-official-pattern-guidance", "Return the official Elementor.com guidance catalog used for widget and layout recommendations.", {}, async () => {
526
+ const guidance = {
527
+ source: "Elementor.com official documentation",
528
+ principles: [
529
+ "Prefer native Elementor widgets over hand-built container patterns when a native widget covers the use case.",
530
+ "Use Grid layout for equal symmetric column groups; use Flexbox for asymmetric or content-driven layouts.",
531
+ "Use Container (Flexbox/Grid) instead of Section/Column for all new layouts — Sections are legacy.",
532
+ "Global colors and typography should come from the active Kit; avoid inline overrides.",
533
+ "Use Global Widgets for repeated components that must stay in sync across pages.",
534
+ "Theme Builder templates (header, footer, single, archive) use display conditions to control scope.",
535
+ "Popups should use the Popup template type and be triggered via Elementor's popup trigger system.",
536
+ "Custom CSS should go in Custom Code snippets (Pro) rather than inline element CSS where possible.",
537
+ "Responsive values should be set per breakpoint; avoid relying on automatic inheritance for spacing.",
538
+ ],
539
+ widget_recommendations: {
540
+ hero: ["Heading widget", "Text Editor widget", "Button widget", "Image widget inside a Container"],
541
+ navigation: ["Nav Menu widget (Pro)", "Theme Builder Header template"],
542
+ testimonials: ["Testimonial Carousel widget (Pro)", "Star Rating widget"],
543
+ pricing: ["Price Table widget (Pro)", "Price List widget (Pro)"],
544
+ forms: ["Form widget (Pro)", "Login widget (Pro)"],
545
+ media: ["Image widget", "Video widget", "Image Carousel widget"],
546
+ },
547
+ };
548
+ return { content: [{ type: "text", text: JSON.stringify(guidance, null, 2) }] };
549
+ });
550
+ server.tool("evaluate-design", "Aggregate all design audits into one score, issue list, and recommendations. Use this as the main entry point for design evaluation.", {
551
+ page_id: z.string().describe("WordPress Page ID"),
552
+ site: siteParam,
553
+ }, async ({ page_id, site }) => {
554
+ try {
555
+ const { wpUrl, authHeader } = resolveSite(sites, site);
556
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/data`, { headers: { Authorization: authHeader } });
557
+ const all = flattenElements(r.data);
558
+ const top = topLevel(r.data);
559
+ const allIssues = [];
560
+ const buttons = all.filter((e) => e.widgetType === "button").length;
561
+ const headings = all.filter((e) => e.widgetType === "heading").length;
562
+ const dividers = all.filter((e) => e.widgetType === "divider").length;
563
+ const icons = all.filter((e) => e.widgetType === "icon-box" || e.widgetType === "icon").length;
564
+ const types = all.map((e) => e.widgetType).filter(Boolean);
565
+ const uniqueT = new Set(types).size;
566
+ if (buttons > 5)
567
+ allIssues.push(`Button overuse: ${buttons} buttons — reduce to 2–3 primary CTAs.`);
568
+ if (headings > top.length * 1.5)
569
+ allIssues.push(`Heading density: ${headings} headings across ${top.length} sections.`);
570
+ if (dividers > top.length)
571
+ allIssues.push(`Separator overuse: ${dividers} dividers for ${top.length} sections.`);
572
+ if (icons > 6)
573
+ allIssues.push(`Icon repetition: ${icons} icon/icon-box widgets — may feel generic.`);
574
+ if (top.length > 8)
575
+ allIssues.push(`Page length: ${top.length} top-level sections — consider merging.`);
576
+ const bgCounts = {};
577
+ top.map((e) => e.settings?.background_color || "none")
578
+ .forEach((b) => { bgCounts[b] = (bgCounts[b] || 0) + 1; });
579
+ Object.entries(bgCounts).filter(([k, v]) => k !== "none" && v > 2)
580
+ .forEach(([bg, count]) => allIssues.push(`Surface repeat: "${bg}" used ${count} times.`));
581
+ const loud = top.filter((e) => {
582
+ const s = e.settings || {};
583
+ return s.background_background === "classic" || s.background_background === "gradient" || s.background_overlay_opacity?.size > 0;
584
+ }).length;
585
+ if (loud > top.length * 0.6)
586
+ allIssues.push(`${loud} of ${top.length} sections use strong backgrounds — too many competing for attention.`);
587
+ const colPatterns = {};
588
+ all.filter((e) => e.elType === "container" && e.elements?.length >= 2)
589
+ .forEach((e) => { const k = `${e.elements.length}-col`; colPatterns[k] = (colPatterns[k] || 0) + 1; });
590
+ Object.entries(colPatterns).filter(([, v]) => v > 3)
591
+ .forEach(([k, v]) => allIssues.push(`Column pattern: ${k} repeated ${v} times.`));
592
+ const flex = all.filter((e) => e.settings?.flex_direction && e.elements?.length >= 3 && e.elements.length <= 4);
593
+ if (flex.length > 0)
594
+ allIssues.push(`${flex.length} Flexbox container(s) with 3–4 equal children — consider Grid layout.`);
595
+ const imageHeadingPairs = all.filter((e) => {
596
+ if (e.elType !== "container")
597
+ return false;
598
+ const childTypes = (e.elements || []).map((c) => c.widgetType);
599
+ return childTypes.includes("image") && childTypes.includes("heading");
600
+ });
601
+ if (imageHeadingPairs.length > 1)
602
+ allIssues.push(`${imageHeadingPairs.length} hand-built image+heading containers — consider using native Image Box widget.`);
603
+ const variety = Math.min(100, Math.round((uniqueT / (types.length || 1)) * 200));
604
+ const penalty = Math.min(50, allIssues.length * 8);
605
+ const score = Math.max(0, Math.round(variety - penalty));
606
+ return { content: [{ type: "text", text: JSON.stringify({ page_id, score, total_issues: allIssues.length, issues: allIssues, widget_variety_score: variety, sections: top.length, total_widgets: types.length }, null, 2) }] };
607
+ }
608
+ catch (e) {
609
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
610
+ }
611
+ });
612
+ server.tool("suggest-design-fixes", "Turn the aggregated design evaluation into concrete, actionable design-fix suggestions.", {
613
+ page_id: z.string().describe("WordPress Page ID"),
614
+ site: siteParam,
615
+ }, async ({ page_id, site }) => {
616
+ try {
617
+ const { wpUrl, authHeader } = resolveSite(sites, site);
618
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/data`, { headers: { Authorization: authHeader } });
619
+ const all = flattenElements(r.data);
620
+ const top = topLevel(r.data);
621
+ const fixes = [];
622
+ const buttons = all.filter((e) => e.widgetType === "button").length;
623
+ if (buttons > 5)
624
+ fixes.push(`Reduce from ${buttons} buttons to 2–3 primary CTAs. Keep one hero CTA and one secondary CTA per page.`);
625
+ const dividers = all.filter((e) => e.widgetType === "divider").length;
626
+ if (dividers > top.length)
627
+ fixes.push(`Remove ${dividers - top.length} excess dividers. Use section padding/margin for spacing instead.`);
628
+ const bgs = {};
629
+ top.forEach((e) => { const b = e.settings?.background_color || "none"; bgs[b] = (bgs[b] || 0) + 1; });
630
+ Object.entries(bgs).filter(([k, v]) => k !== "none" && v > 2)
631
+ .forEach(([, count]) => fixes.push(`Alternate section backgrounds — ${count} consecutive identical surfaces break visual rhythm.`));
632
+ if (top.length > 8)
633
+ fixes.push(`Consider merging related sections — ${top.length} sections is long. Target 5–7 for a focused landing page.`);
634
+ const imageHeading = all.filter((e) => {
635
+ if (e.elType !== "container")
636
+ return false;
637
+ const kids = (e.elements || []).map((c) => c.widgetType);
638
+ return kids.includes("image") && kids.includes("heading");
639
+ }).length;
640
+ if (imageHeading > 1)
641
+ fixes.push(`${imageHeading} hand-built image+heading containers — replace with native Image Box widget for better responsiveness.`);
642
+ const colPatterns = {};
643
+ all.filter((e) => e.elType === "container" && e.elements?.length >= 2)
644
+ .forEach((e) => { const k = `${e.elements.length}-col`; colPatterns[k] = (colPatterns[k] || 0) + 1; });
645
+ Object.entries(colPatterns).filter(([, v]) => v > 3)
646
+ .forEach(([k, v]) => fixes.push(`${k} column layout used ${v} times — vary widths or column count to break repetition.`));
647
+ return { content: [{ type: "text", text: JSON.stringify({ page_id, fix_count: fixes.length, fixes }, null, 2) }] };
648
+ }
649
+ catch (e) {
650
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
651
+ }
652
+ });
653
+ // ── Group 6: Layout Operations ────────────────────────────────────────────
654
+ server.tool("image-widget-to-background-container", "Convert an image-widget container into a native background-image container by moving the image src to the container's background settings.", {
655
+ page_id: z.string(),
656
+ container_id: z.string(),
657
+ site: siteParam,
658
+ }, async ({ page_id, container_id, site }) => {
659
+ try {
660
+ const { wpUrl, authHeader } = resolveSite(sites, site);
661
+ const el = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${container_id}`, { headers: { Authorization: authHeader } });
662
+ const imageChild = (el.data.elements || []).find((c) => c.widgetType === "image");
663
+ if (!imageChild)
664
+ return { content: [{ type: "text", text: "No image widget found as direct child of this container." }], isError: true };
665
+ const imgUrl = imageChild.settings?.image?.url;
666
+ if (!imgUrl)
667
+ return { content: [{ type: "text", text: "Image widget has no URL set." }], isError: true };
668
+ const updated = {
669
+ ...el.data,
670
+ settings: {
671
+ ...el.data.settings,
672
+ background_background: "classic",
673
+ background_image: { url: imgUrl, id: imageChild.settings?.image?.id || "" },
674
+ background_size: "cover",
675
+ background_position: "center center",
676
+ _attributes: { ...(el.data.settings?._attributes || {}), class: ((el.data.settings?._attributes?.class || "") + " e-no-lazyload").trim() },
677
+ },
678
+ elements: (el.data.elements || []).filter((c) => c.id !== imageChild.id),
679
+ };
680
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${container_id}`, updated, { headers: { Authorization: authHeader } });
681
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
682
+ }
683
+ catch (e) {
684
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
685
+ }
686
+ });
687
+ server.tool("enforce-boundary-coherence", "Normalize a subtree to true full-width or coherent boxed left/right boundaries.", {
688
+ page_id: z.string(),
689
+ mode: z.enum(["full-width", "boxed"]).describe("Target boundary mode"),
690
+ root_id: z.string().optional(),
691
+ site: siteParam,
692
+ }, async ({ page_id, mode, root_id, site }) => {
693
+ try {
694
+ const { wpUrl, authHeader } = resolveSite(sites, site);
695
+ const result = await getAndSave(wpUrl, authHeader, page_id, (els) => traverseAndMutate(els, (el) => {
696
+ if (root_id && el.id !== root_id && !root_id)
697
+ return;
698
+ if (el.elType === "container") {
699
+ el.settings = mode === "full-width"
700
+ ? { ...el.settings, content_width: "full", width: { size: 100, unit: "%" } }
701
+ : { ...el.settings, content_width: "boxed" };
702
+ }
703
+ }));
704
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
705
+ }
706
+ catch (e) {
707
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
708
+ }
709
+ });
710
+ server.tool("extract-design-tokens", "Extract recurring colors, typography, spacing, and dimensional tokens from a page or subtree.", {
711
+ page_id: z.string(),
712
+ site: siteParam,
713
+ }, async ({ page_id, site }) => {
714
+ try {
715
+ const { wpUrl, authHeader } = resolveSite(sites, site);
716
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/data`, { headers: { Authorization: authHeader } });
717
+ const all = flattenElements(r.data);
718
+ const colors = {};
719
+ const fonts = {};
720
+ const pads = {};
721
+ all.forEach((el) => {
722
+ const s = el.settings || {};
723
+ if (s.background_color)
724
+ colors[s.background_color] = (colors[s.background_color] || 0) + 1;
725
+ if (s.color)
726
+ colors[s.color] = (colors[s.color] || 0) + 1;
727
+ if (s.typography_font_family)
728
+ fonts[s.typography_font_family] = (fonts[s.typography_font_family] || 0) + 1;
729
+ if (s.padding?.top) {
730
+ const k = s.padding.top + (s.padding.unit || "px");
731
+ pads[k] = (pads[k] || 0) + 1;
732
+ }
733
+ });
734
+ return { content: [{ type: "text", text: JSON.stringify({
735
+ page_id,
736
+ colors: Object.entries(colors).sort(([, a], [, b]) => b - a).slice(0, 10),
737
+ fonts: Object.entries(fonts).sort(([, a], [, b]) => b - a).slice(0, 5),
738
+ padding_values: Object.entries(pads).sort(([, a], [, b]) => b - a).slice(0, 8),
739
+ }, null, 2) }] };
740
+ }
741
+ catch (e) {
742
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
743
+ }
744
+ });
745
+ server.tool("apply-text-hierarchy", "Normalize heading/body/button typography in a subtree using kit system typography values.", {
746
+ page_id: z.string(),
747
+ heading_size: z.string().optional().describe("e.g. 36px"),
748
+ body_size: z.string().optional().describe("e.g. 16px"),
749
+ site: siteParam,
750
+ }, async ({ page_id, heading_size, body_size, site }) => {
751
+ try {
752
+ const { wpUrl, authHeader } = resolveSite(sites, site);
753
+ const result = await getAndSave(wpUrl, authHeader, page_id, (els) => traverseAndMutate(els, (el) => {
754
+ if (el.widgetType === "heading" && heading_size) {
755
+ el.settings = { ...el.settings, typography_font_size: { size: parseInt(heading_size), unit: "px" } };
756
+ }
757
+ if ((el.widgetType === "text-editor" || el.widgetType === "text") && body_size) {
758
+ el.settings = { ...el.settings, typography_font_size: { size: parseInt(body_size), unit: "px" } };
759
+ }
760
+ }));
761
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
762
+ }
763
+ catch (e) {
764
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
765
+ }
766
+ });
767
+ server.tool("normalize-section-spacing-rhythm", "Snap section padding and row gaps to a consistent rhythm value across the page.", {
768
+ page_id: z.string(),
769
+ rhythm: z.string().optional().describe("Spacing unit in px, default 60"),
770
+ site: siteParam,
771
+ }, async ({ page_id, rhythm, site }) => {
772
+ const r = parseInt(rhythm || "60");
773
+ try {
774
+ const { wpUrl, authHeader } = resolveSite(sites, site);
775
+ const result = await getAndSave(wpUrl, authHeader, page_id, (els) => traverseAndMutate(els, (el) => {
776
+ if (el.elType === "container" || el.elType === "section") {
777
+ const s = el.settings || {};
778
+ const snap = (v) => String(Math.round(v / r) * r || r);
779
+ el.settings = { ...s, padding: { ...(s.padding || {}), unit: "px", top: snap(parseFloat(s.padding?.top || "0")), bottom: snap(parseFloat(s.padding?.bottom || "0")) } };
780
+ }
781
+ }));
782
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
783
+ }
784
+ catch (e) {
785
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
786
+ }
787
+ });
788
+ server.tool("normalize-responsive-values", "Fill missing tablet/mobile values from desktop settings with capped inherited side spacing.", {
789
+ page_id: z.string(),
790
+ site: siteParam,
791
+ }, async ({ page_id, site }) => {
792
+ try {
793
+ const { wpUrl, authHeader } = resolveSite(sites, site);
794
+ const result = await getAndSave(wpUrl, authHeader, page_id, (els) => traverseAndMutate(els, (el) => {
795
+ const s = el.settings || {};
796
+ if (s.padding && !s.padding_tablet) {
797
+ el.settings.padding_tablet = { ...s.padding, right: "20", left: "20" };
798
+ }
799
+ if (s.padding && !s.padding_mobile) {
800
+ el.settings.padding_mobile = {
801
+ ...s.padding,
802
+ top: String(Math.round(parseFloat(s.padding.top || "0") * 0.6)),
803
+ bottom: String(Math.round(parseFloat(s.padding.bottom || "0") * 0.6)),
804
+ right: "15",
805
+ left: "15",
806
+ };
807
+ }
808
+ }));
809
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
810
+ }
811
+ catch (e) {
812
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
813
+ }
814
+ });
815
+ server.tool("sync-component-variant", "Copy design-relevant settings from one component subtree to another (colors, typography, spacing — not content).", {
816
+ page_id: z.string(),
817
+ source_id: z.string(),
818
+ target_id: z.string(),
819
+ site: siteParam,
820
+ }, async ({ page_id, source_id, target_id, site }) => {
821
+ const designKeys = ["background_color", "background_background", "color", "typography_font_family", "typography_font_size", "padding", "margin", "border_radius", "box_shadow", "_css_classes"];
822
+ try {
823
+ const { wpUrl, authHeader } = resolveSite(sites, site);
824
+ const src = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${source_id}`, { headers: { Authorization: authHeader } });
825
+ const tgt = await axios.get(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${target_id}`, { headers: { Authorization: authHeader } });
826
+ const designSettings = Object.fromEntries(designKeys.map(k => [k, src.data.settings?.[k]]).filter(([, v]) => v !== undefined));
827
+ const merged = { ...tgt.data, settings: { ...tgt.data.settings, ...designSettings } };
828
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/pages/${page_id}/elements/${target_id}`, merged, { headers: { Authorization: authHeader } });
829
+ return { content: [{ type: "text", text: JSON.stringify({ synced: Object.keys(designSettings), result: r.data }, null, 2) }] };
830
+ }
831
+ catch (e) {
832
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
833
+ }
834
+ });
835
+ // ── Group 7: Pro Features ─────────────────────────────────────────────────
836
+ server.tool("list-custom-code", "List all custom code snippets (requires Elementor Pro). Optionally filter by status.", {
837
+ status: z.string().optional(),
838
+ site: siteParam,
839
+ }, async ({ status, site }) => {
840
+ try {
841
+ const { wpUrl, authHeader } = resolveSite(sites, site);
842
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/site/custom-code`, {
843
+ headers: { Authorization: authHeader },
844
+ params: status ? { status } : {},
845
+ });
846
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
847
+ }
848
+ catch (e) {
849
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
850
+ }
851
+ });
852
+ server.tool("create-custom-code", "Create a new custom code snippet (requires Elementor Pro).", {
853
+ title: z.string(),
854
+ code: z.string(),
855
+ location: z.string().optional().describe("head or body"),
856
+ status: z.string().optional(),
857
+ site: siteParam,
858
+ }, async ({ title, code, location, status, site }) => {
859
+ try {
860
+ const { wpUrl, authHeader } = resolveSite(sites, site);
861
+ const r = await axios.post(`${wpUrl}/wp-json/erc/v1/site/custom-code`, { title, code, location: location || "head", status: status || "draft" }, { headers: { Authorization: authHeader } });
862
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
863
+ }
864
+ catch (e) {
865
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
866
+ }
867
+ });
868
+ server.tool("update-custom-code", "Update a custom code snippet (requires Elementor Pro).", {
869
+ snippet_id: z.string(),
870
+ title: z.string().optional(),
871
+ code: z.string().optional(),
872
+ location: z.string().optional(),
873
+ status: z.string().optional(),
874
+ site: siteParam,
875
+ }, async ({ snippet_id, title, code, location, status, site }) => {
876
+ try {
877
+ const { wpUrl, authHeader } = resolveSite(sites, site);
878
+ const body = {};
879
+ if (title)
880
+ body.title = title;
881
+ if (code)
882
+ body.code = code;
883
+ if (location)
884
+ body.location = location;
885
+ if (status)
886
+ body.status = status;
887
+ const r = await axios.put(`${wpUrl}/wp-json/erc/v1/site/custom-code/${snippet_id}`, body, { headers: { Authorization: authHeader } });
888
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
889
+ }
890
+ catch (e) {
891
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
892
+ }
893
+ });
894
+ server.tool("delete-custom-code", "Delete a custom code snippet (requires Elementor Pro).", {
895
+ snippet_id: z.string(),
896
+ site: siteParam,
897
+ }, async ({ snippet_id, site }) => {
898
+ try {
899
+ const { wpUrl, authHeader } = resolveSite(sites, site);
900
+ const r = await axios.delete(`${wpUrl}/wp-json/erc/v1/site/custom-code/${snippet_id}`, { headers: { Authorization: authHeader } });
901
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
902
+ }
903
+ catch (e) {
904
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
905
+ }
906
+ });
907
+ server.tool("list-form-submissions", "List Elementor Pro form submissions. Optionally filter by form_id or date range (requires Elementor Pro).", {
908
+ form_id: z.string().optional().describe("Filter by specific form ID"),
909
+ date_after: z.string().optional().describe("ISO date string — return submissions after this date"),
910
+ per_page: z.number().optional().describe("Number of results per page (default 20)"),
911
+ site: siteParam,
912
+ }, async ({ form_id, date_after, per_page, site }) => {
913
+ try {
914
+ const { wpUrl, authHeader } = resolveSite(sites, site);
915
+ const params = {};
916
+ if (form_id)
917
+ params.form_id = form_id;
918
+ if (date_after)
919
+ params.date_after = date_after;
920
+ if (per_page)
921
+ params.per_page = per_page;
922
+ const r = await axios.get(`${wpUrl}/wp-json/erc/v1/site/form-submissions`, { headers: { Authorization: authHeader }, params });
923
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
924
+ }
925
+ catch (e) {
926
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
927
+ }
928
+ });
929
+ server.tool("delete-form-submission", "Delete a single Elementor Pro form submission.", {
930
+ submission_id: z.string(),
931
+ site: siteParam,
932
+ }, async ({ submission_id, site }) => {
933
+ try {
934
+ const { wpUrl, authHeader } = resolveSite(sites, site);
935
+ const r = await axios.delete(`${wpUrl}/wp-json/erc/v1/site/form-submissions/${submission_id}`, { headers: { Authorization: authHeader } });
936
+ return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
937
+ }
938
+ catch (e) {
939
+ return { content: [{ type: "text", text: `Error: ${e.response?.data?.message || e.message}` }], isError: true };
940
+ }
941
+ });
942
+ return server;
943
+ }
944
+ // ─── Entry Point ──────────────────────────────────────────────────────────────
945
+ const command = process.argv[2];
946
+ if (command === "setup") {
947
+ await runSetup();
948
+ }
949
+ else {
950
+ const sites = loadConfig();
951
+ if (sites.length === 0) {
952
+ console.error("No sites configured. Run first: npx elementor-mcp setup");
953
+ process.exit(1);
954
+ }
955
+ const server = createMcpServer(sites);
956
+ const transport = new StdioServerTransport();
957
+ await server.connect(transport);
958
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@yashwant.dharmdas/elementor-mcp",
3
+ "version": "3.0.0",
4
+ "description": "MCP server for controlling Elementor via Claude — supports multiple WordPress sites",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "elementor-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist/",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsx src/index.ts"
17
+ },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.26.0",
20
+ "ajv": "^8.12.0",
21
+ "axios": "^1.6.0",
22
+ "zod": "^3.22.4"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^20.11.24",
26
+ "tsx": "^4.21.0",
27
+ "typescript": "^5.3.3"
28
+ },
29
+ "engines": {
30
+ "node": ">=18.0.0"
31
+ },
32
+ "keywords": [
33
+ "mcp",
34
+ "elementor",
35
+ "wordpress",
36
+ "claude",
37
+ "ai"
38
+ ],
39
+ "license": "MIT"
40
+ }